diff --git a/Cargo.lock b/Cargo.lock index 77e949e9..31bc4ee4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1798,6 +1798,7 @@ dependencies = [ name = "craft-webp" version = "0.1.0" dependencies = [ + "craft-video", "image", "thiserror 2.0.21", ] diff --git a/crates/webp/Cargo.toml b/crates/webp/Cargo.toml index 5d6c4cfc..abb63fb2 100644 --- a/crates/webp/Cargo.toml +++ b/crates/webp/Cargo.toml @@ -10,8 +10,13 @@ repository.workspace = true [lints] workspace = true +[features] +# Lossy animations from RGBA frames through craft-video's VP8 key-frame encoder. +vp8 = ["dep:craft-video"] + [dependencies] thiserror = { workspace = true } +craft-video = { workspace = true, optional = true, features = ["vp8-encode"] } [dev-dependencies] # Decodes the files the writer makes and writes still test files; not a product dependency. diff --git a/crates/webp/src/anim.rs b/crates/webp/src/anim.rs new file mode 100644 index 00000000..3c6df1a6 --- /dev/null +++ b/crates/webp/src/anim.rs @@ -0,0 +1,275 @@ +//! Animated WebP writing: the `VP8X`, `ANIM` and `ANMF` chunks around frames another encoder +//! made ([`animation`]), and, with feature `vp8`, whole lossy animations from RGBA frames through +//! `craft-video`'s VP8 encoder ([`lossy_animation`]). + +use crate::{Error, MAX_CANVAS, MAX_VP8_DIMENSION, chunk}; + +/// The encoded picture of one frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Payload<'a> { + /// A VP8 key frame and, optionally, an alpha plane of one byte a pixel (stored uncompressed). + Lossy { vp8: &'a [u8], alpha: Option<&'a [u8]> }, + /// A VP8L (lossless) bitstream, signature byte included. + Lossless { vp8l: &'a [u8] }, +} + +/// One frame of an animation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AnimFrame<'a> { + /// Position on the canvas; both must be even. + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, + /// How long the frame is shown, in milliseconds (below 2^24). + pub duration_ms: u32, + /// Alpha-blend onto the canvas (false: replace the frame's rectangle). + pub blend: bool, + /// Clear the frame's rectangle to the background before the next frame. + pub dispose_to_background: bool, + pub payload: Payload<'a>, +} + +/// Animation-wide settings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct AnimOptions<'a> { + /// Times to play: 0 loops forever. + pub loop_count: u16, + /// Background colour as stored in the file: blue, green, red, alpha. A hint players may ignore. + pub background_bgra: [u8; 4], + /// Set the alpha flag even when no lossy frame carries an alpha plane (lossless frames with alpha). + pub alpha: bool, + pub icc: Option<&'a [u8]>, + pub exif: Option<&'a [u8]>, + pub xmp: Option<&'a [u8]>, +} + +fn u24(out: &mut Vec, v: u32) { + out.extend_from_slice(&v.to_le_bytes()[..3]); +} + +/// Writes an animated WebP of a `width × height` canvas with these frames, in order. +pub fn animation(width: u32, height: u32, frames: &[AnimFrame], opts: &AnimOptions) -> Result, Error> { + if width == 0 || height == 0 || width > MAX_CANVAS || height > MAX_CANVAS || u64::from(width) * u64::from(height) > u64::from(u32::MAX) { + return Err(Error::Encode("the canvas is 1 to 2^24 pixels a side and at most 2^32 pixels")); + } + if frames.is_empty() { + return Err(Error::Encode("an animation needs a frame")); + } + let mut anmf = Vec::new(); + let mut any_alpha = opts.alpha; + for f in frames { + if f.width == 0 || f.height == 0 || f.x % 2 != 0 || f.y % 2 != 0 { + return Err(Error::Encode("a frame is empty or not at an even position")); + } + if f.x.checked_add(f.width).is_none_or(|r| r > width) || f.y.checked_add(f.height).is_none_or(|b| b > height) { + return Err(Error::Encode("a frame reaches past the canvas")); + } + if f.duration_ms >= 1 << 24 { + return Err(Error::Encode("a frame duration is 2^24 ms or more")); + } + let mut body = Vec::new(); + u24(&mut body, f.x / 2); + u24(&mut body, f.y / 2); + u24(&mut body, f.width - 1); + u24(&mut body, f.height - 1); + u24(&mut body, f.duration_ms); + // Bit 1: do not blend; bit 0: dispose to the background. + body.push(u8::from(!f.blend) << 1 | u8::from(f.dispose_to_background)); + match f.payload { + Payload::Lossy { vp8, alpha } => { + if f.width > MAX_VP8_DIMENSION || f.height > MAX_VP8_DIMENSION { + return Err(Error::Encode("a lossy frame is at most 16383 pixels a side")); + } + if let Some(a) = alpha { + if a.len() as u64 != u64::from(f.width) * u64::from(f.height) { + return Err(Error::Encode("an alpha plane does not match its frame")); + } + any_alpha = true; + let mut alph = Vec::with_capacity(a.len() + 1); + // Header byte: no pre-processing, no filtering, no compression. + alph.push(0); + alph.extend_from_slice(a); + chunk(&mut body, b"ALPH", &alph)?; + } + chunk(&mut body, b"VP8 ", vp8)?; + } + Payload::Lossless { vp8l } => chunk(&mut body, b"VP8L", vp8l)?, + } + chunk(&mut anmf, b"ANMF", &body)?; + } + let mut body = b"WEBP".to_vec(); + let flags = [(opts.icc.is_some(), 0x20u8), (any_alpha, 0x10), (opts.exif.is_some(), 0x08), (opts.xmp.is_some(), 0x04), (true, 0x02)] + .iter() + .filter(|(on, _)| *on) + .fold(0, |f, (_, bit)| f | bit); + let mut vp8x = vec![flags, 0, 0, 0]; + u24(&mut vp8x, width - 1); + u24(&mut vp8x, height - 1); + chunk(&mut body, b"VP8X", &vp8x)?; + if let Some(icc) = opts.icc { + chunk(&mut body, b"ICCP", icc)?; + } + let mut anim = opts.background_bgra.to_vec(); + anim.extend_from_slice(&opts.loop_count.to_le_bytes()); + chunk(&mut body, b"ANIM", &anim)?; + body.extend_from_slice(&anmf); + if let Some(exif) = opts.exif { + chunk(&mut body, b"EXIF", exif)?; + } + if let Some(xmp) = opts.xmp { + chunk(&mut body, b"XMP ", xmp)?; + } + let size = u32::try_from(body.len()).map_err(|_| Error::Encode("the file is larger than 4 GiB"))?; + let mut out = Vec::with_capacity(body.len() + 8); + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&size.to_le_bytes()); + out.extend_from_slice(&body); + Ok(out) +} + +/// Writes a lossy animated WebP of full-canvas RGBA8 frames (`width × height × 4` bytes each, +/// straight alpha) with these durations, each frame a VP8 key frame at `quality` (0–100). A frame +/// whose pixels are all opaque gets no alpha plane. Frames replace the canvas (no blending). +#[cfg(feature = "vp8")] +pub fn lossy_animation(width: u32, height: u32, frames: &[(&[u8], u32)], quality: u8, opts: &AnimOptions) -> Result, Error> { + use craft_video::vp8::{Vp8Error, Vp8Params, encode_rgba}; + let params = Vp8Params { quality, loop_filter: None }; + let mut encoded = Vec::with_capacity(frames.len()); + for (rgba, duration_ms) in frames { + let vp8 = encode_rgba(rgba, width, height, ¶ms) + .map_err(|e| match e { + Vp8Error::Size(..) => Error::Encode("a lossy frame is 1 to 16383 pixels a side"), + Vp8Error::Buffer(..) => Error::Encode("a frame is not width × height × 4 bytes of RGBA"), + Vp8Error::PartitionTooLarge(_) => Error::Encode("a lossy frame's first partition is over the 19-bit limit"), + })? + .data; + let alpha: Vec = rgba.iter().skip(3).step_by(4).copied().collect(); + let alpha = (!alpha.iter().all(|&a| a == 255)).then_some(alpha); + encoded.push((vp8, alpha, *duration_ms)); + } + let list: Vec = encoded + .iter() + .map(|(vp8, alpha, duration_ms)| AnimFrame { + x: 0, + y: 0, + width, + height, + duration_ms: *duration_ms, + blend: false, + dispose_to_background: false, + payload: Payload::Lossy { vp8, alpha: alpha.as_deref() }, + }) + .collect(); + animation(width, height, &list, opts) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use image::{AnimationDecoder, ExtendedColorType, ImageEncoder, codecs::webp::WebPDecoder, codecs::webp::WebPEncoder}; + + use super::*; + use crate::scan; + + /// The `VP8L` chunk payload of a lossless still of one colour, written by `image`. + fn vp8l(w: u32, h: u32, rgba: [u8; 4]) -> Vec { + let mut file = Vec::new(); + WebPEncoder::new_lossless(&mut file).write_image(&rgba.repeat((w * h) as usize), w, h, ExtendedColorType::Rgba8).unwrap(); + let at = file.windows(4).position(|c| c == b"VP8L").unwrap(); + let size = u32::from_le_bytes(file[at + 4..at + 8].try_into().unwrap()) as usize; + file[at + 8..at + 8 + size].to_vec() + } + + fn frame(payload: Payload<'_>, x: u32, y: u32, w: u32, h: u32, ms: u32) -> AnimFrame<'_> { + AnimFrame { x, y, width: w, height: h, duration_ms: ms, blend: false, dispose_to_background: false, payload } + } + + #[test] + fn lossless_frames_round_trip_through_scan_and_a_decoder() { + let (red, green) = (vp8l(4, 2, [255, 0, 0, 255]), vp8l(2, 2, [0, 255, 0, 128])); + let frames = [frame(Payload::Lossless { vp8l: &red }, 0, 0, 4, 2, 40), frame(Payload::Lossless { vp8l: &green }, 2, 0, 2, 2, 120)]; + let mut opts = AnimOptions { loop_count: 3, alpha: true, ..AnimOptions::default() }; + opts.xmp = Some(b""); + let file = animation(4, 2, &frames, &opts).unwrap(); + let info = scan(&file, 10).unwrap(); + assert!(info.is_animation() && info.complete && info.alpha); + assert_eq!((info.width, info.height, info.loop_count), (4, 2, Some(3))); + assert_eq!(info.frames.iter().map(|f| (f.x, f.width, f.duration_ms, f.blend)).collect::>(), [(0, 4, 40, false), (2, 2, 120, false)]); + // The `image` crate composites it: the second frame replaces the right half. + let d = WebPDecoder::new(std::io::Cursor::new(&file)).unwrap(); + let decoded: Vec<_> = d.into_frames().collect::>().unwrap(); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].buffer().get_pixel(3, 1).0, [255, 0, 0, 255]); + assert_eq!(decoded[1].buffer().get_pixel(3, 1).0, [0, 255, 0, 128]); + assert_eq!(decoded[1].buffer().get_pixel(0, 0).0, [255, 0, 0, 255]); + assert_eq!(decoded[1].delay().numer_denom_ms(), (120, 1)); + } + + #[test] + fn frame_flags_and_chunk_order() { + let px = vp8l(2, 2, [1, 2, 3, 255]); + let mut f = frame(Payload::Lossless { vp8l: &px }, 0, 0, 2, 2, 10); + f.blend = true; + f.dispose_to_background = true; + let file = + animation(2, 2, &[f], &AnimOptions { icc: Some(b"icc"), exif: Some(b"e"), background_bgra: [1, 2, 3, 4], ..AnimOptions::default() }) + .unwrap(); + let info = scan(&file, 10).unwrap(); + assert_eq!((info.frames[0].blend, info.frames[0].dispose_to_background, info.alpha, info.animated), (true, true, false, true)); + let mut at = 12; + let mut seen = Vec::new(); + while at < file.len() { + let size = u32::from_le_bytes(file[at + 4..at + 8].try_into().unwrap()) as usize; + seen.push(String::from_utf8_lossy(&file[at..at + 4]).into_owned()); + at += 8 + size + (size & 1); + } + assert_eq!(seen, ["VP8X", "ICCP", "ANIM", "ANMF", "EXIF"]); + // ICC, EXIF and animation flags; the background colour and loop count in ANIM. + assert_eq!(file[20], 0x20 | 0x08 | 0x02); + let anim = file.windows(4).position(|c| c == b"ANIM").unwrap(); + assert_eq!(&file[anim + 8..anim + 14], &[1, 2, 3, 4, 0, 0]); + } + + #[test] + fn what_does_not_fit_is_refused() { + let px = vp8l(2, 2, [0; 4]); + let ok = frame(Payload::Lossless { vp8l: &px }, 0, 0, 2, 2, 10); + let o = AnimOptions::default(); + assert!(animation(2, 2, &[ok], &o).is_ok()); + assert!(animation(2, 2, &[], &o).is_err()); + assert!(animation(0, 2, &[ok], &o).is_err()); + assert!(animation(MAX_CANVAS + 1, 1, &[ok], &o).is_err()); + assert!(animation(2, 2, &[AnimFrame { x: 1, ..ok }], &o).is_err()); + assert!(animation(2, 2, &[AnimFrame { x: 2, ..ok }], &o).is_err()); + assert!(animation(2, 2, &[AnimFrame { width: 0, ..ok }], &o).is_err()); + assert!(animation(2, 2, &[AnimFrame { duration_ms: (1 << 24) - 1, ..ok }], &o).is_ok()); + assert!(animation(2, 2, &[AnimFrame { duration_ms: 1 << 24, ..ok }], &o).is_err()); + let lossy = AnimFrame { payload: Payload::Lossy { vp8: &[0; 10], alpha: Some(&[0; 3]) }, ..ok }; + assert!(animation(2, 2, &[lossy], &o).is_err()); + let wide = AnimFrame { width: MAX_VP8_DIMENSION + 1, payload: Payload::Lossy { vp8: &[0; 10], alpha: None }, ..ok }; + assert!(animation(MAX_VP8_DIMENSION + 1, 2, &[wide], &o).is_err()); + } + + #[cfg(feature = "vp8")] + #[test] + fn lossy_animations_decode_with_their_colours_alpha_and_timing() { + let (w, h) = (24u32, 16u32); + let solid = |c: [u8; 4]| c.repeat((w * h) as usize); + let (red, half_blue) = (solid([220, 30, 30, 255]), solid([30, 30, 220, 128])); + let file = lossy_animation(w, h, &[(&red, 100), (&half_blue, 50)], 90, &AnimOptions::default()).unwrap(); + let info = scan(&file, 10).unwrap(); + assert!(info.is_animation() && info.alpha && info.complete); + assert_eq!(info.frames.iter().map(|f| f.duration_ms).collect::>(), [100, 50]); + let d = WebPDecoder::new(std::io::Cursor::new(&file)).unwrap(); + let decoded: Vec<_> = d.into_frames().collect::>().unwrap(); + assert_eq!(decoded.len(), 2); + let close = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 6); + assert!(close(decoded[0].buffer().get_pixel(5, 5).0, [220, 30, 30, 255]), "{:?}", decoded[0].buffer().get_pixel(5, 5)); + assert!(close(decoded[1].buffer().get_pixel(5, 5).0, [30, 30, 220, 128]), "{:?}", decoded[1].buffer().get_pixel(5, 5)); + // An opaque frame carries no alpha plane; a buffer of the wrong size is refused. + let opaque = lossy_animation(w, h, &[(&red, 10), (&red, 10)], 50, &AnimOptions::default()).unwrap(); + assert!(!scan(&opaque, 10).unwrap().alpha); + assert!(lossy_animation(w, h, &[(&red[..10], 10)], 50, &AnimOptions::default()).is_err()); + } +} diff --git a/crates/webp/src/lib.rs b/crates/webp/src/lib.rs index f915a6fc..35d00b9b 100644 --- a/crates/webp/src/lib.rs +++ b/crates/webp/src/lib.rs @@ -11,9 +11,14 @@ //! else, or the extended form (`VP8X`) with an uncompressed alpha plane and ICC, EXIF and XMP //! chunks in the order the specification gives. //! +//! Animated files: [`anim::animation`] writes the `ANIM` / `ANMF` chunks around frames another +//! encoder made; with feature `vp8`, [`anim::lossy_animation`] encodes RGBA frames with +//! `craft-video`'s VP8 encoder. +//! //! Input is treated as hostile: every read is bounds-checked, chunk sizes are checked against the //! file, and the number of frames recorded is capped by the caller. +pub mod anim; #[cfg(test)] mod tests;