From bfbdadd34a59ca0d1bb980cdc30fced73af672f7 Mon Sep 17 00:00:00 2001 From: Limestudio90 Date: Sat, 10 Oct 2026 11:38:00 +0200 Subject: [PATCH] Import animated GIFs as movies GIFs went through the still-image path, so an animated GIF came in as a single frame with zero duration. A GIF with two or more frames is now probed as a movie: its frame rate is chosen so every per-frame delay is a whole number of frames (uneven timing is kept exactly), delays of 0 or 1 hundredths count as 10 like in browsers, and the pool decodes the frame shown at the requested time. A single-frame GIF stays a still. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + crates/media/Cargo.toml | 1 + crates/media/src/gif_anim.rs | 205 +++++++++++++++++++++++++++++++++++ crates/media/src/lib.rs | 1 + crates/media/src/pool.rs | 29 +++++ crates/media/src/probe.rs | 6 + 6 files changed, 243 insertions(+) create mode 100644 crates/media/src/gif_anim.rs diff --git a/Cargo.lock b/Cargo.lock index d7fc9434..8b4a27f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1419,6 +1419,7 @@ dependencies = [ "filmcraft-frame", "filmcraft-media", "filmcraft-time", + "gif", "image", "log", "rayon", diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 3db4b1a9..0d7803fd 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -24,6 +24,7 @@ filmcraft-frame = { workspace = true } filmcraft-color = { workspace = true } filmcraft-time = { workspace = true } exr = { workspace = true } +gif = { workspace = true } image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr"] } rayon = { workspace = true } serde_json = { workspace = true } diff --git a/crates/media/src/gif_anim.rs b/crates/media/src/gif_anim.rs new file mode 100644 index 00000000..01a40eac --- /dev/null +++ b/crates/media/src/gif_anim.rs @@ -0,0 +1,205 @@ +//! Animated GIFs: probed as movies and decoded whole into 8-bit frames. +//! +//! A GIF gives every frame its own delay, in hundredths of a second. The footage gets the frame +//! rate that every delay is a whole number of frames at (100 / the greatest common divisor of the +//! delays), so uneven timing is kept exactly; [`Anim::frame_at`] looks the frame up by time. + +use std::io::Cursor; + +use effectcraft_project::{AlphaMode, Footage, FootageKind}; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder}; + +use crate::{MediaError, Result}; + +/// The `Footage::codec` of an animated GIF. +pub(crate) const CODEC: &str = "GIF"; + +/// Decoded frames of one GIF are kept as 8-bit RGBA, up to this many bytes. +const MAX_BYTES: usize = 1 << 30; + +/// Frames read from a GIF's headers when probing. +const MAX_FRAMES: usize = 100_000; + +/// Whether `f` is an animated GIF imported by [`probe`]. +pub(crate) fn is_gif(f: &Footage) -> bool { + f.kind == FootageKind::Video && f.codec == CODEC +} + +/// The delay of a frame in hundredths of a second. Browsers show the 0 and 1 that many encoders +/// write for "as fast as possible" as 10, and so do we. +fn delay_cs(raw: u32) -> u32 { + if raw < 2 { 10 } else { raw } +} + +fn gcd(a: u32, b: u32) -> u32 { + if b == 0 { a } else { gcd(b, a % b) } +} + +/// Describe an animated GIF as a movie. `None` for a GIF with fewer than two frames (a still) or +/// one whose headers can't be read. +pub(crate) fn probe(path: &str, bytes: &[u8]) -> Option { + let mut dec = gif::DecodeOptions::new().read_info(Cursor::new(bytes)).ok()?; + let (width, height) = (u32::from(dec.width()), u32::from(dec.height())); + let mut delays: Vec = Vec::new(); + while delays.len() < MAX_FRAMES { + match dec.next_frame_info() { + Ok(Some(f)) => delays.push(delay_cs(u32::from(f.delay))), + // The end, or a damaged tail: keep the frames before it. + _ => break, + } + } + if delays.len() < 2 || width == 0 || height == 0 { + return None; + } + let step = delays.iter().fold(0, |g, &d| gcd(g, d)).max(1); + let total: i64 = delays.iter().map(|&d| i64::from(d)).sum(); + Some(Footage { + path: path.to_string(), + kind: FootageKind::Video, + width, + height, + frame_rate: FrameRate::new(100, i64::from(step)), + duration: Tick::from_units(total, 100), + has_video: true, + has_audio: false, + alpha: AlphaMode::Straight, + codec: CODEC.to_string(), + ..Default::default() + }) +} + +/// Every frame of a GIF, composited onto the full canvas. +pub(crate) struct Anim { + frames: Vec, + /// When each frame starts, in hundredths of a second. + starts: Vec, +} + +impl Anim { + /// Decode all frames. A damaged tail keeps the frames before it. + pub(crate) fn load(bytes: &[u8]) -> Result { + let dec = GifDecoder::new(Cursor::new(bytes)).map_err(|e| MediaError::Decode(format!("GIF: {e}")))?; + let (mut frames, mut starts) = (Vec::new(), Vec::new()); + let (mut at, mut size) = (0u64, 0usize); + for frame in dec.into_frames() { + let frame = match frame { + Ok(f) => f, + Err(e) if frames.is_empty() => return Err(MediaError::Decode(format!("GIF: {e}"))), + Err(e) => { + log::warn!("media: GIF stops after {} frames: {e}", frames.len()); + break; + } + }; + let (numer, denom) = frame.delay().numer_denom_ms(); + let cs = delay_cs(numer.checked_div(denom).unwrap_or(0) / 10); + let buf = frame.into_buffer(); + size = size.saturating_add(buf.as_raw().len()); + if size > MAX_BYTES { + return Err(MediaError::Unsupported("GIF is too large to hold in memory".into())); + } + starts.push(at); + at = at.saturating_add(u64::from(cs)); + frames.push(DynamicImage::ImageRgba8(buf)); + } + if frames.is_empty() { + return Err(MediaError::Decode("GIF: no frames".into())); + } + Ok(Anim { frames, starts }) + } + + /// The frame shown at source time `t` (the last one past the end). + pub(crate) fn frame_at(&self, t: Tick) -> Option<&DynamicImage> { + let cs = i128::from(t.0).max(0).saturating_mul(100) / TICKS_PER_SECOND as i128; + let cs = u64::try_from(cs).unwrap_or(u64::MAX); + let i = self.starts.partition_point(|&s| s <= cs).saturating_sub(1); + self.frames.get(i).or(self.frames.last()) + } +} + +#[cfg(test)] +mod tests { + use effectcraft_project::{Footage, FootageKind}; + use effectcraft_time::{FrameRate, Tick}; + + use crate::{MediaPool, probe_bytes}; + + /// A 2×2 GIF with one solid-colour frame per `(rgb, delay)`. + fn gif(frames: &[([u8; 3], u16)]) -> Vec { + let mut out = Vec::new(); + { + let mut enc = gif::Encoder::new(&mut out, 2, 2, &[]).unwrap(); + for (rgb, delay) in frames { + let mut f = gif::Frame::from_rgb(2, 2, &rgb.repeat(4)); + f.delay = *delay; + enc.write_frame(&f).unwrap(); + } + } + out + } + + const RED: [u8; 3] = [255, 0, 0]; + const GREEN: [u8; 3] = [0, 255, 0]; + const BLUE: [u8; 3] = [0, 0, 255]; + + fn colour_at(pool: &MediaPool, f: &Footage, seconds: f64) -> [f32; 3] { + let img = pool.frame_at(f, Tick::from_seconds_f64(seconds)).unwrap(); + let p = img.data[0]; + [p[0], p[1], p[2]] + } + + #[test] + fn an_animated_gif_is_a_movie_with_the_files_timing() { + let bytes = gif(&[(RED, 5), (GREEN, 10), (BLUE, 5)]); + let f = probe_bytes("a.gif", bytes.clone().into()).unwrap(); + assert_eq!(f.kind, FootageKind::Video); + assert!(f.has_video && !f.has_audio); + assert_eq!((f.width, f.height), (2, 2)); + // Delays of 5, 10 and 5 hundredths: 20 frames a second, 0.2 s in all. + assert_eq!(f.frame_rate, FrameRate::new(20, 1)); + assert_eq!(f.duration, Tick::from_units(20, 100)); + + let pool = MediaPool::new(); + pool.add_bytes("a.gif", bytes.into()); + assert_eq!(colour_at(&pool, &f, 0.01), [1.0, 0.0, 0.0]); + assert_eq!(colour_at(&pool, &f, 0.06), [0.0, 1.0, 0.0]); + assert_eq!(colour_at(&pool, &f, 0.14), [0.0, 1.0, 0.0]); + assert_eq!(colour_at(&pool, &f, 0.17), [0.0, 0.0, 1.0]); + // Past the end it holds the last frame. + assert_eq!(colour_at(&pool, &f, 5.0), [0.0, 0.0, 1.0]); + } + + #[test] + fn a_zero_delay_is_ten_hundredths_like_in_browsers() { + let f = probe_bytes("a.gif", gif(&[(RED, 0), (GREEN, 1)]).into()).unwrap(); + assert_eq!(f.frame_rate, FrameRate::new(10, 1)); + assert_eq!(f.duration, Tick::from_units(20, 100)); + } + + #[test] + fn a_single_frame_gif_stays_a_still() { + let f = probe_bytes("a.gif", gif(&[(RED, 10)]).into()).unwrap(); + assert_eq!(f.kind, FootageKind::Still); + } + + #[test] + fn a_damaged_gif_is_an_error_not_a_panic() { + let mut bytes = gif(&[(RED, 5), (GREEN, 5)]); + bytes.truncate(20); + let pool = MediaPool::new(); + pool.add_bytes("a.gif", bytes.clone().into()); + let f = Footage { + path: "a.gif".into(), + kind: FootageKind::Video, + codec: "GIF".into(), + has_video: true, + width: 2, + height: 2, + duration: Tick::from_units(10, 100), + frame_rate: FrameRate::new(20, 1), + ..Default::default() + }; + assert!(pool.frame_at(&f, Tick::ZERO).is_err()); + let _ = probe_bytes("a.gif", bytes.into()); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 64c137c9..000c1624 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -17,6 +17,7 @@ mod convert; pub mod exr_channels; +mod gif_anim; mod layered; pub use layered::vector_doc; mod pool; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 3df9ec45..b69b34b5 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -11,6 +11,7 @@ use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_media::{FrameRequest, SharedSource}; use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in}; +use crate::gif_anim::{self, Anim}; use crate::{MediaError, Result}; /// Default frame-cache budget: 1 GiB of decoded frames. @@ -169,6 +170,9 @@ struct CacheState { /// (`None`: failed to open; not retried until `forget`). type SourceSlot = Arc>>; +/// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load). +type GifSlot = Arc, String>>>; + struct Inner { /// Opened movie/audio sources by path. sources: Mutex, SourceSlot>>, @@ -178,6 +182,9 @@ struct Inner { files: Mutex>>, /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`). models: Mutex>>>, + /// Decoded animated GIFs by path, loaded by the first caller while the others wait (an + /// `Err` is not retried until `forget`). + gifs: Mutex, GifSlot>>, cache: Mutex, done: Condvar, budget: AtomicU64, @@ -246,6 +253,7 @@ impl MediaPool { sources: Mutex::default(), files: Mutex::default(), models: Mutex::default(), + gifs: Mutex::default(), cache: Mutex::default(), done: Condvar::new(), budget: AtomicU64::new(bytes as u64), @@ -300,12 +308,14 @@ impl MediaPool { pub fn clear(&self) { self.clear_frames(); lock(&self.inner.sources).clear(); + lock(&self.inner.gifs).clear(); } /// Forget a file (after it changed on disk, or to retry a file that failed to open). pub fn forget(&self, path: &str) { lock(&self.inner.sources).remove(path); lock(&self.inner.models).remove(path); + lock(&self.inner.gifs).remove(path); let mut c = lock(&self.inner.cache); let keys: Vec = c.lru.map.keys().filter(|k| &*k.path == path).cloned().collect(); for k in keys { @@ -539,6 +549,20 @@ impl Inner { .clone() } + /// The decoded frames of the animated GIF at `path`. The first caller decodes it, outside the + /// map's lock; callers asking for the same file meanwhile wait for that. + fn gif(&self, path: &Arc) -> Result> { + let slot = lock(&self.gifs).entry(path.clone()).or_default().clone(); + let loaded = slot.get_or_init(|| { + let loaded = self.read(path).and_then(|bytes| Anim::load(&bytes)); + loaded.map(Arc::new).map_err(|e| { + log::warn!("media: cannot decode {path}: {e}"); + e.to_string() + }) + }); + loaded.clone().map_err(|e| MediaError::Decode(format!("{path}: {e}"))) + } + /// The frame index (in the footage's interpreted rate) shown at source time `t`, after /// looping (`loop_count`) and clamping to the footage's frames; `n` = frames per loop. fn frame_index(rate: FrameRate, t: Tick, n: i64, loops: u32) -> i64 { @@ -667,6 +691,11 @@ impl Inner { }; Ok(dynamic_to_image(&img, op)) } + Some(mt) if gif_anim::is_gif(footage) => { + let anim = self.gif(path)?; + let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?; + Ok(dynamic_to_image(frame, op)) + } Some(mt) => { let src = self.source(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?; let vf = src.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))).map_err(MediaError::from)?; diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 72e25699..4e986151 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -65,6 +65,12 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { return probe_model(path, &bytes, &|_| None); } if let Ok(fmt) = image::guess_format(&bytes) { + // An animated GIF is a movie; one with a single frame is a still. + if fmt == image::ImageFormat::Gif + && let Some(f) = crate::gif_anim::probe(path, &bytes) + { + return Ok(f); + } return probe_still_bytes(path, &bytes, fmt); } let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());