From d2995266061b99bda04c0d5633a4bccbb89fa50f Mon Sep 17 00:00:00 2001 From: magges Date: Tue, 30 Jun 2026 23:06:36 +0200 Subject: [PATCH] fix: preserve fast gif frame timing --- crates/aster-webui/src/display.rs | 38 +++++++++++++----- crates/aster-webui/src/images.rs | 67 +++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/crates/aster-webui/src/display.rs b/crates/aster-webui/src/display.rs index 4cbda30..1e7013a 100644 --- a/crates/aster-webui/src/display.rs +++ b/crates/aster-webui/src/display.rs @@ -10,13 +10,14 @@ use chrono::Utc; use tracing::{error, info, warn}; use crate::{ - images::{is_gif_manifest_name, load_gif_manifest, load_panel_rgb}, + images::{is_gif_manifest_name, load_gif_manifest, load_panel_rgb, MIN_GIF_FRAME_DELAY_MS}, monitor::{load_monitor_json_sync, rotation_snapshot}, system::{overlay_system_specs, render_system_panel}, types::{ActiveFrame, AppState, DisplayConfig, DisplayStatus, RotationSlot, RotationSnapshot}, }; const SYSTEM_FRAME_NAME: &str = "System Specs"; +const IDLE_SLEEP_MS: u64 = 750; pub(crate) fn initial_display_status(config: &DisplayConfig) -> DisplayStatus { let mut status = DisplayStatus { @@ -156,7 +157,7 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> { }); if !snapshot.rotation_active() { - thread::sleep(Duration::from_millis(750)); + thread::sleep(Duration::from_millis(IDLE_SLEEP_MS)); continue; } @@ -246,7 +247,9 @@ fn build_rotation_slots( display_name: format!("{} [{}]", manifest.label, idx + 1), animated: true, }, - Duration::from_millis(frame.delay_ms.max(100) as u64), + Duration::from_millis( + frame.delay_ms.max(MIN_GIF_FRAME_DELAY_MS) as u64 + ), )); } slots.push(RotationSlot { @@ -369,7 +372,7 @@ fn frame_cycle_ms(slot: &RotationSlot) -> u64 { .iter() .map(|(_, delay)| delay.as_millis() as u64) .sum::() - .max(100) + .max(1) } fn prepare_screen_for_frame(screen: &mut AooScreen, frame: &ActiveFrame) { @@ -385,18 +388,18 @@ fn rotation_sleep( cycle_started_at: Instant, ) -> Duration { if !rotation_active || slots.is_empty() { - return Duration::from_millis(750); + return Duration::from_millis(IDLE_SLEEP_MS); } let total_frames: usize = slots.iter().map(|slot| slot.frames.len()).sum(); if total_frames <= 1 { - return Duration::from_millis(750); + return Duration::from_millis(IDLE_SLEEP_MS); } let cycle_ms: u64 = slots .iter() .map(|slot| slot.total_duration.as_millis() as u64) .sum::() - .max(100); + .max(1); let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms; let mut cursor = 0u64; @@ -404,7 +407,7 @@ fn rotation_sleep( let slot_ms = slot.total_duration.as_millis() as u64; if elapsed_ms < cursor + slot_ms { if slot.frames.len() <= 1 { - return Duration::from_millis(750); + return Duration::from_millis(IDLE_SLEEP_MS); } let local_ms = elapsed_ms - cursor; let frame_elapsed_ms = local_ms % frame_cycle_ms(slot); @@ -415,7 +418,9 @@ fn rotation_sleep( let frame_remaining = frame_cursor + delay_ms - frame_elapsed_ms; let slot_remaining = slot_ms.saturating_sub(local_ms); return Duration::from_millis( - frame_remaining.min(slot_remaining).clamp(100, 750), + frame_remaining + .min(slot_remaining) + .clamp(MIN_GIF_FRAME_DELAY_MS as u64, IDLE_SLEEP_MS), ); } frame_cursor += delay_ms; @@ -533,6 +538,21 @@ mod tests { assert_eq!(slot_frame_at(&slot, 0, 9_950).unwrap().1.key, "gif#0"); } + #[test] + fn rotation_sleep_preserves_fast_gif_frames() { + let slots = vec![RotationSlot { + total_duration: Duration::from_secs(2), + frames: vec![ + (test_frame("gif#0"), Duration::from_millis(50)), + (test_frame("gif#1"), Duration::from_millis(50)), + ], + }]; + + let sleep = rotation_sleep(true, &slots, Instant::now()); + assert!(sleep <= Duration::from_millis(50)); + assert!(sleep >= Duration::from_millis(MIN_GIF_FRAME_DELAY_MS as u64)); + } + #[test] fn build_rotation_slots_uses_switch_time_for_gif_slot_duration() { let unique = SystemTime::now() diff --git a/crates/aster-webui/src/images.rs b/crates/aster-webui/src/images.rs index b5f44ae..075914c 100644 --- a/crates/aster-webui/src/images.rs +++ b/crates/aster-webui/src/images.rs @@ -15,6 +15,7 @@ use crate::{ }; pub(crate) const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif"); +pub(crate) const MIN_GIF_FRAME_DELAY_MS: u32 = 33; const GIF_FRAME_PREFIX: &str = ".gifframe-"; const GIF_MANIFEST_SUFFIX: &str = ".gifset.json"; @@ -95,13 +96,25 @@ async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8]) 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(); + let mut rendered_frames = Vec::new(); - for (idx, frame) in frames.into_iter().enumerate() { + for frame in frames { let delay_ms = frame_delay_ms(&frame); + let rendered = render_display_panel(&DynamicImage::ImageRgba8(frame.into_buffer())); + rendered_frames.push((rendered, delay_ms)); + } + + let collapsed_frames = collapse_rendered_frames(rendered_frames); + if collapsed_frames.len() < 2 { + anyhow::bail!( + "GIF contains no visible animation after decoding; please upload a truly animated GIF" + ); + } + + let mut stored_frames = Vec::new(); + for (idx, (rendered, delay_ms)) in collapsed_frames.into_iter().enumerate() { 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()))?; @@ -128,6 +141,23 @@ async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8]) Ok(manifest_name) } +fn collapse_rendered_frames(frames: Vec<(RgbImage, u32)>) -> Vec<(RgbImage, u32)> { + let mut collapsed: Vec<(RgbImage, u32)> = Vec::new(); + + for (frame, delay_ms) in frames { + if let Some((prev_frame, prev_delay_ms)) = collapsed.last_mut() + && *prev_frame == frame + { + *prev_delay_ms = prev_delay_ms.saturating_add(delay_ms); + continue; + } + + collapsed.push((frame, delay_ms)); + } + + collapsed +} + pub(crate) fn render_display_panel(image: &DynamicImage) -> RgbImage { let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3); let rgb = resized.to_rgb8(); @@ -187,7 +217,7 @@ fn frame_delay_ms(frame: &Frame) -> u32 { } else { ((numer as f64) / (denom as f64)).round() as u32 }; - delay.max(100) + delay.max(MIN_GIF_FRAME_DELAY_MS) } pub(crate) async fn delete_image_asset(state: &AppState, name: &str) -> Result<()> { @@ -228,3 +258,32 @@ pub(crate) fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result