use std::{io::Cursor, path::PathBuf}; use anyhow::{Context, Result}; use asterctl::img; use chrono::Local; use image::{ codecs::gif::GifDecoder, imageops::FilterType, AnimationDecoder, DynamicImage, Frame, ImageFormat, RgbImage, }; use tokio::fs; use crate::{ system::{DISPLAY_HEIGHT, DISPLAY_WIDTH}, types::{AppState, GifFrameMeta, GifSetManifest, ImageView}, }; 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"; pub(crate) async fn list_images(state: &AppState) -> Result> { let mut out = Vec::new(); let mut dir = fs::read_dir(&state.image_dir).await?; while let Some(entry) = dir.next_entry().await? { let path = entry.path(); let file_type = entry.file_type().await?; if !file_type.is_file() { continue; } 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?; 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.source_frame_count.unwrap_or(manifest.frames.len()), display_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, display_frame_count: 1, }); } } out.sort_by(|a, b| a.name.cmp(&b.name)); Ok(out) } pub(crate) 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); let path = state.image_dir.join(&target_name); let rendered = render_display_panel(&image); rendered .save_with_format(&path, ImageFormat::Jpeg) .with_context(|| format!("Failed to save {}", path.display()))?; 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 source_frame_count = frames.len(); 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 rendered_frames = Vec::new(); 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); 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(), source_frame_count: Some(source_frame_count), 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 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(); let mut canvas = RgbImage::from_pixel(DISPLAY_WIDTH, DISPLAY_HEIGHT, image::Rgb([8, 10, 12])); let offset_x = ((DISPLAY_WIDTH - rgb.width()) / 2) as i64; let offset_y = ((DISPLAY_HEIGHT - rgb.height()) / 2) as i64; image::imageops::overlay(&mut canvas, &rgb, offset_x, offset_y); canvas } 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) .unwrap_or(file_name); let clean: String = base .chars() .map(|ch| match ch { 'a'..='z' | 'A'..='Z' | '0'..='9' => ch.to_ascii_lowercase(), _ => '-', }) .collect(); let clean = clean.trim_matches('-'); 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") } pub(crate) 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(MIN_GIF_FRAME_DELAY_MS) } pub(crate) 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(()) } pub(crate) 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())) } pub(crate) fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result { let path = image_dir.join(image_name); let image = img::load_image(&path, Some(asterctl_lcd::DISPLAY_SIZE)) .with_context(|| format!("Failed to load panel image {}", path.display()))?; Ok(image.to_rgb8()) } #[cfg(test)] mod tests { use image::{Rgb, RgbImage}; use serde_json::json; use crate::types::GifSetManifest; 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); } #[test] fn gif_manifest_without_source_frame_count_stays_compatible() { let manifest: GifSetManifest = serde_json::from_value(json!({ "kind": "gif_set", "version": 1, "label": "demo.gif", "preview": ".gifframe-000.jpg", "frames": [ { "name": ".gifframe-000.jpg", "delay_ms": 50 }, { "name": ".gifframe-001.jpg", "delay_ms": 50 } ] })) .unwrap(); assert_eq!(manifest.source_frame_count, None); assert_eq!(manifest.frames.len(), 2); } }