Files
photocraft/crates/codecs/tests/webp_frames.rs
T
lloydandClaude Opus 5.5 54e7287ba6 Animated WebP: every frame loads into video layers (#1198)
An animated WebP used to bring only its first frame into a video layer. Now
New Video Layer from File, Replace Footage, Reload Frame and Video Frames to
Layers take every frame, as they do for an animated GIF (#572):

- photocraft-codecs: `decode_webp_frames` returns each frame composited onto
  the canvas by image-webp (offsets, blending, disposal), with its duration in
  milliseconds and the file's ICC profile. A still WebP is one frame, read as
  `decode` reads it. A damaged later frame keeps the frames before it and marks
  the first `Truncated`; the frame count and total bytes are checked against
  `Limits`. `webp_is_animated` tells the two apart. New `decode_webp` fuzz
  target.
- photocraft-io: `import_webp_frames`, sharing `import_gif_frames`' loop.
- photocraft-engine: `load_frames` takes an animated WebP's frames; the
  timeline rate comes from the most common duration. A still WebP keeps the
  existing path and the 30 fps default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:24:07 +02:00

167 lines
6.5 KiB
Rust

//! `decode_webp_frames`: every frame of an animated WebP, composited, with its duration (#1198).
use photocraft_codecs::{
AnimationFrame, ChannelLayout, CodecError, DecodeOptions, DecodeWarning, EncodeOptions, Format, Image, Limits, decode, decode_webp_frames, encode,
webp_is_animated,
};
const RED: [u8; 4] = [255, 0, 0, 255];
const GREEN: [u8; 4] = [0, 255, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
fn frames(bytes: &[u8]) -> Vec<AnimationFrame> {
decode_webp_frames(bytes, &DecodeOptions::default()).unwrap()
}
fn solid(c: [u8; 4], w: u32, h: u32) -> Image {
Image::from_u8(w, h, ChannelLayout::Rgba, c.repeat((w * h) as usize)).unwrap()
}
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut c = id.to_vec();
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
c.extend_from_slice(data);
if data.len() % 2 == 1 {
c.push(0);
}
c
}
fn u24(v: u32) -> [u8; 3] {
let b = v.to_le_bytes();
[b[0], b[1], b[2]]
}
/// One frame of [`animated`]: the picture, where it sits on the canvas (even offsets, as WebP
/// stores them halved), its duration in milliseconds, and whether it is alpha-blended over the
/// canvas rather than replacing it.
struct Part {
image: Image,
at: [u32; 2],
duration_ms: u32,
blend: bool,
}
fn part(c: [u8; 4], w: u32, h: u32, duration_ms: u32) -> Part {
Part { image: solid(c, w, h), at: [0, 0], duration_ms, blend: false }
}
/// An animated WebP with a `w`x`h` canvas (transparent background) and one ANMF chunk per part,
/// each holding a lossless still from our own encoder.
fn animated(w: u32, h: u32, parts: &[Part]) -> Vec<u8> {
let mut body = b"WEBP".to_vec();
let mut vp8x = vec![0x02 | 0x10, 0, 0, 0]; // animation and alpha flags
vp8x.extend_from_slice(&u24(w - 1));
vp8x.extend_from_slice(&u24(h - 1));
body.extend(chunk(b"VP8X", &vp8x));
body.extend(chunk(b"ANIM", &[0, 0, 0, 0, 0, 0])); // background 0, loop forever
for p in parts {
let still = encode(&p.image, Format::WebP, &EncodeOptions::default()).unwrap();
let at = still.windows(4).position(|w| w == b"VP8L").unwrap();
let len = u32::from_le_bytes(still[at + 4..at + 8].try_into().unwrap()) as usize;
let mut anmf = Vec::new();
anmf.extend_from_slice(&u24(p.at[0] / 2));
anmf.extend_from_slice(&u24(p.at[1] / 2));
anmf.extend_from_slice(&u24(p.image.width() - 1));
anmf.extend_from_slice(&u24(p.image.height() - 1));
anmf.extend_from_slice(&u24(p.duration_ms));
anmf.push(if p.blend { 0 } else { 0x02 }); // bit 1: do not blend
anmf.extend(chunk(b"VP8L", &still[at + 8..at + 8 + len]));
body.extend(chunk(b"ANMF", &anmf));
}
let mut out = b"RIFF".to_vec();
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend(body);
out
}
fn pixel(f: &AnimationFrame, x: u32, y: u32) -> [u8; 4] {
let rgba = f.image.to_rgba8();
let i = ((y * f.image.width() + x) * 4) as usize;
rgba[i..i + 4].try_into().unwrap()
}
#[test]
fn a_still_webp_gives_one_frame_equal_to_decode() {
let still = encode(&solid(RED, 6, 4), Format::WebP, &EncodeOptions::default()).unwrap();
assert!(!webp_is_animated(&still));
let f = frames(&still);
assert_eq!(f.len(), 1);
assert_eq!(f[0].delay_ms, 0);
assert_eq!(f[0].image, decode(&still).unwrap());
}
#[test]
fn a_one_frame_animation_is_not_animated() {
let a = animated(6, 4, &[part(RED, 6, 4, 70)]);
assert!(!webp_is_animated(&a));
assert_eq!(frames(&a).len(), 1);
}
#[test]
fn every_frame_comes_back_in_order_with_its_duration() {
let a = animated(6, 4, &[part(RED, 6, 4, 40), part(GREEN, 6, 4, 120), part(BLUE, 6, 4, 7)]);
assert!(webp_is_animated(&a));
let f = frames(&a);
assert_eq!(f.iter().map(|f| f.delay_ms).collect::<Vec<_>>(), [40, 120, 7], "WebP durations are milliseconds");
for (f, c) in f.iter().zip([RED, GREEN, BLUE]) {
assert_eq!((f.image.width(), f.image.height()), (6, 4));
assert_eq!(pixel(f, 0, 0), c);
assert_eq!(pixel(f, 5, 3), c);
assert_eq!(f.image.warnings, []);
}
}
#[test]
fn a_smaller_frame_is_drawn_over_the_previous_one() {
let small = Part { image: solid(BLUE, 2, 2), at: [2, 2], duration_ms: 50, blend: true };
let f = frames(&animated(6, 4, &[part(RED, 6, 4, 50), small]));
assert_eq!(f.len(), 2);
// Alpha blending rounds: within one level.
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1);
assert!(near(pixel(&f[1], 0, 0), RED), "outside the second frame the first shows through");
assert!(near(pixel(&f[1], 2, 2), BLUE));
assert!(near(pixel(&f[1], 3, 3), BLUE));
assert!(near(pixel(&f[1], 4, 2), RED));
}
#[test]
fn a_damaged_later_frame_keeps_the_frames_before_it() {
let mut a = animated(6, 4, &[part(RED, 6, 4, 50), part(GREEN, 6, 4, 50), part(BLUE, 6, 4, 50)]);
// Break the last frame's VP8L signature byte; the container around it stays whole.
let last = a.windows(4).rposition(|w| w == b"VP8L").unwrap();
a[last + 8] = 0;
let f = frames(&a);
assert_eq!(f.len(), 2);
assert_eq!(pixel(&f[0], 0, 0), RED);
assert_eq!(pixel(&f[1], 0, 0), GREEN);
assert_eq!(f[0].image.warnings, [DecodeWarning::Truncated { format: Format::WebP }]);
}
#[test]
fn every_prefix_of_an_animated_webp_returns() {
let a = animated(5, 4, &[part(RED, 5, 4, 50), part(GREEN, 5, 4, 50), part(BLUE, 5, 4, 50)]);
for n in 0..a.len() {
let _ = decode_webp_frames(&a[..n], &DecodeOptions::default());
}
}
#[test]
fn dimension_limits_apply() {
let a = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]);
let opts = DecodeOptions { limits: Limits { max_width: 3, ..Limits::default() }, ..Default::default() };
assert!(matches!(decode_webp_frames(&a, &opts), Err(CodecError::LimitExceeded(_))));
}
#[test]
fn the_byte_budget_covers_all_frames_together() {
// One 4x4 RGBA frame is 64 bytes. The decoder's own canvas counts against the budget too, so
// two frames need more than 128; ten frames of the same size must not fit where two do.
let two = animated(4, 4, &[part(RED, 4, 4, 50), part(GREEN, 4, 4, 50)]);
let ten: Vec<Part> = (0..10).map(|i| part(if i % 2 == 0 { RED } else { BLUE }, 4, 4, 50)).collect();
let ten = animated(4, 4, &ten);
let opts = DecodeOptions { limits: Limits { max_alloc: 256, ..Limits::default() }, ..Default::default() };
assert_eq!(decode_webp_frames(&two, &opts).unwrap().len(), 2);
assert!(matches!(decode_webp_frames(&ten, &opts), Err(CodecError::LimitExceeded(_))));
}