mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-13 23:17:30 +00:00
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:
1 parent
813c7c4650
commit
bfbdadd34a
6 files changed
+243
No files matched your search
Generated
+1
@@ -1419,6 +1419,7 @@ dependencies = [
|
||||
"filmcraft-frame",
|
||||
"filmcraft-media",
|
||||
"filmcraft-time",
|
||||
"gif",
|
||||
"image",
|
||||
"log",
|
||||
"rayon",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
mod convert;
|
||||
pub mod exr_channels;
|
||||
mod gif_anim;
|
||||
mod layered;
|
||||
pub use layered::vector_doc;
|
||||
mod pool;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user