oxideav_webp/anim_encode.rs
1//! Animation *encoder* — the published-0.1.5 `build_animated_webp` surface,
2//! rebuilt clean-room on top of the in-crate §3.7 VP8L lossless encoder and
3//! the §2.7.1.1 `ANIM` / `ANMF` container framing.
4//!
5//! Where [`crate::anim`] / [`crate::anmf`] *parse* the global and per-frame
6//! animation headers, and [`crate::build`] frames a single still image, this
7//! module assembles a **multi-frame** animated `.webp` from a list of
8//! caller-supplied frames:
9//!
10//! ```text
11//! RIFF | WEBP | VP8X(A flag) | [ICCP] | ANIM | ANMF… ANMF | [EXIF] | [XMP ]
12//! ```
13//!
14//! Each `ANMF` carries the §2.7.1.1 Figure 9 16-byte header (frame X / Y /
15//! width / height / duration plus the `Reserved|B|D` info byte) followed by
16//! its "Frame Data" — a padded §2.3 sub-RIFF holding a single §2.6 `VP8L`
17//! chunk for the [`AnimFrameMode::Lossless`] path. The bitstream itself is
18//! produced by [`crate::vp8l_encode::encode_vp8l_argb_with`], so the encoded
19//! file decodes back through [`crate::decode_webp`] (animation path) to the
20//! exact input pixels.
21//!
22//! ## Auto / Delta encoding (round 127)
23//!
24//! [`AnimFrameMode::Lossless`] always emits the full caller-supplied frame
25//! at its `(x, y)` offset as a single VP8L keyframe. [`AnimFrameMode::Delta`]
26//! and [`AnimFrameMode::Auto`] take advantage of the §2.7.1.1
27//! `B = 1` (overwrite) / `D = 0` (no dispose) ANMF semantics to encode only
28//! the **dirty rectangle** of each frame against the previous canvas:
29//!
30//! * `Delta` always emits the dirty-rect sub-frame; pixels outside the
31//! dirty rect remain whatever the previous frame left on the canvas, so
32//! the §2.7.1.1 compositing rules naturally reconstruct the caller's
33//! intended canvas on decode. The sub-frame carries the **post-composite**
34//! pixels (the frame as drawn per its `blend` method), which keeps
35//! `AlphaBlend` frames bit-exact. Two cases fall back to a full keyframe
36//! with the caller's flags honoured verbatim: the first frame (no
37//! previous canvas to diff against) and any frame with
38//! `dispose == Background` (the §2.7.1.1 clear applies to the frame's
39//! declared rect, which a smaller dirty-rect ANMF cannot express).
40//! * `Auto` evaluates both the dirty-rect sub-frame and the full-canvas
41//! keyframe and emits whichever produces a smaller VP8L bitstream
42//! (subject to the same two full-keyframe fallbacks).
43//!
44//! Both modes are **lossless** — every encoded byte round-trips through
45//! [`crate::decode_webp`] to the exact caller-provided pixels, the same as
46//! `Lossless`. The [`DeltaConfig`] / [`DownsampleKernel`] knobs are
47//! preserved for API-shape compatibility but the dirty-rect algorithm
48//! does not consult them yet (they were originally intended for a
49//! lossy-aware MS-SSIM quality gate).
50
51use crate::anmf::{BlendingMethod, DisposalMethod};
52use crate::build::{self, Vp8xFlags};
53use crate::container::fourcc;
54use crate::vp8l_encode;
55use crate::{Error, WebpError, WebpMetadata};
56
57/// §2.7.1.1 Figure 9 fixed `ANMF` header length (5 × uint24 + 1 info byte).
58const ANMF_HEADER_LEN: usize = 16;
59
60/// §2.7.1.1 Figure 8 fixed `ANIM` payload length (uint32 bg + uint16 loop).
61const ANIM_PAYLOAD_LEN: usize = 6;
62
63/// How a single animation frame's pixels are compressed into its `ANMF`
64/// "Frame Data" bitstream subchunk.
65///
66/// Reproduces the published-0.1.5 variant set. All three modes are wired
67/// in this build; the round-127 `Auto` and `Delta` paths encode the
68/// caller's full-canvas frame as a **lossless dirty-rect sub-frame**
69/// against the previous canvas (the original lossy keyframe vs. inter-frame
70/// delta choice is deferred until the `oxideav-vp8` lossy encoder is ready,
71/// at which point `Auto` will also evaluate a lossy candidate).
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum AnimFrameMode {
74 /// Evaluate both the full-canvas VP8L keyframe and the dirty-rect VP8L
75 /// sub-frame and emit whichever is smaller. Falls back to the
76 /// full-canvas keyframe for the first frame and for frames whose
77 /// dirty rect happens to cover the whole canvas.
78 #[default]
79 Auto,
80 /// Always emit the dirty-rect sub-frame (the §2.7.1.1 `B = 1` / `D = 0`
81 /// overwrite-no-dispose path). First frame is always the full canvas.
82 Delta,
83 /// Encode the frame as a standalone §2.6 `VP8L` lossless keyframe.
84 Lossless,
85}
86
87/// A single animation frame to encode.
88///
89/// `pixels` is `width * height * 4` interleaved 8-bit `[R, G, B, A]` bytes in
90/// scan-line order — the same flat layout [`crate::WebpFrame::rgba`] decodes
91/// to. `x` / `y` place the frame's upper-left corner on the canvas (must be
92/// even per §2.7.1.1, since the on-disk field is the coordinate / 2).
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct AnimFrame {
95 /// `width * height * 4` interleaved `[R, G, B, A]` bytes, scan order.
96 pub pixels: Vec<u8>,
97 /// Frame width in pixels (≥ 1).
98 pub width: u32,
99 /// Frame height in pixels (≥ 1).
100 pub height: u32,
101 /// X coordinate of the frame's upper-left corner on the canvas. Must be
102 /// even (§2.7.1.1 stores `x / 2`).
103 pub x: u32,
104 /// Y coordinate of the frame's upper-left corner on the canvas. Must be
105 /// even (§2.7.1.1 stores `y / 2`).
106 pub y: u32,
107 /// Display duration in 1-millisecond units (the §2.7.1.1 `Frame
108 /// Duration` field).
109 pub duration: u32,
110 /// §2.7.1.1 blending method (`B` bit).
111 pub blend: BlendingMethod,
112 /// §2.7.1.1 disposal method (`D` bit).
113 pub dispose: DisposalMethod,
114 /// Per-frame compression mode.
115 pub mode: AnimFrameMode,
116}
117
118impl AnimFrame {
119 /// Construct a top-left, **overwrite-blended**, non-disposed lossless
120 /// frame from a flat RGBA buffer — the common case.
121 ///
122 /// `BlendingMethod::Overwrite` (§2.7.1.1 `B = 1`) is the default so a
123 /// full-canvas frame round-trips byte-for-byte through
124 /// [`crate::decode_webp`]'s canvas-compositing path. Callers that want
125 /// §2.7.1.1 alpha-blending of a translucent sub-frame onto the existing
126 /// canvas must build the struct literally and set `blend:
127 /// BlendingMethod::AlphaBlend`.
128 pub fn new(width: u32, height: u32, pixels: Vec<u8>, duration: u32) -> Self {
129 Self {
130 pixels,
131 width,
132 height,
133 x: 0,
134 y: 0,
135 duration,
136 blend: BlendingMethod::Overwrite,
137 dispose: DisposalMethod::None,
138 mode: AnimFrameMode::Lossless,
139 }
140 }
141}
142
143/// Multi-scale SSIM downsample kernel selector for the (blocked) delta path.
144///
145/// Re-exposed for published-API shape compatibility. Has no effect on the
146/// lossless path.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum DownsampleKernel {
149 /// Plain box (average) downsample.
150 #[default]
151 Box,
152 /// Gaussian-weighted downsample.
153 Gaussian,
154}
155
156/// Tuning knobs for the inter-frame delta path.
157///
158/// Re-exposed for published-API shape compatibility. The fields feed the
159/// (still blocked) [`AnimFrameMode::Delta`] / [`AnimFrameMode::Auto`] paths;
160/// they have no effect on the lossless path.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct DeltaConfig {
163 /// Maximum number of disjoint dirty-rectangle components to keep when
164 /// diffing two frames before falling back to a full keyframe.
165 pub max_components: usize,
166 /// Optional byte threshold below which an inner sub-rectangle delta is
167 /// preferred. `None` disables the heuristic.
168 pub auto_inner_threshold_bytes: Option<usize>,
169 /// Which kernel to use when downsampling for the MS-SSIM quality gate.
170 pub msssim_downsample_kernel: DownsampleKernel,
171}
172
173impl Default for DeltaConfig {
174 fn default() -> Self {
175 Self {
176 max_components: 8,
177 auto_inner_threshold_bytes: None,
178 msssim_downsample_kernel: DownsampleKernel::Box,
179 }
180 }
181}
182
183impl DeltaConfig {
184 /// Override the maximum dirty-rectangle component count (builder form).
185 pub fn max_components_override(mut self, n: usize) -> Self {
186 self.max_components = n;
187 self
188 }
189
190 /// Set the inner-rectangle byte threshold (builder form).
191 pub fn auto_inner_threshold_bytes(mut self, bytes: Option<usize>) -> Self {
192 self.auto_inner_threshold_bytes = bytes;
193 self
194 }
195
196 /// Select the MS-SSIM downsample kernel (builder form).
197 pub fn msssim_downsample_kernel(mut self, kernel: DownsampleKernel) -> Self {
198 self.msssim_downsample_kernel = kernel;
199 self
200 }
201}
202
203/// Options for [`build_animated_webp_with_options`].
204///
205/// `loop_count` is the §2.7.1.1 `ANIM` loop count (`0` = loop forever).
206/// `background_rgba` is the `ANIM` background colour as `[R, G, B, A]`. The
207/// borrowed [`WebpMetadata`] carries optional ICC / Exif / XMP payloads to
208/// embed in the §2.7 chunk order. `delta` tunes the (blocked) delta path.
209#[derive(Debug, Clone, Copy, Default)]
210pub struct AnimEncoderOptions<'a> {
211 /// §2.7.1.1 `ANIM` loop count. `0` means "loop infinitely".
212 pub loop_count: u16,
213 /// §2.7.1.1 `ANIM` background colour as `[R, G, B, A]`.
214 pub background_rgba: [u8; 4],
215 /// File-level metadata (ICC / Exif / XMP) to embed, borrowed.
216 pub metadata: WebpMetadata<'a>,
217 /// Tuning for the (blocked) inter-frame delta path.
218 pub delta: DeltaConfig,
219}
220
221/// Build an animated `.webp` from `frames`, defaulting all encoder options
222/// (infinite loop, transparent-black background, no metadata).
223///
224/// Convenience wrapper over [`build_animated_webp_with_options`]; see that
225/// function for the full semantics.
226pub fn build_animated_webp(frames: &[AnimFrame]) -> Result<Vec<u8>, WebpError> {
227 build_animated_webp_with_options(frames, &AnimEncoderOptions::default())
228}
229
230/// Assemble a complete animated `RIFF/WEBP` file from `frames` per
231/// RFC 9649 §2.7.1.1.
232///
233/// Output layout:
234///
235/// ```text
236/// RIFF | WEBP | VP8X(A[,L][,I][,E][,X]) | [ICCP] | ANIM | ANMF… | [EXIF] | [XMP ]
237/// ```
238///
239/// The §2.7.1 `VP8X` canvas is sized to cover every frame
240/// (`max(frame.x + frame.width)` × `max(frame.y + frame.height)`). The `A`
241/// (animation) flag is always set; `L` (alpha) is set when any frame carries
242/// a non-opaque pixel; `I` / `E` / `X` follow the supplied metadata.
243///
244/// Each frame's [`AnimFrameMode::Lossless`] pixels are encoded to a §2.6
245/// `VP8L` chunk via [`crate::vp8l_encode::encode_vp8l_argb_with`] and
246/// wrapped in the `ANMF` "Frame Data" sub-RIFF as-is.
247///
248/// **Round 127** (corrected round 279): [`AnimFrameMode::Delta`] encodes
249/// only the dirty rectangle — the bounding box of canvas pixels the frame
250/// actually changes once composited per its `blend` method — as the `ANMF`
251/// sub-frame, carrying the post-composite pixels with `B = 1` (overwrite)
252/// and `D = 0` (no dispose). The first frame, any frame with
253/// `dispose == Background`, and any frame whose dirty rect happens to span
254/// its whole declared rect, fall back to a full keyframe with the caller's
255/// flags honoured verbatim. [`AnimFrameMode::Auto`] evaluates both
256/// candidates and picks the smaller bitstream. Both modes round-trip
257/// byte-for-byte through [`crate::decode_webp`]'s canvas compositor for
258/// every blend/dispose combination.
259///
260/// An empty `frames` slice, a frame whose `pixels` length disagrees with
261/// `width * height * 4`, or an odd `x` / `y` offset is
262/// [`WebpError::InvalidData`].
263pub fn build_animated_webp_with_options(
264 frames: &[AnimFrame],
265 opts: &AnimEncoderOptions<'_>,
266) -> Result<Vec<u8>, WebpError> {
267 if frames.is_empty() {
268 return Err(WebpError::InvalidData);
269 }
270
271 // §2.7.1.1: canvas must cover every frame rectangle.
272 let mut canvas_width = 0u32;
273 let mut canvas_height = 0u32;
274 let mut any_alpha = false;
275
276 for f in frames {
277 if f.width == 0 || f.height == 0 {
278 return Err(WebpError::InvalidData);
279 }
280 // §2.7.1.1 stores Frame X / Frame Y as coord/2, so only even
281 // offsets are representable.
282 if f.x & 1 != 0 || f.y & 1 != 0 {
283 return Err(WebpError::InvalidData);
284 }
285 let expected = (f.width as usize)
286 .checked_mul(f.height as usize)
287 .and_then(|n| n.checked_mul(4));
288 if expected != Some(f.pixels.len()) {
289 return Err(WebpError::InvalidData);
290 }
291 let right = f.x.checked_add(f.width).ok_or(WebpError::InvalidData)?;
292 let bottom = f.y.checked_add(f.height).ok_or(WebpError::InvalidData)?;
293 canvas_width = canvas_width.max(right);
294 canvas_height = canvas_height.max(bottom);
295 if f.pixels.chunks_exact(4).any(|px| px[3] != 0xff) {
296 any_alpha = true;
297 }
298 }
299
300 let meta = &opts.metadata;
301
302 // §2.7.1 VP8X flag octet — animation always; alpha/metadata as present.
303 let flags = Vp8xFlags {
304 has_iccp: meta.icc.is_some(),
305 has_alpha: any_alpha,
306 has_exif: meta.exif.is_some(),
307 has_xmp: meta.xmp.is_some(),
308 has_animation: true,
309 };
310 let vp8x_payload = build::build_vp8x_chunk(canvas_width, canvas_height, flags).map_err(to_w)?;
311
312 let mut body = Vec::new();
313 let mut push = |fourcc, payload: &[u8]| -> Result<(), WebpError> {
314 let chunk = build::build_chunk(fourcc, payload).map_err(to_w)?;
315 body.extend_from_slice(&chunk);
316 Ok(())
317 };
318
319 // §2.7 chunk order: VP8X, ICCP, ANIM, ANMF…, EXIF, XMP.
320 push(fourcc::VP8X, &vp8x_payload)?;
321 if let Some(icc) = meta.icc {
322 push(fourcc::ICCP, icc)?;
323 }
324 push(fourcc::ANIM, &build_anim_payload(opts))?;
325
326 // Track the canvas state the decoder will see right *before* this
327 // iteration's frame is drawn. Initialised to the ANIM bg colour to
328 // mirror the decoder's §2.7.1.1 "canvas is cleared at the start" rule.
329 let mut prev_canvas = build_initial_canvas(canvas_width, canvas_height, opts.background_rgba);
330 let bg_rgba = opts.background_rgba;
331 // Per the decoder: before drawing each frame after the first, the
332 // *previous* frame's dispose method is applied to *its* rect.
333 let mut prev_disposal: Option<(u32, u32, u32, u32, DisposalMethod)> = None;
334
335 for (idx, f) in frames.iter().enumerate() {
336 // Apply previous frame's dispose to the predicted-canvas tracker.
337 if let Some((px, py, pw, ph, DisposalMethod::Background)) = prev_disposal {
338 fill_canvas_rect_in_place(&mut prev_canvas, canvas_width, px, py, pw, ph, bg_rgba);
339 }
340 let anmf_payload = build_anmf_payload_with_prev(f, canvas_width, &prev_canvas, idx == 0)?;
341 push(fourcc::ANMF, &anmf_payload)?;
342 // Update the canvas tracker with this frame's drawn pixels
343 // (matching the decoder's blend method).
344 composite_frame_onto_canvas(&mut prev_canvas, canvas_width, f);
345 // Remember this frame's rect + dispose for the next iteration.
346 prev_disposal = Some((f.x, f.y, f.width, f.height, f.dispose));
347 }
348
349 if let Some(exif) = meta.exif {
350 push(fourcc::EXIF, exif)?;
351 }
352 if let Some(xmp) = meta.xmp {
353 push(fourcc::XMP, xmp)?;
354 }
355
356 // §2.4 file framing: "RIFF" | File Size (= body + 4 for "WEBP") | "WEBP".
357 let file_size = (body.len() as u64) + 4;
358 if file_size > u64::from(u32::MAX) {
359 return Err(WebpError::InvalidData);
360 }
361 let mut out = Vec::with_capacity(12 + body.len());
362 out.extend_from_slice(&fourcc::RIFF);
363 out.extend_from_slice(&(file_size as u32).to_le_bytes());
364 out.extend_from_slice(&fourcc::WEBP);
365 out.extend_from_slice(&body);
366 Ok(out)
367}
368
369/// Build a fresh canvas filled with `bg` per §2.7.1.1 — what the decoder
370/// initialises the canvas to before drawing the first frame.
371fn build_initial_canvas(width: u32, height: u32, bg: [u8; 4]) -> Vec<u8> {
372 let pixels = (width as usize) * (height as usize);
373 let mut canvas = Vec::with_capacity(pixels * 4);
374 for _ in 0..pixels {
375 canvas.extend_from_slice(&bg);
376 }
377 canvas
378}
379
380/// Fill the sub-rectangle `(x, y, w, h)` of `canvas` with `rgba` — mirrors
381/// the decoder's `fill_canvas_rect` when applying a previous frame's
382/// `DisposalMethod::Background`.
383fn fill_canvas_rect_in_place(
384 canvas: &mut [u8],
385 canvas_w: u32,
386 x: u32,
387 y: u32,
388 w: u32,
389 h: u32,
390 rgba: [u8; 4],
391) {
392 let cw_bytes = (canvas_w as usize) * 4;
393 for row in 0..(h as usize) {
394 let off = ((y as usize) + row) * cw_bytes + (x as usize) * 4;
395 for col in 0..(w as usize) {
396 canvas[off + col * 4] = rgba[0];
397 canvas[off + col * 4 + 1] = rgba[1];
398 canvas[off + col * 4 + 2] = rgba[2];
399 canvas[off + col * 4 + 3] = rgba[3];
400 }
401 }
402}
403
404/// Composite a frame onto `canvas` exactly the way the decoder will, so
405/// the next iteration's dirty-rect diff is computed against the same
406/// reference state. Honours the frame's `blend` method — `Overwrite`
407/// (the default and the one Auto/Delta forces) copies the rect bytes
408/// verbatim; `AlphaBlend` runs the §2.7.1.1 8-bit alpha-blending formula.
409fn composite_frame_onto_canvas(canvas: &mut [u8], canvas_w: u32, f: &AnimFrame) {
410 let cw = canvas_w as usize;
411 let cw_bytes = cw * 4;
412 let fw = f.width as usize;
413 let fh = f.height as usize;
414 let fx = f.x as usize;
415 let fy = f.y as usize;
416 match f.blend {
417 BlendingMethod::Overwrite => {
418 let src_stride = fw * 4;
419 for row in 0..fh {
420 let src_off = row * src_stride;
421 let dst_off = (fy + row) * cw_bytes + fx * 4;
422 canvas[dst_off..dst_off + src_stride]
423 .copy_from_slice(&f.pixels[src_off..src_off + src_stride]);
424 }
425 }
426 BlendingMethod::AlphaBlend => {
427 for row in 0..fh {
428 for col in 0..fw {
429 let src_off = (row * fw + col) * 4;
430 let dst_off = (fy + row) * cw_bytes + (fx + col) * 4;
431 let sa = f.pixels[src_off + 3] as u32;
432 if sa == 255 {
433 canvas[dst_off..dst_off + 4]
434 .copy_from_slice(&f.pixels[src_off..src_off + 4]);
435 } else if sa == 0 {
436 // src fully transparent → leave dst.
437 } else {
438 let sr = f.pixels[src_off] as u32;
439 let sg = f.pixels[src_off + 1] as u32;
440 let sb = f.pixels[src_off + 2] as u32;
441 let dr = canvas[dst_off] as u32;
442 let dg = canvas[dst_off + 1] as u32;
443 let db = canvas[dst_off + 2] as u32;
444 let da = canvas[dst_off + 3] as u32;
445 let dst_factor = (da * (255 - sa) + 127) / 255;
446 let out_a = sa + dst_factor;
447 let out_r = (sr * sa + dr * dst_factor + out_a / 2)
448 .checked_div(out_a)
449 .unwrap_or(0);
450 let out_g = (sg * sa + dg * dst_factor + out_a / 2)
451 .checked_div(out_a)
452 .unwrap_or(0);
453 let out_b = (sb * sa + db * dst_factor + out_a / 2)
454 .checked_div(out_a)
455 .unwrap_or(0);
456 canvas[dst_off] = out_r.min(255) as u8;
457 canvas[dst_off + 1] = out_g.min(255) as u8;
458 canvas[dst_off + 2] = out_b.min(255) as u8;
459 canvas[dst_off + 3] = out_a.min(255) as u8;
460 }
461 }
462 }
463 }
464 }
465}
466
467/// Dirty-rect (bounding box of changed pixels) between the post-composite
468/// `drawn` canvas and the `prev` canvas, restricted to `f`'s declared
469/// rect (the only region a frame may touch) and expressed in canvas
470/// coordinates. Diffing the *drawn* state — rather than the raw source
471/// pixels — makes the rect correct for `AlphaBlend` frames, whose drawn
472/// pixels differ from their source pixels. Returns `None` when no canvas
473/// pixel changes — Delta/Auto then emit a degenerate 2×2 same-pixels rect
474/// (the smallest representable ANMF) to preserve duration timing.
475fn dirty_rect_canvas_coords(
476 f: &AnimFrame,
477 canvas_w: u32,
478 prev: &[u8],
479 drawn: &[u8],
480) -> Option<DirtyRect> {
481 let cw = canvas_w as usize;
482 let cw_bytes = cw * 4;
483 let fw = f.width as usize;
484 let fh = f.height as usize;
485 let fx = f.x as usize;
486 let fy = f.y as usize;
487
488 let mut min_x = usize::MAX;
489 let mut min_y = usize::MAX;
490 let mut max_x = 0usize;
491 let mut max_y = 0usize;
492 let mut any_diff = false;
493
494 for row in 0..fh {
495 let dst_row_off = (fy + row) * cw_bytes + fx * 4;
496 for col in 0..fw {
497 let s = &drawn[dst_row_off + col * 4..dst_row_off + col * 4 + 4];
498 let d = &prev[dst_row_off + col * 4..dst_row_off + col * 4 + 4];
499 if s != d {
500 any_diff = true;
501 let cx = fx + col;
502 let cy = fy + row;
503 if cx < min_x {
504 min_x = cx;
505 }
506 if cy < min_y {
507 min_y = cy;
508 }
509 if cx > max_x {
510 max_x = cx;
511 }
512 if cy > max_y {
513 max_y = cy;
514 }
515 }
516 }
517 }
518
519 if !any_diff {
520 return None;
521 }
522
523 // §2.7.1.1 stores Frame X / Frame Y as coord/2, so the dirty rect's
524 // top-left must be aligned to even coordinates. Round down to the
525 // nearest even.
526 let aligned_min_x = min_x & !1;
527 let aligned_min_y = min_y & !1;
528
529 Some(DirtyRect {
530 x: aligned_min_x as u32,
531 y: aligned_min_y as u32,
532 w: ((max_x + 1) - aligned_min_x) as u32,
533 h: ((max_y + 1) - aligned_min_y) as u32,
534 })
535}
536
537/// Extract a sub-rectangle of a full-canvas RGBA buffer covering `rect`
538/// (in canvas coordinates). Returns the flat RGBA buffer the VP8L encoder
539/// will consume. `rect` must lie fully inside the canvas.
540fn extract_subrect_from_canvas(canvas: &[u8], canvas_w: u32, rect: DirtyRect) -> Vec<u8> {
541 let cw_bytes = (canvas_w as usize) * 4;
542 let rx = rect.x as usize;
543 let ry = rect.y as usize;
544 let rw = rect.w as usize;
545 let rh = rect.h as usize;
546 let mut out = Vec::with_capacity(rw * rh * 4);
547 for row in 0..rh {
548 let src_off = (ry + row) * cw_bytes + rx * 4;
549 out.extend_from_slice(&canvas[src_off..src_off + rw * 4]);
550 }
551 out
552}
553
554#[derive(Debug, Clone, Copy)]
555struct DirtyRect {
556 x: u32,
557 y: u32,
558 w: u32,
559 h: u32,
560}
561
562/// Build the 6-byte §2.7.1.1 Figure 8 `ANIM` payload: BGRA background colour
563/// (the `[R,G,B,A]` option re-ordered to on-disk `[B,G,R,A]`) + LE u16 loop
564/// count.
565fn build_anim_payload(opts: &AnimEncoderOptions<'_>) -> Vec<u8> {
566 let [r, g, b, a] = opts.background_rgba;
567 let mut p = Vec::with_capacity(ANIM_PAYLOAD_LEN);
568 // §2.7.1.1: on-disk byte order is [Blue, Green, Red, Alpha].
569 p.push(b);
570 p.push(g);
571 p.push(r);
572 p.push(a);
573 p.extend_from_slice(&opts.loop_count.to_le_bytes());
574 p
575}
576
577/// Build a single `ANMF` chunk payload, taking the previous-canvas state
578/// into account for [`AnimFrameMode::Auto`] / [`AnimFrameMode::Delta`].
579///
580/// For [`AnimFrameMode::Lossless`], the frame is always encoded as a full
581/// keyframe at its declared `(x, y, width, height)` — the caller's
582/// `blend`/`dispose` flags are honoured verbatim.
583///
584/// For [`AnimFrameMode::Delta`], the dirty rect — the bounding box of
585/// canvas pixels the frame actually *changes*, i.e. the diff between the
586/// post-composite canvas (`prev` with the frame drawn per its `blend`
587/// method) and `prev` itself — is encoded as the ANMF sub-frame carrying
588/// the **post-composite** pixels with `B = 1` / `D = 0`. Diffing and
589/// emitting the drawn state (rather than the raw source pixels) is what
590/// keeps an `AlphaBlend` frame bit-exact: the decoder overwrites the rect
591/// with the already-blended pixels and lands on the same canvas the
592/// caller's blend would have produced.
593///
594/// Two cases fall back to a full keyframe with the caller's flags
595/// honoured verbatim (same emission as `Lossless`):
596///
597/// * `is_first_frame` — no `prev` to diff against;
598/// * `dispose == Background` — a dirty-rect sub-frame cannot carry the
599/// §2.7.1.1 "clear the frame's rect to the background colour" rule for
600/// the *caller's* full rect (the emitted rect is smaller), so the
601/// dispose flag must travel on a full-rect ANMF to keep the decoder's
602/// canvas in lockstep with the reference state the next frame is
603/// diffed against.
604///
605/// For [`AnimFrameMode::Auto`], both candidates are encoded and the
606/// smaller VP8L bitstream wins (subject to the same two fallbacks).
607fn build_anmf_payload_with_prev(
608 f: &AnimFrame,
609 canvas_w: u32,
610 prev: &[u8],
611 is_first_frame: bool,
612) -> Result<Vec<u8>, WebpError> {
613 match f.mode {
614 AnimFrameMode::Lossless => emit_full_anmf(f, f.x, f.y, f.width, f.height, &f.pixels),
615 AnimFrameMode::Delta => {
616 if is_first_frame || f.dispose == DisposalMethod::Background {
617 emit_full_anmf(f, f.x, f.y, f.width, f.height, &f.pixels)
618 } else {
619 let drawn = drawn_canvas(prev, canvas_w, f);
620 let rect =
621 dirty_rect_canvas_coords(f, canvas_w, prev, &drawn).unwrap_or(DirtyRect {
622 // Identical-to-previous: emit a 2×2 overwrite of
623 // the (unchanged) drawn-canvas pixels at the
624 // frame's corner — duration is preserved and
625 // re-writing the same pixels is a no-op for the
626 // decoder's compositor.
627 x: f.x,
628 y: f.y,
629 w: 2.min(f.width),
630 h: 2.min(f.height),
631 });
632 let sub_rgba = extract_subrect_from_canvas(&drawn, canvas_w, rect);
633 emit_dirty_anmf(f, rect, &sub_rgba)
634 }
635 }
636 AnimFrameMode::Auto => {
637 // Always evaluate the full-frame candidate (and use it for the
638 // first frame / Background-dispose fallbacks regardless).
639 let full = emit_full_anmf(f, f.x, f.y, f.width, f.height, &f.pixels)?;
640 if is_first_frame || f.dispose == DisposalMethod::Background {
641 return Ok(full);
642 }
643 let drawn = drawn_canvas(prev, canvas_w, f);
644 let Some(rect) = dirty_rect_canvas_coords(f, canvas_w, prev, &drawn) else {
645 // Frame leaves the canvas untouched — a 2×2 same-pixels
646 // delta is smaller than any non-trivial full frame.
647 let degen_rect = DirtyRect {
648 x: f.x,
649 y: f.y,
650 w: 2.min(f.width),
651 h: 2.min(f.height),
652 };
653 let sub_rgba = extract_subrect_from_canvas(&drawn, canvas_w, degen_rect);
654 let delta = emit_dirty_anmf(f, degen_rect, &sub_rgba)?;
655 return Ok(if delta.len() < full.len() {
656 delta
657 } else {
658 full
659 });
660 };
661 // If the dirty rect covers the whole declared frame rect and
662 // the frame is plain-overwrite, the full candidate carries the
663 // identical pixels — no win from a sub-frame.
664 if f.blend == BlendingMethod::Overwrite
665 && rect.w == f.width
666 && rect.h == f.height
667 && rect.x == f.x
668 && rect.y == f.y
669 {
670 return Ok(full);
671 }
672 let sub_rgba = extract_subrect_from_canvas(&drawn, canvas_w, rect);
673 let delta = emit_dirty_anmf(f, rect, &sub_rgba)?;
674 Ok(if delta.len() < full.len() {
675 delta
676 } else {
677 full
678 })
679 }
680 }
681}
682
683/// Clone `prev` and composite `f` onto it per its `blend` method — the
684/// §2.7.1.1 canvas state the decoder must display after this frame.
685fn drawn_canvas(prev: &[u8], canvas_w: u32, f: &AnimFrame) -> Vec<u8> {
686 let mut drawn = prev.to_vec();
687 composite_frame_onto_canvas(&mut drawn, canvas_w, f);
688 drawn
689}
690
691/// Encode `pixels` (`w*h*4` flat RGBA) as a §2.6 `VP8L` chunk wrapped in
692/// the §2.7.1.1 Figure 9 16-byte ANMF header at `(x, y, w, h)` with the
693/// caller's blend/dispose/duration. Used by both the full-keyframe and
694/// dirty-rect emission paths.
695fn emit_full_anmf(
696 f: &AnimFrame,
697 x: u32,
698 y: u32,
699 w: u32,
700 h: u32,
701 pixels: &[u8],
702) -> Result<Vec<u8>, WebpError> {
703 let argb = rgba_to_argb(pixels);
704 let has_alpha = pixels.chunks_exact(4).any(|px| px[3] != 0xff);
705 let bitstream = vp8l_encode::encode_vp8l_argb_with(&argb, w, h, has_alpha)
706 .map_err(Error::from)
707 .map_err(WebpError::from)?;
708 let frame_data = build::build_chunk(fourcc::VP8L, &bitstream).map_err(to_w)?;
709 Ok(build_anmf_header_then_data(
710 x,
711 y,
712 w,
713 h,
714 f.duration,
715 f.blend,
716 f.dispose,
717 &frame_data,
718 ))
719}
720
721/// Emit an ANMF carrying only the dirty `rect` sub-frame, forced to
722/// `B = 1` (overwrite) and `D = 0` (no dispose) so the decoder's
723/// compositor reconstructs the caller's intended canvas bit-exactly:
724/// `sub_rgba` is the **post-composite** drawn state, so a plain overwrite
725/// of the rect lands exactly on the canvas the caller's `blend` produces.
726/// Only reached when the caller's `dispose` is `None` (a `Background`
727/// dispose travels on the full-keyframe fallback instead — see
728/// [`build_anmf_payload_with_prev`]), so `D = 0` *is* the caller's flag.
729fn emit_dirty_anmf(f: &AnimFrame, rect: DirtyRect, sub_rgba: &[u8]) -> Result<Vec<u8>, WebpError> {
730 let argb = rgba_to_argb(sub_rgba);
731 let has_alpha = sub_rgba.chunks_exact(4).any(|px| px[3] != 0xff);
732 let bitstream = vp8l_encode::encode_vp8l_argb_with(&argb, rect.w, rect.h, has_alpha)
733 .map_err(Error::from)
734 .map_err(WebpError::from)?;
735 let frame_data = build::build_chunk(fourcc::VP8L, &bitstream).map_err(to_w)?;
736 Ok(build_anmf_header_then_data(
737 rect.x,
738 rect.y,
739 rect.w,
740 rect.h,
741 f.duration,
742 BlendingMethod::Overwrite,
743 DisposalMethod::None,
744 &frame_data,
745 ))
746}
747
748/// Splice the 16-byte §2.7.1.1 Figure 9 header in front of the per-frame
749/// Frame Data sub-RIFF and return the complete ANMF chunk payload.
750#[allow(clippy::too_many_arguments)]
751fn build_anmf_header_then_data(
752 x: u32,
753 y: u32,
754 w: u32,
755 h: u32,
756 duration: u32,
757 blend: BlendingMethod,
758 dispose: DisposalMethod,
759 frame_data: &[u8],
760) -> Vec<u8> {
761 let mut payload = Vec::with_capacity(ANMF_HEADER_LEN + frame_data.len());
762 push_u24_le(&mut payload, x / 2);
763 push_u24_le(&mut payload, y / 2);
764 push_u24_le(&mut payload, w - 1);
765 push_u24_le(&mut payload, h - 1);
766 push_u24_le(&mut payload, duration & 0x00FF_FFFF);
767 let b_bit = match blend {
768 BlendingMethod::AlphaBlend => 0,
769 BlendingMethod::Overwrite => 1,
770 };
771 let d_bit = match dispose {
772 DisposalMethod::None => 0,
773 DisposalMethod::Background => 1,
774 };
775 payload.push((b_bit << 1) | d_bit);
776 payload.extend_from_slice(frame_data);
777 payload
778}
779
780/// Push the low 24 bits of `v` as three little-endian bytes.
781fn push_u24_le(out: &mut Vec<u8>, v: u32) {
782 out.push((v & 0xFF) as u8);
783 out.push(((v >> 8) & 0xFF) as u8);
784 out.push(((v >> 16) & 0xFF) as u8);
785}
786
787/// Repack interleaved `[R, G, B, A]` bytes into packed ARGB
788/// (`(a<<24)|(r<<16)|(g<<8)|b`) — the layout the VP8L encoder consumes.
789fn rgba_to_argb(rgba: &[u8]) -> Vec<u32> {
790 rgba.chunks_exact(4)
791 .map(|px| {
792 let (r, g, b, a) = (px[0] as u32, px[1] as u32, px[2] as u32, px[3] as u32);
793 (a << 24) | (r << 16) | (g << 8) | b
794 })
795 .collect()
796}
797
798/// Collapse a [`crate::build::BuildError`] into the published coarse
799/// [`WebpError::InvalidData`].
800fn to_w(_e: build::BuildError) -> WebpError {
801 WebpError::InvalidData
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 fn solid_rgba(w: u32, h: u32, color: [u8; 4]) -> Vec<u8> {
809 let mut v = Vec::with_capacity((w * h * 4) as usize);
810 for _ in 0..(w * h) {
811 v.extend_from_slice(&color);
812 }
813 v
814 }
815
816 #[test]
817 fn empty_frames_is_invalid_data() {
818 assert_eq!(build_animated_webp(&[]), Err(WebpError::InvalidData));
819 }
820
821 #[test]
822 fn auto_and_delta_modes_emit_valid_files_round_127() {
823 // Round 127: Auto + Delta are wired up against the §2.7.1.1
824 // overwrite-no-dispose path. Selecting them no longer returns
825 // Unsupported; the encoded file is structurally valid and the
826 // container walker accepts it.
827 for mode in [AnimFrameMode::Auto, AnimFrameMode::Delta] {
828 let mut f = AnimFrame::new(2, 2, solid_rgba(2, 2, [1, 2, 3, 255]), 100);
829 f.mode = mode;
830 let file = build_animated_webp(&[f]).unwrap_or_else(|e| {
831 panic!("mode {mode:?} must build a valid file in round 127, got {e:?}")
832 });
833 assert_eq!(&file[0..4], b"RIFF");
834 assert_eq!(&file[8..12], b"WEBP");
835 let c = crate::container::parse(&file).expect("parseable container");
836 assert!(c.first_chunk_with_fourcc(fourcc::ANMF).is_some());
837 }
838 }
839
840 #[test]
841 fn dirty_rect_shrinks_anmf_payload_for_localised_change() {
842 // Build a 16×16 canvas-aligned frame pair where only a 4×4 block
843 // in the centre differs. A Delta-mode emit must produce a
844 // strictly smaller ANMF chunk than a Lossless full-frame emit.
845 let w = 16u32;
846 let h = 16u32;
847 let mut f0 = AnimFrame::new(w, h, solid_rgba(w, h, [200, 100, 50, 255]), 80);
848 f0.mode = AnimFrameMode::Lossless;
849 let mut f1_pixels = solid_rgba(w, h, [200, 100, 50, 255]);
850 for row in 6..10 {
851 for col in 6..10 {
852 let off = (row * w as usize + col) * 4;
853 f1_pixels[off] = 0;
854 f1_pixels[off + 1] = 0;
855 f1_pixels[off + 2] = 0;
856 f1_pixels[off + 3] = 255;
857 }
858 }
859 let mut f1_lossless = AnimFrame::new(w, h, f1_pixels.clone(), 80);
860 f1_lossless.mode = AnimFrameMode::Lossless;
861 let mut f1_delta = AnimFrame::new(w, h, f1_pixels.clone(), 80);
862 f1_delta.mode = AnimFrameMode::Delta;
863
864 let file_lossless = build_animated_webp(&[f0.clone(), f1_lossless]).unwrap();
865 let file_delta = build_animated_webp(&[f0, f1_delta]).unwrap();
866
867 assert!(
868 file_delta.len() < file_lossless.len(),
869 "delta-mode file ({} bytes) must beat lossless ({} bytes) on a 4×4 change",
870 file_delta.len(),
871 file_lossless.len(),
872 );
873 }
874
875 #[test]
876 fn auto_mode_picks_dirty_rect_on_localised_change() {
877 // Same 4×4-change setup as above; Auto must pick the smaller
878 // (delta) candidate.
879 let w = 16u32;
880 let h = 16u32;
881 let mut f0 = AnimFrame::new(w, h, solid_rgba(w, h, [200, 100, 50, 255]), 80);
882 f0.mode = AnimFrameMode::Lossless;
883 let mut f1_pixels = solid_rgba(w, h, [200, 100, 50, 255]);
884 for row in 6..10 {
885 for col in 6..10 {
886 let off = (row * w as usize + col) * 4;
887 f1_pixels[off] = 0;
888 }
889 }
890 let mut f1_auto = AnimFrame::new(w, h, f1_pixels.clone(), 80);
891 f1_auto.mode = AnimFrameMode::Auto;
892 let mut f1_lossless = AnimFrame::new(w, h, f1_pixels, 80);
893 f1_lossless.mode = AnimFrameMode::Lossless;
894
895 let file_auto = build_animated_webp(&[f0.clone(), f1_auto]).unwrap();
896 let file_lossless = build_animated_webp(&[f0, f1_lossless]).unwrap();
897
898 assert!(
899 file_auto.len() <= file_lossless.len(),
900 "auto-mode never regresses vs lossless ({} vs {} bytes)",
901 file_auto.len(),
902 file_lossless.len(),
903 );
904 }
905
906 #[test]
907 fn dirty_rect_canvas_coords_covers_only_the_changed_pixels() {
908 let w = 8u32;
909 let h = 8u32;
910 let prev = solid_rgba(w, h, [0, 0, 0, 0]);
911 let mut pixels = solid_rgba(w, h, [0, 0, 0, 0]);
912 pixels[(3 * 8 + 5) * 4] = 0xff;
913 pixels[(4 * 8 + 5) * 4 + 1] = 0xee;
914 let f = AnimFrame::new(w, h, pixels, 0);
915 let drawn = drawn_canvas(&prev, w, &f);
916 let rect = dirty_rect_canvas_coords(&f, w, &prev, &drawn).expect("change exists");
917 // Single-pixel changes at (5,3) and (5,4); after even-alignment of
918 // the top-left, the rect spans x ∈ [4, 5], y ∈ [2, 4].
919 assert_eq!(rect.x % 2, 0);
920 assert_eq!(rect.y % 2, 0);
921 assert!(rect.x <= 5 && rect.x + rect.w > 5);
922 assert!(rect.y <= 3 && rect.y + rect.h > 4);
923 }
924
925 #[test]
926 fn dirty_rect_is_none_on_identical_frames() {
927 let w = 4u32;
928 let h = 4u32;
929 let pixels = solid_rgba(w, h, [1, 2, 3, 255]);
930 let prev = pixels.clone();
931 let f = AnimFrame::new(w, h, pixels, 0);
932 let drawn = drawn_canvas(&prev, w, &f);
933 assert!(dirty_rect_canvas_coords(&f, w, &prev, &drawn).is_none());
934 }
935
936 #[test]
937 fn pixel_length_mismatch_is_invalid_data() {
938 let mut f = AnimFrame::new(2, 2, solid_rgba(2, 2, [0, 0, 0, 255]), 0);
939 f.pixels.truncate(4);
940 assert_eq!(build_animated_webp(&[f]), Err(WebpError::InvalidData));
941 }
942
943 #[test]
944 fn odd_offset_is_invalid_data() {
945 let mut f = AnimFrame::new(2, 2, solid_rgba(2, 2, [0, 0, 0, 255]), 0);
946 f.x = 1;
947 assert_eq!(build_animated_webp(&[f]), Err(WebpError::InvalidData));
948 }
949
950 #[test]
951 fn output_begins_with_riff_webp_and_is_parseable() {
952 let f = AnimFrame::new(4, 4, solid_rgba(4, 4, [10, 20, 30, 255]), 100);
953 let file = build_animated_webp(&[f]).expect("build animated webp");
954 assert_eq!(&file[0..4], b"RIFF");
955 assert_eq!(&file[8..12], b"WEBP");
956 // The container walker must accept it.
957 let c = crate::container::parse(&file).expect("parseable container");
958 // VP8X then ANIM then ANMF must all be present.
959 assert!(c.first_chunk_with_fourcc(fourcc::VP8X).is_some());
960 assert!(c.first_chunk_with_fourcc(fourcc::ANIM).is_some());
961 assert!(c.first_chunk_with_fourcc(fourcc::ANMF).is_some());
962 }
963
964 #[test]
965 fn delta_config_builders_chain() {
966 let cfg = DeltaConfig::default()
967 .max_components_override(3)
968 .auto_inner_threshold_bytes(Some(512))
969 .msssim_downsample_kernel(DownsampleKernel::Gaussian);
970 assert_eq!(cfg.max_components, 3);
971 assert_eq!(cfg.auto_inner_threshold_bytes, Some(512));
972 assert_eq!(cfg.msssim_downsample_kernel, DownsampleKernel::Gaussian);
973 }
974}