fix: preserve fast gif frame timing
Build And Push Container / build-and-push (push) Successful in 56s
Build And Push Container / build-and-push (push) Successful in 56s
This commit is contained in:
@@ -10,13 +10,14 @@ use chrono::Utc;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use crate::{
|
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},
|
monitor::{load_monitor_json_sync, rotation_snapshot},
|
||||||
system::{overlay_system_specs, render_system_panel},
|
system::{overlay_system_specs, render_system_panel},
|
||||||
types::{ActiveFrame, AppState, DisplayConfig, DisplayStatus, RotationSlot, RotationSnapshot},
|
types::{ActiveFrame, AppState, DisplayConfig, DisplayStatus, RotationSlot, RotationSnapshot},
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYSTEM_FRAME_NAME: &str = "System Specs";
|
const SYSTEM_FRAME_NAME: &str = "System Specs";
|
||||||
|
const IDLE_SLEEP_MS: u64 = 750;
|
||||||
|
|
||||||
pub(crate) fn initial_display_status(config: &DisplayConfig) -> DisplayStatus {
|
pub(crate) fn initial_display_status(config: &DisplayConfig) -> DisplayStatus {
|
||||||
let mut status = DisplayStatus {
|
let mut status = DisplayStatus {
|
||||||
@@ -156,7 +157,7 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if !snapshot.rotation_active() {
|
if !snapshot.rotation_active() {
|
||||||
thread::sleep(Duration::from_millis(750));
|
thread::sleep(Duration::from_millis(IDLE_SLEEP_MS));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +247,9 @@ fn build_rotation_slots(
|
|||||||
display_name: format!("{} [{}]", manifest.label, idx + 1),
|
display_name: format!("{} [{}]", manifest.label, idx + 1),
|
||||||
animated: true,
|
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 {
|
slots.push(RotationSlot {
|
||||||
@@ -369,7 +372,7 @@ fn frame_cycle_ms(slot: &RotationSlot) -> u64 {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(_, delay)| delay.as_millis() as u64)
|
.map(|(_, delay)| delay.as_millis() as u64)
|
||||||
.sum::<u64>()
|
.sum::<u64>()
|
||||||
.max(100)
|
.max(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepare_screen_for_frame(screen: &mut AooScreen, frame: &ActiveFrame) {
|
fn prepare_screen_for_frame(screen: &mut AooScreen, frame: &ActiveFrame) {
|
||||||
@@ -385,18 +388,18 @@ fn rotation_sleep(
|
|||||||
cycle_started_at: Instant,
|
cycle_started_at: Instant,
|
||||||
) -> Duration {
|
) -> Duration {
|
||||||
if !rotation_active || slots.is_empty() {
|
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();
|
let total_frames: usize = slots.iter().map(|slot| slot.frames.len()).sum();
|
||||||
if total_frames <= 1 {
|
if total_frames <= 1 {
|
||||||
return Duration::from_millis(750);
|
return Duration::from_millis(IDLE_SLEEP_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cycle_ms: u64 = slots
|
let cycle_ms: u64 = slots
|
||||||
.iter()
|
.iter()
|
||||||
.map(|slot| slot.total_duration.as_millis() as u64)
|
.map(|slot| slot.total_duration.as_millis() as u64)
|
||||||
.sum::<u64>()
|
.sum::<u64>()
|
||||||
.max(100);
|
.max(1);
|
||||||
let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms;
|
let elapsed_ms = (cycle_started_at.elapsed().as_millis() as u64) % cycle_ms;
|
||||||
let mut cursor = 0u64;
|
let mut cursor = 0u64;
|
||||||
|
|
||||||
@@ -404,7 +407,7 @@ fn rotation_sleep(
|
|||||||
let slot_ms = slot.total_duration.as_millis() as u64;
|
let slot_ms = slot.total_duration.as_millis() as u64;
|
||||||
if elapsed_ms < cursor + slot_ms {
|
if elapsed_ms < cursor + slot_ms {
|
||||||
if slot.frames.len() <= 1 {
|
if slot.frames.len() <= 1 {
|
||||||
return Duration::from_millis(750);
|
return Duration::from_millis(IDLE_SLEEP_MS);
|
||||||
}
|
}
|
||||||
let local_ms = elapsed_ms - cursor;
|
let local_ms = elapsed_ms - cursor;
|
||||||
let frame_elapsed_ms = local_ms % frame_cycle_ms(slot);
|
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 frame_remaining = frame_cursor + delay_ms - frame_elapsed_ms;
|
||||||
let slot_remaining = slot_ms.saturating_sub(local_ms);
|
let slot_remaining = slot_ms.saturating_sub(local_ms);
|
||||||
return Duration::from_millis(
|
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;
|
frame_cursor += delay_ms;
|
||||||
@@ -533,6 +538,21 @@ mod tests {
|
|||||||
assert_eq!(slot_frame_at(&slot, 0, 9_950).unwrap().1.key, "gif#0");
|
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]
|
#[test]
|
||||||
fn build_rotation_slots_uses_switch_time_for_gif_slot_duration() {
|
fn build_rotation_slots_uses_switch_time_for_gif_slot_duration() {
|
||||||
let unique = SystemTime::now()
|
let unique = SystemTime::now()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif");
|
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_FRAME_PREFIX: &str = ".gifframe-";
|
||||||
const GIF_MANIFEST_SUFFIX: &str = ".gifset.json";
|
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 base = make_image_stem(file_name);
|
||||||
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
|
let timestamp = Local::now().format("%Y%m%d-%H%M%S");
|
||||||
let manifest_name = format!("panel-{timestamp}-{base}{GIF_MANIFEST_SUFFIX}");
|
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 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_name = format!("{GIF_FRAME_PREFIX}{timestamp}-{base}-{idx:03}.jpg");
|
||||||
let frame_path = state.image_dir.join(&frame_name);
|
let frame_path = state.image_dir.join(&frame_name);
|
||||||
let rendered = render_display_panel(&DynamicImage::ImageRgba8(frame.into_buffer()));
|
|
||||||
rendered
|
rendered
|
||||||
.save_with_format(&frame_path, ImageFormat::Jpeg)
|
.save_with_format(&frame_path, ImageFormat::Jpeg)
|
||||||
.with_context(|| format!("Failed to save {}", frame_path.display()))?;
|
.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)
|
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 {
|
pub(crate) fn render_display_panel(image: &DynamicImage) -> RgbImage {
|
||||||
let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
|
let resized = image.resize(DISPLAY_WIDTH, DISPLAY_HEIGHT, FilterType::Lanczos3);
|
||||||
let rgb = resized.to_rgb8();
|
let rgb = resized.to_rgb8();
|
||||||
@@ -187,7 +217,7 @@ fn frame_delay_ms(frame: &Frame) -> u32 {
|
|||||||
} else {
|
} else {
|
||||||
((numer as f64) / (denom as f64)).round() as u32
|
((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<()> {
|
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<Rg
|
|||||||
.with_context(|| format!("Failed to load panel image {}", path.display()))?;
|
.with_context(|| format!("Failed to load panel image {}", path.display()))?;
|
||||||
Ok(image.to_rgb8())
|
Ok(image.to_rgb8())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use image::{Rgb, RgbImage};
|
||||||
|
|
||||||
|
use super::{collapse_rendered_frames, MIN_GIF_FRAME_DELAY_MS};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapse_rendered_frames_merges_identical_neighbors() {
|
||||||
|
let frame_a = RgbImage::from_pixel(2, 2, Rgb([1, 2, 3]));
|
||||||
|
let frame_b = RgbImage::from_pixel(2, 2, Rgb([9, 8, 7]));
|
||||||
|
|
||||||
|
let collapsed = collapse_rendered_frames(vec![
|
||||||
|
(frame_a.clone(), 100),
|
||||||
|
(frame_a, 120),
|
||||||
|
(frame_b.clone(), 80),
|
||||||
|
(frame_b, 90),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(collapsed.len(), 2);
|
||||||
|
assert_eq!(collapsed[0].1, 220);
|
||||||
|
assert_eq!(collapsed[1].1, 170);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn min_gif_frame_delay_supports_50ms_frames() {
|
||||||
|
assert!(MIN_GIF_FRAME_DELAY_MS <= 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user