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 <noreply@anthropic.com>
This commit is contained in:
Limestudio90andClaude Sonnet 5.5 committed 2026-10-10 11:38:00 +02:00
1 parent 813c7c4650
commit bfbdadd34a
6 files changed
+243

No files matched your search

Generated
+1
View File
@@ -1419,6 +1419,7 @@ dependencies = [
"filmcraft-frame",
"filmcraft-media",
"filmcraft-time",
"gif",
"image",
"log",
"rayon",
+1
View File
@@ -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 }
+205
View File
@@ -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<Footage> {
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<u32> = 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<DynamicImage>,
/// When each frame starts, in hundredths of a second.
starts: Vec<u64>,
}
impl Anim {
/// Decode all frames. A damaged tail keeps the frames before it.
pub(crate) fn load(bytes: &[u8]) -> Result<Anim> {
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<u8> {
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());
}
}
+1
View File
@@ -17,6 +17,7 @@
mod convert;
pub mod exr_channels;
mod gif_anim;
mod layered;
pub use layered::vector_doc;
mod pool;
+29
View File
@@ -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<OnceLock<Option<SharedSource>>>;
/// An animated GIF by path, as [`SourceSlot`] (the error message when it failed to load).
type GifSlot = Arc<OnceLock<std::result::Result<Arc<Anim>, String>>>;
struct Inner {
/// Opened movie/audio sources by path.
sources: Mutex<HashMap<Arc<str>, SourceSlot>>,
@@ -178,6 +182,9 @@ struct Inner {
files: Mutex<HashMap<String, Arc<[u8]>>>,
/// Parsed 3D models by path (`None`: failed to load; not retried until `forget`).
models: Mutex<HashMap<String, Option<Arc<effectcraft_model::Model>>>>,
/// Decoded animated GIFs by path, loaded by the first caller while the others wait (an
/// `Err` is not retried until `forget`).
gifs: Mutex<HashMap<Arc<str>, GifSlot>>,
cache: Mutex<CacheState>,
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<Key> = 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<str>) -> Result<Arc<Anim>> {
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)?;
+6
View File
@@ -65,6 +65,12 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
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());