From 979e9a0c56f43a73f360a8887a4c2e00ed9efdbe Mon Sep 17 00:00:00 2001 From: liangforstudy <36067395+liangforstudy@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:44:15 +0800 Subject: [PATCH] #402: animated GIF and APNG import as clips, with transparency APNG and animated GIF files opened as stills showing only their first frame. They now open as movie clips (crates/media/src/animated.rs): - every frame is decoded and composited onto the full canvas by the image crate (APNG blend/dispose ops, GIF disposal), keeping alpha - each frame lasts its own delay; GIF delays <= 10 ms play as 100 ms and APNG delays < 10 ms as 10 ms, as browsers do - consecutive identical frames are merged; the clip is one pass of the animation and its timebase is the most common frame length - hostile input: at most 10,000 frames / 1 GiB of decoded pictures; a damaged frame ends the clip at the frames before it; files with one frame (or none that decode) still open as stills, as before Tests: media unit tests (timing, transparency, merging, single-frame files stay stills, truncated/bit-flipped files never panic) and an engine test that imports an animated GIF and reopens it from a saved project. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QkQ2PDrNFjs9Vm8T44tdjA --- Cargo.lock | 1 + README.md | 2 +- crates/engine/Cargo.toml | 2 +- crates/engine/src/animated_image_tests.rs | 62 ++++++ crates/engine/src/lib.rs | 2 + crates/media/Cargo.toml | 4 + crates/media/src/animated.rs | 225 ++++++++++++++++++++++ crates/media/src/animated_tests.rs | 176 +++++++++++++++++ crates/media/src/lib.rs | 8 +- crates/media/src/still.rs | 3 +- 10 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/animated_image_tests.rs create mode 100644 crates/media/src/animated.rs create mode 100644 crates/media/src/animated_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9d1aa331..4ac9ff90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2079,6 +2079,7 @@ dependencies = [ "filmcraft-frame", "filmcraft-time", "image", + "png", "rayon", "serde", "thiserror 2.0.21", diff --git a/README.md b/README.md index 0ce67cb3..cebad497 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own | | **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks | | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | -| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF | +| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF and APNG as clips, with transparency); export PNG sequences and animated GIF | Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)). diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index defe08a8..713a5e28 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -46,4 +46,4 @@ web-time = "1" [dev-dependencies] filmcraft-testkit = { workspace = true } -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["png", "gif"] } diff --git a/crates/engine/src/animated_image_tests.rs b/crates/engine/src/animated_image_tests.rs new file mode 100644 index 00000000..8fe0dc67 --- /dev/null +++ b/crates/engine/src/animated_image_tests.rs @@ -0,0 +1,62 @@ +//! Animated GIF and APNG files import as movie clips that play every frame with its transparency +//! (#402), and reopen that way from a saved project. + +use filmcraft_media::{FrameRequest, MediaKind}; +use filmcraft_project::ItemId; +use filmcraft_time::{TICKS_PER_SECOND, Tick}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::media_test_util::tmp_dir; + +/// A 16×8 animated GIF: frame `i` is red `40 * i` with its left half transparent, 100 ms each. +fn write_gif(path: &std::path::Path, frames: u8) { + let mut out = Vec::new(); + { + let mut e = image::codecs::gif::GifEncoder::new(&mut out); + let frames = (0..frames).map(|i| { + let img = image::RgbaImage::from_fn(16, 8, |x, _| image::Rgba([40 * i, 0, 0, if x < 8 { 0 } else { 255 }])); + image::Frame::from_parts(img, 0, 0, image::Delay::from_numer_denom_ms(100, 1)) + }); + e.encode_frames(frames).unwrap(); + } + std::fs::write(path, out).unwrap(); +} + +fn item_of(r: &Value) -> ItemId { + ItemId(r["items"][0].as_u64().unwrap_or_else(|| panic!("{r}"))) +} + +/// The pixel at (`x`, 0) at media time `ms`. +fn pixel_at(s: &Session, item: ItemId, ms: i64, x: usize) -> [u8; 4] { + let src = s.media.source_for(&s.project, item, &*s.services).unwrap(); + let f = src.video_frame(FrameRequest::full(Tick(ms * TICKS_PER_SECOND / 1000))).unwrap(); + match &f.data { + filmcraft_frame::PixelData::Rgba8(p) => [p[4 * x], p[4 * x + 1], p[4 * x + 2], p[4 * x + 3]], + _ => panic!("rgba frames"), + } +} + +#[test] +fn animated_gif_imports_as_a_clip_and_reopens() { + let dir = tmp_dir("animated-gif"); + let gif = dir.join("loop.gif"); + write_gif(&gif, 4); + let mut s = Session::default(); + let id = item_of(&s.execute("file.import", json!({"paths": [gif.to_string_lossy()]})).unwrap()); + let m = s.project.item(id).unwrap().as_media().unwrap(); + assert_eq!(m.info.kind, MediaKind::Movie, "not a still showing the first frame"); + assert_eq!(m.info.duration, Tick(4 * TICKS_PER_SECOND / 10)); + assert!(m.info.video.as_ref().unwrap().has_alpha); + assert_eq!(pixel_at(&s, id, 0, 12), [0, 0, 0, 255]); + assert_eq!(pixel_at(&s, id, 250, 12), [80, 0, 0, 255], "the third frame plays"); + assert_eq!(pixel_at(&s, id, 250, 2)[3], 0, "transparent pixels stay transparent, not black"); + + let proj = dir.join("anim.fcproj"); + s.execute("file.saveAs", json!({"path": proj.to_string_lossy()})).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": proj.to_string_lossy()})).unwrap(); + assert_eq!(t.project.item(id).unwrap().as_media().unwrap().info.kind, MediaKind::Movie); + assert_eq!(pixel_at(&t, id, 350, 12), [120, 0, 0, 255], "frames reopen through the media pool"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ed9e6a..f63b1591 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1086,6 +1086,8 @@ const MAX_SUBCLIP_CHAIN: usize = 16; #[cfg(test)] mod aaf_omf_tests; #[cfg(test)] +mod animated_image_tests; +#[cfg(test)] mod audio_effects_tests; #[cfg(test)] mod audio_placement_tests; diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 29c63d93..69e732ab 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -17,3 +17,7 @@ rayon = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "tiff", "bmp"] } + +[dev-dependencies] +# APNG fixtures for the animated-image tests (the image crate does not encode APNG) +png = "0.18" diff --git a/crates/media/src/animated.rs b/crates/media/src/animated.rs new file mode 100644 index 00000000..0bf02020 --- /dev/null +++ b/crates/media/src/animated.rs @@ -0,0 +1,225 @@ +//! Animated images (APNG and animated GIF) as movie clips (#402). +//! +//! The still decoders read only the first picture of these files. [`open`] decodes every frame +//! instead, composited onto the full canvas by the `image` crate (APNG blend and dispose operations, +//! GIF disposal methods), and keeps them with their timing: +//! +//! - each frame lasts its own delay; GIF delays of 10 ms or less play as 100 ms and APNG delays +//! under 10 ms as 10 ms, as browsers do, so a zero delay never makes a zero-length frame; +//! - consecutive identical frames are merged (one picture, the sum of their delays); +//! - the clip is one pass through the animation (the file's loop count is not applied); +//! - transparency is kept: the source reports alpha when any frame has a pixel that is not fully +//! opaque. +//! +//! The file is hostile: decoding stops at [`MAX_FRAMES`] frames or [`MAX_DECODED_BYTES`] of +//! pictures (the clip then ends there), and a frame that fails to decode ends the animation at the +//! frames decoded before it. A file that turns out not to be animated (one frame, or no frame that +//! decodes) is left to the still decoders, which is what happened to every animated image before. + +use std::io::Cursor; +use std::sync::Arc; + +use filmcraft_frame::{AudioBuffer, VideoFrame}; +use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use image::codecs::gif::GifDecoder; +use image::codecs::png::PngDecoder; +use image::{AnimationDecoder, Delay, Frames, ImageDecoder, Limits}; + +use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo}; + +/// Most frames kept from one file (frames past this are dropped; the clip ends there). +pub const MAX_FRAMES: usize = 10_000; +/// Most bytes of decoded RGBA pictures kept from one file (after merging identical frames). +pub const MAX_DECODED_BYTES: usize = 1 << 30; + +/// Animated image formats. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + Apng, + Gif, +} + +impl Format { + fn sniff(b: &[u8]) -> Option { + if b.starts_with(b"\x89PNG\r\n\x1a\n") { + Some(Format::Apng) + } else if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") { + Some(Format::Gif) + } else { + None + } + } + + fn codec(self) -> &'static str { + match self { + Format::Apng => "APNG", + Format::Gif => "GIF", + } + } + + /// A frame's delay in ticks, with browsers' minimum for zero and near-zero delays. + fn frame_ticks(self, delay: Delay) -> Tick { + let (numer, denom) = delay.numer_denom_ms(); + let (numer, denom) = (u128::from(numer), u128::from(denom.max(1))); + // whole milliseconds, rounded down, decide the minimum (GIF delays are 10 ms steps) + let ms = numer / denom; + let (numer, denom) = match self { + Format::Gif if ms <= 10 => (100, 1), + Format::Apng if ms < 10 => (10, 1), + _ => (numer, denom), + }; + let ticks = numer.saturating_mul(TICKS_PER_SECOND as u128) / denom.saturating_mul(1000); + Tick(i64::try_from(ticks).unwrap_or(i64::MAX).max(1)) + } +} + +/// An animation: its frames (full-canvas pictures) and when each one starts. +pub struct AnimatedSource { + info: MediaInfo, + frames: Vec>, + /// Media time each frame starts at; ascending, `starts[0]` is zero. + starts: Vec, +} + +/// Decode `bytes` as an animated image. `None` when it is not an APNG or GIF with at least two +/// frames that decode (the caller then opens it as a still). +pub fn open(name: &str, bytes: &[u8]) -> Option> { + let format = Format::sniff(bytes)?; + let frames = match format { + Format::Apng => { + let mut d = PngDecoder::new(Cursor::new(bytes)).ok()?; + if !d.is_apng().ok()? { + return None; + } + d.set_limits(Limits::default()).ok()?; + d.apng().ok()?.into_frames() + } + Format::Gif => { + let mut d = GifDecoder::new(Cursor::new(bytes)).ok()?; + d.set_limits(Limits::default()).ok()?; + d.into_frames() + } + }; + AnimatedSource::from_frames(name, format, frames, bytes.len() as u64).map(Ok) +} + +impl AnimatedSource { + fn from_frames(name: &str, format: Format, frames: Frames<'_>, file_size: u64) -> Option { + let mut pictures: Vec<(u32, u32, Vec)> = Vec::new(); + let mut durations: Vec = Vec::new(); + let mut decoded = 0usize; + let mut has_alpha = false; + for frame in frames.take(MAX_FRAMES) { + // a damaged frame ends the animation at the frames before it + let Ok(frame) = frame else { break }; + let ticks = format.frame_ticks(frame.delay()); + let buffer = frame.into_buffer(); + let (w, h) = buffer.dimensions(); + let rgba = buffer.into_raw(); + if let (Some(last), Some(d)) = (pictures.last(), durations.last_mut()) + && (last.0, last.1) == (w, h) + && last.2 == rgba + { + *d = Tick(d.0.saturating_add(ticks.0)); + continue; + } + decoded = decoded.saturating_add(rgba.len()); + if decoded > MAX_DECODED_BYTES { + break; + } + has_alpha = has_alpha || rgba.chunks_exact(4).any(|p| p.get(3).is_some_and(|&a| a < 255)); + pictures.push((w, h, rgba)); + durations.push(ticks); + } + if pictures.len() < 2 { + return None; + } + let (width, height) = pictures.first().map(|p| (p.0, p.1))?; + let mut starts = Vec::with_capacity(durations.len()); + let mut t = Tick::ZERO; + for d in &durations { + starts.push(t); + t = Tick(t.0.saturating_add(d.0)); + } + let info = MediaInfo { + name: name.to_string(), + kind: MediaKind::Movie, + duration: t, + video: Some(VideoStreamInfo { + width, + height, + frame_rate: rate_of(&durations), + par: (1, 1), + codec: format.codec().into(), + pixel_format: if has_alpha { "RGBA 8-bit".into() } else { "RGB 8-bit".into() }, + color: filmcraft_color::ColorInfo::SRGB_FULL, + has_alpha, + bitrate: None, + hdr: None, + }), + audio_streams: Vec::new(), + container: format.codec().into(), + start_timecode: None, + file_size: Some(file_size), + }; + let frames = pictures.into_iter().map(|(w, h, rgba)| Arc::new(VideoFrame::rgba8(w, h, rgba))).collect(); + Some(Self { info, frames, starts }) + } + + /// Number of distinct frames kept. + pub fn frame_count(&self) -> usize { + self.frames.len() + } + + /// Media time frame `i` starts at. + pub fn frame_start(&self, i: usize) -> Option { + self.starts.get(i).copied() + } + + /// The frame shown at media time `t` (the first before the start, the last after the end). + fn index_at(&self, t: Tick) -> usize { + self.starts.partition_point(|s| *s <= t).saturating_sub(1) + } +} + +/// The clip's frame rate: the rate of its most common frame duration, when that is a sensible video +/// rate (1 to 120 frames per second), else 30 fps. Frames are shown for their own durations +/// whatever this says; it sets the clip's timebase (e.g. a sequence made from the clip). +fn rate_of(durations: &[Tick]) -> FrameRate { + let mut sorted: Vec = durations.iter().map(|d| d.0).collect(); + sorted.sort_unstable(); + let mut best = (0usize, 0i64); + let mut i = 0; + while let Some(&v) = sorted.get(i) { + let run = sorted.get(i..).map_or(0, |rest| rest.iter().take_while(|&&x| x == v).count()).max(1); + if run > best.0 { + best = (run, v); + } + i = i.saturating_add(run); + } + let ticks = best.1; + if ticks <= 0 { + return FrameRate::FPS_30; + } + let rate = FrameRate::new(TICKS_PER_SECOND, ticks); + let fps = rate.as_f64(); + if (1.0..=120.0).contains(&fps) { rate } else { FrameRate::FPS_30 } +} + +impl MediaSource for AnimatedSource { + fn info(&self) -> &MediaInfo { + &self.info + } + + fn video_frame(&self, req: FrameRequest) -> Result> { + self.frames.get(self.index_at(req.time)).cloned().ok_or(MediaError::NoStream("video")) + } + + fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result { + Err(MediaError::NoStream("audio")) + } +} + +#[cfg(test)] +#[path = "animated_tests.rs"] +mod tests; diff --git a/crates/media/src/animated_tests.rs b/crates/media/src/animated_tests.rs new file mode 100644 index 00000000..be77ca87 --- /dev/null +++ b/crates/media/src/animated_tests.rs @@ -0,0 +1,176 @@ +use super::*; +use crate::{FrameRequest, MediaKind, open_bytes}; + +const W: u32 = 4; +const H: u32 = 2; + +/// A 4×2 RGBA picture: the left half `rgb` with alpha `left_alpha`, the right half `rgb` opaque. +fn picture(rgb: [u8; 3], left_alpha: u8) -> Vec { + let mut px = Vec::new(); + for _y in 0..H { + for x in 0..W { + let a = if x < W / 2 { left_alpha } else { 255 }; + px.extend_from_slice(&[rgb[0], rgb[1], rgb[2], a]); + } + } + px +} + +/// An APNG whose frames are `frames` (RGBA, W×H) with delays `(numerator, denominator)` seconds. +fn apng(frames: &[(Vec, (u16, u16))]) -> Vec { + let mut out = Vec::new(); + { + let mut e = png::Encoder::new(&mut out, W, H); + e.set_color(png::ColorType::Rgba); + e.set_depth(png::BitDepth::Eight); + e.set_animated(frames.len() as u32, 0).unwrap(); + let mut w = e.write_header().unwrap(); + for (px, (num, den)) in frames { + w.set_frame_delay(*num, *den).unwrap(); + w.set_dispose_op(png::DisposeOp::None).unwrap(); + w.set_blend_op(png::BlendOp::Source).unwrap(); + w.write_image_data(px).unwrap(); + } + w.finish().unwrap(); + } + out +} + +/// A GIF whose frames are `frames` (RGBA, W×H) with delays in milliseconds. +fn gif(frames: &[(Vec, u32)]) -> Vec { + let mut out = Vec::new(); + { + let mut e = image::codecs::gif::GifEncoder::new(&mut out); + e.set_repeat(image::codecs::gif::Repeat::Infinite).unwrap(); + let frames = frames.iter().map(|(px, ms)| { + let img = image::RgbaImage::from_raw(W, H, px.clone()).unwrap(); + image::Frame::from_parts(img, 0, 0, Delay::from_numer_denom_ms(*ms, 1)) + }); + e.encode_frames(frames).unwrap(); + } + out +} + +fn ms(n: i64) -> Tick { + Tick(n * TICKS_PER_SECOND / 1000) +} + +fn pixels(s: &dyn MediaSource, t: Tick) -> Vec { + match &s.video_frame(FrameRequest::full(t)).unwrap().data { + filmcraft_frame::PixelData::Rgba8(p) => p.to_vec(), + _ => panic!("not RGBA"), + } +} + +#[test] +fn apng_plays_every_frame_with_its_timing_and_keeps_transparency() { + let (red, green, blue) = (picture([255, 0, 0], 0), picture([0, 255, 0], 128), picture([0, 0, 255], 255)); + let bytes = apng(&[(red.clone(), (1, 10)), (green.clone(), (1, 5)), (blue.clone(), (1, 10))]); + let s = open_bytes("spin.png", bytes.into(), &[]).unwrap(); + let info = s.info(); + assert_eq!(info.kind, MediaKind::Movie, "an APNG is a clip, not a still (#402)"); + assert_eq!(info.duration, ms(400)); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height, v.codec.as_str()), (W, H, "APNG")); + assert!(v.has_alpha, "transparent pixels are reported"); + assert_eq!(pixels(&*s, Tick::ZERO), red, "fully transparent pixels keep alpha 0, not black"); + assert_eq!(pixels(&*s, ms(99)), red); + assert_eq!(pixels(&*s, ms(100)), green); + assert_eq!(pixels(&*s, ms(299)), green, "the second frame lasts its 200 ms"); + assert_eq!(pixels(&*s, ms(300)), blue); + assert_eq!(pixels(&*s, ms(10_000)), blue, "past the end: the last frame"); + assert_eq!(pixels(&*s, Tick(-5)), red, "before the start: the first frame"); +} + +#[test] +fn identical_frames_merge_into_one_longer_frame() { + let (a, b) = (picture([10, 20, 30], 255), picture([40, 50, 60], 255)); + let bytes = apng(&[(a.clone(), (1, 10)), (a.clone(), (1, 10)), (a, (1, 10)), (b.clone(), (1, 10))]); + let s = open("hold.png", &bytes).unwrap().unwrap(); + assert_eq!(s.frame_count(), 2); + assert_eq!(s.frame_start(1), Some(ms(300))); + assert_eq!(s.info().duration, ms(400)); + assert!(!s.info().video.as_ref().unwrap().has_alpha); + assert_eq!(s.info().frame_rate(), FrameRate::new(10, 1), "the most common frame length is 100 ms"); + assert_eq!(pixels(&s, ms(350)), b); +} + +#[test] +fn animated_gif_plays_every_frame_with_transparency() { + let (red, green) = (picture([255, 0, 0], 0), picture([0, 255, 0], 255)); + let bytes = gif(&[(red, 40), (green, 0)]); + let s = open_bytes("wave.gif", bytes.into(), &[]).unwrap(); + let info = s.info(); + assert_eq!(info.kind, MediaKind::Movie); + assert_eq!(info.video.as_ref().unwrap().codec, "GIF"); + assert_eq!(info.duration, ms(140), "40 ms, then a zero delay played as 100 ms as browsers do"); + assert!(info.video.as_ref().unwrap().has_alpha); + let first = pixels(&*s, Tick::ZERO); + assert_eq!(first[3], 0, "the GIF's transparent pixels stay transparent"); + assert_eq!(&first[12..16], &[255, 0, 0, 255]); + assert_eq!(&pixels(&*s, ms(40))[0..4], &[0, 255, 0, 255]); +} + +#[test] +fn single_frame_files_stay_stills() { + let one_frame_apng = apng(&[(picture([1, 2, 3], 255), (1, 10))]); + let one_frame_gif = gif(&[(picture([1, 2, 3], 255), 100)]); + let mut plain = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_raw(W, H, picture([1, 2, 3], 0)).unwrap().write_to(&mut plain, image::ImageFormat::Png).unwrap(); + for (name, bytes) in [("one.png", one_frame_apng), ("one.gif", one_frame_gif), ("plain.png", plain.into_inner())] { + assert!(open(name, &bytes).is_none(), "{name}"); + let s = open_bytes(name, bytes.into(), &[]).unwrap(); + assert_eq!(s.info().kind, MediaKind::Still, "{name}"); + } +} + +#[test] +fn damaged_files_never_panic() { + let frames: Vec<_> = (0..6u8).map(|i| (picture([i * 40, 0, 255 - i * 40], i * 40), (1, 10))).collect(); + let png_bytes = apng(&frames); + let gif_bytes = gif(&frames.iter().map(|(p, _)| (p.clone(), 100)).collect::>()); + for whole in [png_bytes, gif_bytes] { + for cut in (0..whole.len()).step_by(7) { + // truncated: opens as fewer frames, a still or an error, never a panic + let _ = open_bytes("cut", whole[..cut].to_vec().into(), &[]); + } + let mut flipped = whole.clone(); + for i in (40..flipped.len()).step_by(11) { + if let Some(b) = flipped.get_mut(i) { + *b ^= 0x5A; + } + } + let _ = open_bytes("flipped", flipped.into(), &[]); + } +} + +#[test] +fn a_damaged_later_frame_keeps_the_frames_before_it() { + let frames: Vec<_> = (0..4u8).map(|i| (picture([i * 60, 1, 2], 255), (1, 10))).collect(); + let whole = apng(&frames); + // cut inside the last frame's data: the first three frames still play + let cut = whole.len() - 20; + let s = open("cut.png", &whole[..cut]).expect("still animated").expect("opens"); + assert_eq!(s.frame_count(), 3); + assert_eq!(s.info().duration, ms(300)); + assert_eq!(pixels(&s, Tick::ZERO), frames[0].0); +} + +#[test] +fn frame_delays_have_browser_minimums_and_never_overflow() { + let d = |n: u32, den: u32| Delay::from_numer_denom_ms(n, den); + assert_eq!(Format::Gif.frame_ticks(d(0, 1)), ms(100)); + assert_eq!(Format::Gif.frame_ticks(d(10, 1)), ms(100)); + assert_eq!(Format::Gif.frame_ticks(d(20, 1)), ms(20)); + assert_eq!(Format::Apng.frame_ticks(d(0, 1)), ms(10)); + assert_eq!(Format::Apng.frame_ticks(d(40, 1)), ms(40)); + assert_eq!(Format::Apng.frame_ticks(d(1000, 3)), Tick(TICKS_PER_SECOND / 3)); + assert!(Format::Apng.frame_ticks(d(u32::MAX, 1)).0 > 0); +} + +#[test] +fn clip_rate_follows_the_common_frame_length() { + assert_eq!(rate_of(&[ms(40), ms(40), ms(100)]), FrameRate::new(25, 1)); + assert_eq!(rate_of(&[ms(5_000), ms(5_000)]), FrameRate::FPS_30, "0.2 fps is not a video rate"); + assert_eq!(rate_of(&[]), FrameRate::FPS_30); +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index a73451e7..8397cd42 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -9,6 +9,7 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] +pub mod animated; pub mod cache; pub mod cancel; pub mod digits; @@ -185,7 +186,8 @@ pub fn is_importable(path: &Path) -> bool { /// A pluggable opener (containers/codecs register themselves here from higher-level crates). pub type Opener = fn(name: &str, bytes: Arc<[u8]>) -> Option>; -/// Open media from bytes, trying `extra` openers first, then the built-in ones (stills, WAV). +/// Open media from bytes, trying `extra` openers first, then the built-in ones (WAV, animated +/// images, stills). pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result { for o in extra { if let Some(r) = o(name, bytes.clone()) { @@ -195,6 +197,10 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result