From 3bcd5fe6187771c39f269bbf87b0ebfcbd98ec87 Mon Sep 17 00:00:00 2001 From: magges Date: Fri, 19 Jun 2026 20:00:16 +0200 Subject: [PATCH] Add animated GIF support to aster-webui --- crates/aster-webui/src/main.rs | 477 ++++++++++++++++++++++++++------- 1 file changed, 385 insertions(+), 92 deletions(-) diff --git a/crates/aster-webui/src/main.rs b/crates/aster-webui/src/main.rs index 5c53039..0956491 100644 --- a/crates/aster-webui/src/main.rs +++ b/crates/aster-webui/src/main.rs @@ -1,4 +1,5 @@ use std::{ + io::Cursor, net::SocketAddr, path::PathBuf, sync::{Arc, OnceLock, RwLock}, @@ -20,7 +21,10 @@ use axum::{ }; use chrono::{Local, Utc}; use clap::Parser; -use image::{DynamicImage, ImageFormat, Rgb, RgbImage, imageops::FilterType}; +use image::{ + AnimationDecoder, DynamicImage, Frame, ImageFormat, Rgb, RgbImage, codecs::gif::GifDecoder, + imageops::FilterType, +}; use imageproc::{ drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut}, rect::Rect, @@ -35,6 +39,8 @@ const DISPLAY_WIDTH: u32 = 960; const DISPLAY_HEIGHT: u32 = 376; const SYSTEM_FRAME_NAME: &str = "System Specs"; const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif"); +const GIF_FRAME_PREFIX: &str = ".gifframe-"; +const GIF_MANIFEST_SUFFIX: &str = ".gifset.json"; #[derive(Parser, Debug)] #[command(author, version, about)] @@ -82,25 +88,36 @@ struct RotationSnapshot { impl RotationSnapshot { fn rotation_active(&self) -> bool { - self.custom_panel && !self.frames().is_empty() - } - - fn frames(&self) -> Vec { - let mut frames = Vec::new(); - if self.memes_enabled { - frames.extend(self.active_images.iter().cloned().map(RotationFrame::Image)); - } - if frames.is_empty() && self.specs_enabled { - frames.push(RotationFrame::SystemSpecs); - } - frames + self.custom_panel && (self.specs_enabled || (self.memes_enabled && !self.active_images.is_empty())) } } -#[derive(Clone, Debug, PartialEq, Eq)] -enum RotationFrame { - SystemSpecs, - Image(String), +#[derive(Clone, Debug, Serialize, Deserialize)] +struct GifFrameMeta { + name: String, + delay_ms: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct GifSetManifest { + kind: String, + version: u32, + label: String, + preview: String, + frames: Vec, +} + +#[derive(Clone, Debug)] +struct ActiveFrame { + key: String, + image_name: String, + display_name: String, +} + +#[derive(Clone, Debug)] +struct RotationSlot { + total_duration: Duration, + frames: Vec<(ActiveFrame, Duration)>, } #[derive(Clone, Serialize)] @@ -125,8 +142,11 @@ struct SetupView { #[derive(Clone, Serialize)] struct ImageView { name: String, + label: String, size: u64, url: String, + animated: bool, + frame_count: usize, } #[derive(Clone, Debug, Default, Serialize)] @@ -472,13 +492,13 @@ async fn api_delete( return error_response(StatusCode::BAD_REQUEST, "Invalid file name"); } - let path = state.image_dir.join(&payload.name); - match fs::remove_file(&path).await { - Ok(_) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return error_response(StatusCode::NOT_FOUND, "Image not found"); - } - Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + if let Err(err) = delete_image_asset(&state, &payload.name).await { + return match err.downcast_ref::() { + Some(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => { + error_response(StatusCode::NOT_FOUND, "Image not found") + } + _ => error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()), + }; } let mut monitor = match load_monitor_json(&state).await { @@ -716,12 +736,32 @@ async fn list_images(state: &AppState) -> Result> { let Some(name) = path.file_name().map(|name| name.to_string_lossy().to_string()) else { continue; }; + if is_gif_frame_name(&name) { + continue; + } let meta = entry.metadata().await?; - out.push(ImageView { - url: format!("/api/images/{name}"), - name, - size: meta.len(), - }); + if is_gif_manifest_name(&name) { + let raw = fs::read_to_string(&path).await?; + let manifest: GifSetManifest = serde_json::from_str(&raw) + .with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?; + out.push(ImageView { + url: format!("/api/images/{}", manifest.preview), + name, + label: manifest.label, + size: meta.len(), + animated: true, + frame_count: manifest.frames.len(), + }); + } else { + out.push(ImageView { + url: format!("/api/images/{name}"), + label: name.clone(), + name, + size: meta.len(), + animated: false, + frame_count: 1, + }); + } } out.sort_by(|a, b| a.name.cmp(&b.name)); Ok(out) @@ -821,6 +861,10 @@ fn set_custom_panels( } async fn convert_and_store_image(state: &AppState, file_name: &str, bytes: &[u8]) -> Result { + if is_gif_file(file_name, bytes) { + return convert_and_store_gif(state, file_name, bytes).await; + } + let image = image::load_from_memory(bytes) .with_context(|| format!("Unsupported image format: {file_name}"))?; let target_name = make_image_name(file_name); @@ -832,6 +876,53 @@ async fn convert_and_store_image(state: &AppState, file_name: &str, bytes: &[u8] Ok(target_name) } +async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8]) -> Result { + let decoder = GifDecoder::new(Cursor::new(bytes)) + .with_context(|| format!("Unsupported GIF format: {file_name}"))?; + let frames = decoder + .into_frames() + .collect_frames() + .context("Failed to decode GIF frames")?; + if frames.is_empty() { + anyhow::bail!("GIF contains no frames"); + } + + let base = make_image_stem(file_name); + let timestamp = Local::now().format("%Y%m%d-%H%M%S"); + let manifest_name = format!("panel-{timestamp}-{base}{GIF_MANIFEST_SUFFIX}"); + let mut stored_frames = Vec::new(); + + for (idx, frame) in frames.into_iter().enumerate() { + let delay_ms = frame_delay_ms(&frame); + let frame_name = format!("{GIF_FRAME_PREFIX}{timestamp}-{base}-{idx:03}.jpg"); + let frame_path = state.image_dir.join(&frame_name); + let rendered = render_display_panel(&DynamicImage::ImageRgba8(frame.into_buffer())); + rendered + .save_with_format(&frame_path, ImageFormat::Jpeg) + .with_context(|| format!("Failed to save {}", frame_path.display()))?; + stored_frames.push(GifFrameMeta { + name: frame_name, + delay_ms, + }); + } + + let manifest = GifSetManifest { + kind: "gif_set".into(), + version: 1, + label: file_name.to_string(), + preview: stored_frames + .first() + .map(|frame| frame.name.clone()) + .unwrap_or_default(), + frames: stored_frames, + }; + let manifest_path = state.image_dir.join(&manifest_name); + fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?) + .await + .with_context(|| format!("Failed to save {}", manifest_path.display()))?; + Ok(manifest_name) +} + fn render_display_panel(image: &DynamicImage) -> RgbImage { let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3); let rgb = resized.to_rgb8(); @@ -1052,6 +1143,12 @@ fn display_font() -> &'static FontArc { } fn make_image_name(file_name: &str) -> String { + let clean = make_image_stem(file_name); + let timestamp = Local::now().format("%Y%m%d-%H%M%S"); + format!("panel-{timestamp}-{clean}.jpg") +} + +fn make_image_stem(file_name: &str) -> String { let base = file_name .rsplit_once('.') .map(|(name, _)| name) @@ -1064,9 +1161,181 @@ fn make_image_name(file_name: &str) -> String { }) .collect(); let clean = clean.trim_matches('-'); - let clean = if clean.is_empty() { "panel" } else { clean }; - let timestamp = Local::now().format("%Y%m%d-%H%M%S"); - format!("panel-{timestamp}-{clean}.jpg") + if clean.is_empty() { + "panel".into() + } else { + clean.to_string() + } +} + +fn is_gif_file(file_name: &str, bytes: &[u8]) -> bool { + file_name.to_ascii_lowercase().ends_with(".gif") || bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") +} + +fn is_gif_manifest_name(name: &str) -> bool { + name.ends_with(GIF_MANIFEST_SUFFIX) +} + +fn is_gif_frame_name(name: &str) -> bool { + name.starts_with(GIF_FRAME_PREFIX) +} + +fn frame_delay_ms(frame: &Frame) -> u32 { + let (numer, denom) = frame.delay().numer_denom_ms(); + let delay = if denom == 0 { + numer + } else { + ((numer as f64) / (denom as f64)).round() as u32 + }; + delay.max(100) +} + +async fn delete_image_asset(state: &AppState, name: &str) -> Result<()> { + let path = state.image_dir.join(name); + if is_gif_manifest_name(name) { + let raw = fs::read_to_string(&path) + .await + .with_context(|| format!("Failed to read {}", path.display()))?; + let manifest: GifSetManifest = serde_json::from_str(&raw) + .with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?; + for frame in manifest.frames { + let frame_path = state.image_dir.join(frame.name); + match fs::remove_file(&frame_path).await { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + } + } + + fs::remove_file(&path) + .await + .with_context(|| format!("Failed to remove {}", path.display()))?; + Ok(()) +} + +fn load_gif_manifest(image_dir: &PathBuf, name: &str) -> Result { + let path = image_dir.join(name); + let raw = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read GIF manifest {}", path.display()))?; + serde_json::from_str(&raw) + .with_context(|| format!("Failed to parse GIF manifest {}", path.display())) +} + +fn build_rotation_slots(snapshot: &RotationSnapshot, image_dir: &PathBuf) -> Vec { + let mut slots = Vec::new(); + if snapshot.memes_enabled { + for name in &snapshot.active_images { + if is_gif_manifest_name(name) { + match load_gif_manifest(image_dir, name) { + Ok(manifest) if !manifest.frames.is_empty() => { + let mut total_ms = 0u64; + let mut frames = Vec::new(); + for (idx, frame) in manifest.frames.iter().enumerate() { + total_ms += frame.delay_ms.max(100) as u64; + frames.push(( + ActiveFrame { + key: format!("{name}#{idx}"), + image_name: frame.name.clone(), + display_name: manifest.label.clone(), + }, + Duration::from_millis(frame.delay_ms.max(100) as u64), + )); + } + slots.push(RotationSlot { + total_duration: Duration::from_millis(total_ms.max(100)), + frames, + }); + } + Ok(_) => {} + Err(err) => warn!("Skipping GIF set {name}: {err}"), + } + } else { + slots.push(RotationSlot { + total_duration: Duration::from_secs(snapshot.switch_time as u64), + frames: vec![( + ActiveFrame { + key: name.clone(), + image_name: name.clone(), + display_name: name.clone(), + }, + Duration::from_secs(snapshot.switch_time as u64), + )], + }); + } + } + } + if slots.is_empty() && snapshot.specs_enabled { + slots.push(RotationSlot { + total_duration: Duration::from_secs(snapshot.switch_time as u64), + frames: vec![( + ActiveFrame { + key: SYSTEM_FRAME_NAME.into(), + image_name: SYSTEM_FRAME_NAME.into(), + display_name: SYSTEM_FRAME_NAME.into(), + }, + Duration::from_secs(snapshot.switch_time as u64), + )], + }); + } + slots +} + +fn current_slot_frame( + slots: &[RotationSlot], + specs_only: bool, + switch_time: u32, + cycle_started_at: Instant, +) -> Option<(usize, ActiveFrame)> { + if specs_only { + return Some(( + 0, + ActiveFrame { + key: SYSTEM_FRAME_NAME.into(), + image_name: SYSTEM_FRAME_NAME.into(), + display_name: SYSTEM_FRAME_NAME.into(), + }, + )); + } + if slots.is_empty() { + return None; + } + + let cycle_ms: u64 = slots + .iter() + .map(|slot| slot.total_duration.as_millis() as u64) + .sum::() + .max((switch_time as u64).max(1) * 1000); + let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms; + let mut cursor = 0u64; + + for (slot_idx, slot) in slots.iter().enumerate() { + let slot_ms = slot.total_duration.as_millis() as u64; + if elapsed_ms < cursor + slot_ms { + if slot.frames.len() == 1 { + return Some((slot_idx, slot.frames[0].0.clone())); + } + let local_ms = elapsed_ms - cursor; + let mut frame_cursor = 0u64; + for (frame, delay) in &slot.frames { + let delay_ms = delay.as_millis() as u64; + if local_ms < frame_cursor + delay_ms { + return Some((slot_idx, frame.clone())); + } + frame_cursor += delay_ms; + } + return slot + .frames + .last() + .map(|(frame, _)| (slot_idx, frame.clone())); + } + cursor += slot_ms; + } + + slots + .last() + .and_then(|slot| slot.frames.last().map(|(frame, _)| frame.clone())) + .map(|frame| (slots.len() - 1, frame)) } fn spawn_display_worker(state: Arc, config: DisplayConfig) { @@ -1141,7 +1410,8 @@ fn open_screen(config: &DisplayConfig) -> Result { fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { let mut snapshot = RotationSnapshot::default(); let mut cycle_started_at = Instant::now(); - let mut current_frame_index = None; + let mut current_frame_key: Option = None; + let mut slots: Vec = Vec::new(); loop { let monitor = match load_monitor_json_sync(&state.monitor_path) { @@ -1161,11 +1431,10 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { if new_snapshot != snapshot { snapshot = new_snapshot; cycle_started_at = Instant::now(); - current_frame_index = None; + current_frame_key = None; + slots = build_rotation_slots(&snapshot, &state.image_dir); } - let frames = snapshot.frames(); - update_display_status(&state.display_status, |status| { status.custom_panel = snapshot.custom_panel; status.specs_enabled = snapshot.specs_enabled; @@ -1185,16 +1454,13 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { continue; } - let switch_after = Duration::from_secs(snapshot.switch_time as u64); - let elapsed = cycle_started_at.elapsed(); - let frame_slot = elapsed.as_secs() / snapshot.switch_time as u64; - let next_index = (frame_slot as usize) % frames.len(); - let send_due = current_frame_index != Some(next_index); - - if send_due { - let frame = frames[next_index].clone(); - match frame { - RotationFrame::SystemSpecs => { + let specs_only = snapshot.specs_enabled && (!snapshot.memes_enabled || snapshot.active_images.is_empty()); + if let Some((_slot_idx, active_frame)) = + current_slot_frame(&slots, specs_only, snapshot.switch_time, cycle_started_at) + { + let send_due = current_frame_key.as_deref() != Some(active_frame.key.as_str()); + if send_due { + if active_frame.image_name == SYSTEM_FRAME_NAME { let panel_name = SYSTEM_FRAME_NAME.to_string(); let rgb_img = render_system_panel(); screen @@ -1207,40 +1473,44 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { status.connected = true; status.updated_at = Some(stamp_now()); }); - } - RotationFrame::Image(image_name) => match load_panel_rgb(&state.image_dir, &image_name) - { - Ok(mut rgb_img) => { - if snapshot.specs_enabled { - overlay_system_specs(&mut rgb_img); - } - screen - .send_image(&rgb_img) - .with_context(|| format!("Failed to send panel {image_name}"))?; + } else { + match load_panel_rgb(&state.image_dir, &active_frame.image_name) { + Ok(mut rgb_img) => { + if snapshot.specs_enabled { + overlay_system_specs(&mut rgb_img); + } + screen + .send_image(&rgb_img) + .with_context(|| { + format!("Failed to send panel {}", active_frame.image_name) + })?; - update_display_status(&state.display_status, |status| { - status.current_image = Some(image_name.clone()); - status.last_error = None; - status.connected = true; - status.updated_at = Some(stamp_now()); - }); + update_display_status(&state.display_status, |status| { + status.current_image = Some(active_frame.display_name.clone()); + status.last_error = None; + status.connected = true; + status.updated_at = Some(stamp_now()); + }); + } + Err(err) => { + warn!("Skipping panel {}: {err}", active_frame.image_name); + update_display_status(&state.display_status, |status| { + status.current_image = Some(active_frame.display_name.clone()); + status.last_error = Some(format!( + "Panel {} could not be rendered: {err}", + active_frame.display_name + )); + status.connected = true; + status.updated_at = Some(stamp_now()); + }); + } } - Err(err) => { - warn!("Skipping panel {image_name}: {err}"); - update_display_status(&state.display_status, |status| { - status.current_image = Some(image_name.clone()); - status.last_error = - Some(format!("Panel {image_name} could not be rendered: {err}")); - status.connected = true; - status.updated_at = Some(stamp_now()); - }); - } - }, + } + current_frame_key = Some(active_frame.key); } - current_frame_index = Some(next_index); } - thread::sleep(rotation_sleep(frames.len(), switch_after, cycle_started_at)); + thread::sleep(rotation_sleep(snapshot.rotation_active(), &slots, cycle_started_at)); } } @@ -1257,22 +1527,41 @@ fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result { Ok(image.to_rgb8()) } -fn rotation_sleep(frame_count: usize, switch_after: Duration, cycle_started_at: Instant) -> Duration { - if frame_count <= 1 { +fn rotation_sleep(rotation_active: bool, slots: &[RotationSlot], cycle_started_at: Instant) -> Duration { + if !rotation_active || slots.is_empty() { + return Duration::from_millis(750); + } + let total_frames: usize = slots.iter().map(|slot| slot.frames.len()).sum(); + if total_frames <= 1 { return Duration::from_millis(750); } - let elapsed = cycle_started_at.elapsed(); - let switch_secs = switch_after.as_secs().max(1); - let next_boundary_secs = ((elapsed.as_secs() / switch_secs) + 1) * switch_secs; - let remaining = Duration::from_secs(next_boundary_secs).saturating_sub(elapsed); - if remaining > Duration::from_millis(750) { - Duration::from_millis(750) - } else if remaining < Duration::from_millis(200) { - Duration::from_millis(200) - } else { - remaining + let cycle_ms: u64 = slots + .iter() + .map(|slot| slot.total_duration.as_millis() as u64) + .sum::() + .max(100); + let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms; + let mut cursor = 0u64; + + for slot in slots { + let slot_ms = slot.total_duration.as_millis() as u64; + if elapsed_ms < cursor + slot_ms { + let local_ms = elapsed_ms - cursor; + let mut frame_cursor = 0u64; + for (_, delay) in &slot.frames { + let delay_ms = delay.as_millis() as u64; + if local_ms < frame_cursor + delay_ms { + let remaining = frame_cursor + delay_ms - local_ms; + return Duration::from_millis(remaining.clamp(100, 750)); + } + frame_cursor += delay_ms; + } + } + cursor += slot_ms; } + + Duration::from_millis(250) } fn read_display_status(status: &Arc>) -> DisplayStatus { @@ -1455,7 +1744,7 @@ const INDEX_HTML: &str = r##"

aoostar-rs fork

aster-webui

-

Eigene Rust-WebUI mit nativer Display-Logik. Uploads werden auf 960x376 JPG normalisiert; die Rotation laeuft direkt ueber `aoostar-rs` ohne Vendor-Binary.

+

Eigene Rust-WebUI mit nativer Display-Logik. Bilder werden auf 960x376 normalisiert, GIFs als animierte Frame-Sets importiert; die Rotation laeuft direkt ueber `aoostar-rs` ohne Vendor-Binary.