mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 19:26:59 -04:00
Merge pull request #931 from liangforstudy/feat/402-apng-animation
#402: animated GIF and APNG import as clips, with transparency
This commit is contained in:
commit
79b48e5452
10 files changed
+481
-4
No files matched your search
Generated
+1
@@ -2103,6 +2103,7 @@ dependencies = [
|
||||
"filmcraft-frame",
|
||||
"filmcraft-time",
|
||||
"image",
|
||||
"png",
|
||||
"rayon",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -185,7 +185,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
|
||||
| **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks |
|
||||
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
|
||||
| **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet |
|
||||
| **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)).
|
||||
|
||||
|
||||
@@ -47,4 +47,4 @@ web-time = "1"
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
filmcraft-isobmff = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "gif"] }
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1209,6 +1209,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;
|
||||
|
||||
@@ -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"
|
||||
@@ -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<Format> {
|
||||
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<Arc<VideoFrame>>,
|
||||
/// Media time each frame starts at; ascending, `starts[0]` is zero.
|
||||
starts: Vec<Tick>,
|
||||
}
|
||||
|
||||
/// 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<Result<AnimatedSource>> {
|
||||
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<Self> {
|
||||
let mut pictures: Vec<(u32, u32, Vec<u8>)> = Vec::new();
|
||||
let mut durations: Vec<Tick> = 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.as_chunks::<4>().0.iter().any(|p| p[3] < 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<Tick> {
|
||||
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<i64> = 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<Arc<VideoFrame>> {
|
||||
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<AudioBuffer> {
|
||||
Err(MediaError::NoStream("audio"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "animated_tests.rs"]
|
||||
mod tests;
|
||||
@@ -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<u8> {
|
||||
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<u8>, (u16, u16))]) -> Vec<u8> {
|
||||
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<u8>, u32)]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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::<Vec<_>>());
|
||||
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);
|
||||
}
|
||||
@@ -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<Result<SharedSource>>;
|
||||
|
||||
/// 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<SharedSource> {
|
||||
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<Shar
|
||||
if wav::sniff(&bytes) {
|
||||
return Ok(Arc::new(wav::WavSource::parse(name, bytes)?));
|
||||
}
|
||||
// APNG and animated GIF play as movies (#402); single-frame files fall through to the stills
|
||||
if let Some(r) = animated::open(name, &bytes) {
|
||||
return Ok(Arc::new(r?));
|
||||
}
|
||||
if still::sniff(name, &bytes) {
|
||||
return Ok(Arc::new(still::StillSource::decode(name, &bytes)?));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Still images (PNG, JPEG, GIF (first frame), WebP, TIFF, BMP, Targa, DPX) as media sources.
|
||||
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX) as media sources. Animated PNG and
|
||||
//! GIF files open as movies instead ([`crate::animated`]); here they would show their first frame.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
Reference in new issue
Block a user