Skip to main content

oximedia_codec/webp/
animation.rs

1//! WebP animation encoding and decoding.
2//!
3//! Implements the WebP animation container format (Extended WebP with ANIM/ANMF chunks).
4//!
5//! # Container structure
6//!
7//! ```text
8//! RIFF <file_size>
9//!   WEBP
10//!   VP8X <10 bytes>   (has_animation=1, canvas_width-1, canvas_height-1)
11//!   ANIM <6 bytes>    (background_color BGRA u32 LE, loop_count u16 LE)
12//!   ANMF <frame_data> (per-frame: x/2, y/2, w-1, h-1, duration 24-bit, flags, VP8L bitstream)
13//!   ...
14//! ```
15//!
16//! Each ANMF chunk embeds a VP8L lossless bitstream for the frame pixels.
17
18use crate::error::{CodecError, CodecResult};
19use crate::webp::vp8l_encoder::Vp8lEncoder;
20
21// ── Constants ──────────────────────────────────────────────────────────────────
22
23const RIFF_MAGIC: &[u8; 4] = b"RIFF";
24const WEBP_MAGIC: &[u8; 4] = b"WEBP";
25
26const FOURCC_VP8X: [u8; 4] = *b"VP8X";
27const FOURCC_ANIM: [u8; 4] = *b"ANIM";
28const FOURCC_ANMF: [u8; 4] = *b"ANMF";
29const FOURCC_VP8L: [u8; 4] = *b"VP8L";
30
31/// VP8X flag bit for animation.
32const VP8X_FLAG_ANIMATION: u8 = 1 << 1;
33/// VP8X flag bit for alpha.
34const VP8X_FLAG_ALPHA: u8 = 1 << 4;
35
36/// Minimum bytes needed for RIFF header (RIFF tag + file size + WEBP).
37const RIFF_HEADER_SIZE: usize = 12;
38/// Size of each chunk header (FourCC + u32 size).
39const CHUNK_HEADER_SIZE: usize = 8;
40/// ANMF chunk header payload size before the bitstream data.
41/// X/2 (3 bytes) + Y/2 (3 bytes) + (W-1) (3 bytes) + (H-1) (3 bytes) + duration (3 bytes) + flags (1 byte) = 16 bytes.
42const ANMF_HEADER_SIZE: usize = 16;
43/// ANIM chunk payload size: background_color (4) + loop_count (2).
44const ANIM_PAYLOAD_SIZE: usize = 6;
45/// VP8X chunk payload size.
46const VP8X_PAYLOAD_SIZE: usize = 10;
47
48// ── Public types ───────────────────────────────────────────────────────────────
49
50/// Configuration for an animated WebP sequence.
51#[derive(Debug, Clone)]
52pub struct WebpAnimConfig {
53    /// Number of times to loop the animation. 0 = infinite.
54    pub loop_count: u16,
55    /// Canvas background color as 0xAARRGGBB (stored as BGRA in the file).
56    pub background_color: u32,
57}
58
59impl Default for WebpAnimConfig {
60    fn default() -> Self {
61        Self {
62            loop_count: 0,
63            background_color: 0xFF000000, // opaque black
64        }
65    }
66}
67
68/// A single frame within an animated WebP.
69#[derive(Debug, Clone)]
70pub struct WebpAnimFrame {
71    /// Raw RGBA pixel data (4 bytes per pixel, row-major).
72    pub pixels: Vec<u8>,
73    /// Frame width in pixels.
74    pub width: u32,
75    /// Frame height in pixels.
76    pub height: u32,
77    /// Frame display timestamp in milliseconds.
78    pub timestamp_ms: u32,
79    /// X offset on the canvas (must be divisible by 2).
80    pub x_offset: u32,
81    /// Y offset on the canvas (must be divisible by 2).
82    pub y_offset: u32,
83    /// Whether to alpha-blend this frame over the previous one.
84    pub blend: bool,
85    /// Whether to dispose (clear to background) the frame area after display.
86    pub dispose: bool,
87}
88
89impl WebpAnimFrame {
90    /// Validate frame constraints required by the WebP spec.
91    fn validate(&self) -> CodecResult<()> {
92        if self.x_offset % 2 != 0 {
93            return Err(CodecError::InvalidParameter(format!(
94                "x_offset {} must be divisible by 2",
95                self.x_offset
96            )));
97        }
98        if self.y_offset % 2 != 0 {
99            return Err(CodecError::InvalidParameter(format!(
100                "y_offset {} must be divisible by 2",
101                self.y_offset
102            )));
103        }
104        if self.width == 0 || self.height == 0 {
105            return Err(CodecError::InvalidParameter(
106                "Frame dimensions must be non-zero".into(),
107            ));
108        }
109        let expected = (self.width as usize)
110            .checked_mul(self.height as usize)
111            .and_then(|px| px.checked_mul(4))
112            .ok_or_else(|| {
113                CodecError::InvalidParameter("Frame pixel buffer size overflow".into())
114            })?;
115        if self.pixels.len() != expected {
116            return Err(CodecError::InvalidParameter(format!(
117                "pixels length {} does not match {}×{}×4 = {}",
118                self.pixels.len(),
119                self.width,
120                self.height,
121                expected
122            )));
123        }
124        Ok(())
125    }
126}
127
128// ── Encoder ────────────────────────────────────────────────────────────────────
129
130/// Encoder for animated WebP files.
131pub struct WebpAnimEncoder;
132
133impl WebpAnimEncoder {
134    /// Encode a sequence of frames into an animated WebP byte stream.
135    ///
136    /// The canvas dimensions are derived from the maximum extent of all frames
137    /// (x_offset + width, y_offset + height). All frames must have valid pixel
138    /// data matching their declared width/height.
139    pub fn encode(frames: &[WebpAnimFrame], config: &WebpAnimConfig) -> CodecResult<Vec<u8>> {
140        if frames.is_empty() {
141            return Err(CodecError::InvalidParameter(
142                "Animation must contain at least one frame".into(),
143            ));
144        }
145
146        // Validate all frames up front.
147        for (i, frame) in frames.iter().enumerate() {
148            frame
149                .validate()
150                .map_err(|e| CodecError::InvalidParameter(format!("Frame {i}: {e}")))?;
151        }
152
153        // Compute canvas dimensions.
154        let canvas_width = frames
155            .iter()
156            .map(|f| f.x_offset + f.width)
157            .max()
158            .unwrap_or(1);
159        let canvas_height = frames
160            .iter()
161            .map(|f| f.y_offset + f.height)
162            .max()
163            .unwrap_or(1);
164
165        // Detect whether any frame has a non-trivial alpha channel.
166        let has_alpha = frames.iter().any(|f| has_non_opaque_alpha(&f.pixels));
167
168        // Build VP8X chunk payload.
169        let vp8x_payload = encode_vp8x(canvas_width, canvas_height, true, has_alpha);
170
171        // Build ANIM chunk payload.
172        let anim_payload = encode_anim_chunk(config);
173
174        // Build ANMF chunks for each frame.
175        let anmf_chunks: Vec<Vec<u8>> = frames
176            .iter()
177            .enumerate()
178            .map(|(i, frame)| encode_anmf_chunk(frame, i))
179            .collect::<CodecResult<_>>()?;
180
181        // Compute total RIFF body size.
182        // RIFF body = "WEBP" (4) + VP8X chunk + ANIM chunk + all ANMF chunks
183        let mut body_size: usize = 4; // "WEBP"
184        body_size += chunk_wire_size(VP8X_PAYLOAD_SIZE);
185        body_size += chunk_wire_size(ANIM_PAYLOAD_SIZE);
186        for anmf in &anmf_chunks {
187            body_size += chunk_wire_size(anmf.len());
188        }
189
190        let mut out = Vec::with_capacity(RIFF_HEADER_SIZE + body_size);
191
192        // RIFF header.
193        out.extend_from_slice(RIFF_MAGIC);
194        write_u32_le(&mut out, body_size as u32);
195        out.extend_from_slice(WEBP_MAGIC);
196
197        // VP8X chunk.
198        write_chunk(&mut out, &FOURCC_VP8X, &vp8x_payload);
199
200        // ANIM chunk.
201        write_chunk(&mut out, &FOURCC_ANIM, &anim_payload);
202
203        // ANMF chunks.
204        for anmf in &anmf_chunks {
205            write_chunk(&mut out, &FOURCC_ANMF, anmf);
206        }
207
208        Ok(out)
209    }
210}
211
212// ── Decoder ────────────────────────────────────────────────────────────────────
213
214/// Decoder for animated WebP files.
215pub struct WebpAnimDecoder;
216
217impl WebpAnimDecoder {
218    /// Decode an animated WebP byte stream into frames and configuration.
219    ///
220    /// Returns the decoded frames (with RGBA pixel data) and animation config.
221    pub fn decode(data: &[u8]) -> CodecResult<(Vec<WebpAnimFrame>, WebpAnimConfig)> {
222        validate_riff_header(data)?;
223
224        let chunks = parse_chunks(&data[RIFF_HEADER_SIZE..], data.len() - RIFF_HEADER_SIZE)?;
225
226        // Find the ANIM chunk.
227        let anim_payload = chunks
228            .iter()
229            .find(|(cc, _)| cc == &FOURCC_ANIM)
230            .map(|(_, d)| d.as_slice())
231            .ok_or_else(|| CodecError::InvalidBitstream("Missing ANIM chunk".into()))?;
232
233        let config = decode_anim_chunk(anim_payload)?;
234
235        // Decode each ANMF chunk.
236        let frames: Vec<WebpAnimFrame> = chunks
237            .iter()
238            .filter(|(cc, _)| cc == &FOURCC_ANMF)
239            .map(|(_, d)| decode_anmf_chunk(d))
240            .collect::<CodecResult<_>>()?;
241
242        if frames.is_empty() {
243            return Err(CodecError::InvalidBitstream(
244                "Animated WebP contains no ANMF frames".into(),
245            ));
246        }
247
248        Ok((frames, config))
249    }
250
251    /// Return the number of animation frames without fully decoding all pixel data.
252    pub fn frame_count(data: &[u8]) -> CodecResult<u32> {
253        if !Self::is_webp_anim(data) {
254            return Err(CodecError::InvalidBitstream(
255                "Data is not an animated WebP".into(),
256            ));
257        }
258        let chunks = parse_chunks(&data[RIFF_HEADER_SIZE..], data.len() - RIFF_HEADER_SIZE)?;
259        let count = chunks.iter().filter(|(cc, _)| cc == &FOURCC_ANMF).count();
260        Ok(count as u32)
261    }
262
263    /// Return true if the byte slice is a valid animated WebP (RIFF+WEBP with ANIM chunk).
264    pub fn is_webp_anim(data: &[u8]) -> bool {
265        if data.len() < RIFF_HEADER_SIZE {
266            return false;
267        }
268        if &data[0..4] != RIFF_MAGIC || &data[8..12] != WEBP_MAGIC {
269            return false;
270        }
271        // Quick scan for ANIM chunk FourCC without full parse.
272        let body = &data[RIFF_HEADER_SIZE..];
273        has_chunk_fourcc(body, &FOURCC_ANIM)
274    }
275}
276
277// ── Encoding helpers ───────────────────────────────────────────────────────────
278
279/// Build the VP8X chunk payload (10 bytes) for animated WebP.
280fn encode_vp8x(
281    canvas_width: u32,
282    canvas_height: u32,
283    has_anim: bool,
284    has_alpha: bool,
285) -> [u8; VP8X_PAYLOAD_SIZE] {
286    let mut buf = [0u8; VP8X_PAYLOAD_SIZE];
287    let mut flags: u8 = 0;
288    if has_anim {
289        flags |= VP8X_FLAG_ANIMATION;
290    }
291    if has_alpha {
292        flags |= VP8X_FLAG_ALPHA;
293    }
294    buf[0] = flags;
295    // bytes 1..4 reserved (zero)
296    let w = canvas_width.saturating_sub(1);
297    buf[4] = (w & 0xFF) as u8;
298    buf[5] = ((w >> 8) & 0xFF) as u8;
299    buf[6] = ((w >> 16) & 0xFF) as u8;
300    let h = canvas_height.saturating_sub(1);
301    buf[7] = (h & 0xFF) as u8;
302    buf[8] = ((h >> 8) & 0xFF) as u8;
303    buf[9] = ((h >> 16) & 0xFF) as u8;
304    buf
305}
306
307/// Build the ANIM chunk payload (6 bytes).
308///
309/// background_color is stored as BGRA (Blue, Green, Red, Alpha) per the spec.
310fn encode_anim_chunk(config: &WebpAnimConfig) -> [u8; ANIM_PAYLOAD_SIZE] {
311    let mut buf = [0u8; ANIM_PAYLOAD_SIZE];
312    // background_color: AARRGGBB -> stored as BGRA LE
313    let aa = ((config.background_color >> 24) & 0xFF) as u8;
314    let rr = ((config.background_color >> 16) & 0xFF) as u8;
315    let gg = ((config.background_color >> 8) & 0xFF) as u8;
316    let bb = (config.background_color & 0xFF) as u8;
317    buf[0] = bb;
318    buf[1] = gg;
319    buf[2] = rr;
320    buf[3] = aa;
321    let lc = config.loop_count.to_le_bytes();
322    buf[4] = lc[0];
323    buf[5] = lc[1];
324    buf
325}
326
327/// Build the ANMF chunk payload for a single frame (header + VP8L bitstream).
328///
329/// ANMF payload layout (all LE):
330/// - X/2 (24-bit): frame x offset / 2
331/// - Y/2 (24-bit): frame y offset / 2
332/// - (W-1) (24-bit): frame width - 1
333/// - (H-1) (24-bit): frame height - 1
334/// - Duration (24-bit): display duration in ms
335/// - Flags (8-bit): bit 1 = dispose method, bit 2 = blend method
336/// - Frame data (VP8L chunk: FourCC "VP8L" + LE u32 size + bitstream)
337fn encode_anmf_chunk(frame: &WebpAnimFrame, _index: usize) -> CodecResult<Vec<u8>> {
338    // Encode pixels as VP8L.
339    let vp8l_data = encode_frame_vp8l(frame)?;
340
341    // ANMF frame data = VP8L chunk (FourCC + size + bitstream).
342    // We embed the VP8L as a sub-chunk within the ANMF payload.
343    let inner_chunk_size =
344        CHUNK_HEADER_SIZE + vp8l_data.len() + if vp8l_data.len() % 2 != 0 { 1 } else { 0 };
345    let mut payload = Vec::with_capacity(ANMF_HEADER_SIZE + inner_chunk_size);
346
347    // Offsets (24-bit LE, divided by 2).
348    let x2 = frame.x_offset / 2;
349    let y2 = frame.y_offset / 2;
350    write_u24_le(&mut payload, x2);
351    write_u24_le(&mut payload, y2);
352
353    // Dimensions (24-bit LE, minus 1).
354    write_u24_le(&mut payload, frame.width.saturating_sub(1));
355    write_u24_le(&mut payload, frame.height.saturating_sub(1));
356
357    // Duration (24-bit LE, milliseconds).
358    write_u24_le(&mut payload, frame.timestamp_ms.min(0x00FF_FFFF));
359
360    // Flags byte.
361    // bit 0: dispose method (0 = do not dispose, 1 = dispose to background)
362    // bit 1: blending method (0 = use alpha blending, 1 = do not blend)
363    let mut flags: u8 = 0;
364    if frame.dispose {
365        flags |= 0x01;
366    }
367    if !frame.blend {
368        flags |= 0x02;
369    }
370    payload.push(flags);
371
372    // Embed VP8L as a sub-chunk.
373    write_chunk(&mut payload, &FOURCC_VP8L, &vp8l_data);
374
375    Ok(payload)
376}
377
378/// Convert RGBA pixels to ARGB u32 values for the VP8L encoder.
379fn rgba_to_argb_u32(pixels: &[u8], width: u32, height: u32) -> CodecResult<Vec<u32>> {
380    let expected = (width as usize)
381        .checked_mul(height as usize)
382        .and_then(|n| n.checked_mul(4))
383        .ok_or_else(|| CodecError::InvalidParameter("Pixel buffer size overflow".into()))?;
384    if pixels.len() < expected {
385        return Err(CodecError::InvalidParameter(format!(
386            "Pixel buffer too small: need {expected}, have {}",
387            pixels.len()
388        )));
389    }
390    let count = (width as usize) * (height as usize);
391    let mut argb = Vec::with_capacity(count);
392    for i in 0..count {
393        let r = pixels[i * 4] as u32;
394        let g = pixels[i * 4 + 1] as u32;
395        let b = pixels[i * 4 + 2] as u32;
396        let a = pixels[i * 4 + 3] as u32;
397        argb.push((a << 24) | (r << 16) | (g << 8) | b);
398    }
399    Ok(argb)
400}
401
402/// Encode a single animation frame to VP8L bitstream bytes.
403fn encode_frame_vp8l(frame: &WebpAnimFrame) -> CodecResult<Vec<u8>> {
404    let argb = rgba_to_argb_u32(&frame.pixels, frame.width, frame.height)?;
405    let has_alpha = has_non_opaque_alpha(&frame.pixels);
406    let encoder = Vp8lEncoder::new(0);
407    encoder.encode(&argb, frame.width, frame.height, has_alpha)
408}
409
410/// Return true if any pixel in the RGBA buffer has alpha < 255.
411fn has_non_opaque_alpha(pixels: &[u8]) -> bool {
412    pixels.chunks_exact(4).any(|px| px[3] < 255)
413}
414
415// ── Decoding helpers ───────────────────────────────────────────────────────────
416
417/// Validate the RIFF/WEBP header magic bytes.
418fn validate_riff_header(data: &[u8]) -> CodecResult<()> {
419    if data.len() < RIFF_HEADER_SIZE {
420        return Err(CodecError::InvalidBitstream(
421            "Data too small for RIFF header".into(),
422        ));
423    }
424    if &data[0..4] != RIFF_MAGIC {
425        return Err(CodecError::InvalidBitstream(
426            "Missing RIFF magic bytes".into(),
427        ));
428    }
429    if &data[8..12] != WEBP_MAGIC {
430        return Err(CodecError::InvalidBitstream(
431            "Missing WEBP form type magic".into(),
432        ));
433    }
434    Ok(())
435}
436
437/// Parse all top-level RIFF chunks from the WEBP body.
438///
439/// `body` is `data[RIFF_HEADER_SIZE..]`, i.e. everything after the 12-byte
440/// RIFF+size+WEBP header.  The chunk stream starts immediately at offset 0.
441///
442/// Returns a list of (fourcc, payload_bytes) pairs.
443fn parse_chunks(body: &[u8], _body_len: usize) -> CodecResult<Vec<([u8; 4], Vec<u8>)>> {
444    let mut offset = 0usize;
445    let mut chunks = Vec::new();
446
447    while offset + CHUNK_HEADER_SIZE <= body.len() {
448        let mut fourcc = [0u8; 4];
449        fourcc.copy_from_slice(&body[offset..offset + 4]);
450        let chunk_size = read_u32_le(&body[offset + 4..offset + 8]) as usize;
451        offset += CHUNK_HEADER_SIZE;
452
453        if offset + chunk_size > body.len() {
454            return Err(CodecError::InvalidBitstream(format!(
455                "Chunk '{}' at offset {} declares size {} but only {} bytes remain",
456                String::from_utf8_lossy(&fourcc),
457                offset - CHUNK_HEADER_SIZE,
458                chunk_size,
459                body.len().saturating_sub(offset),
460            )));
461        }
462
463        let payload = body[offset..offset + chunk_size].to_vec();
464        chunks.push((fourcc, payload));
465
466        offset += chunk_size;
467        if chunk_size % 2 != 0 {
468            offset += 1; // skip pad byte
469        }
470    }
471
472    Ok(chunks)
473}
474
475/// Decode the ANIM chunk payload into a WebpAnimConfig.
476fn decode_anim_chunk(data: &[u8]) -> CodecResult<WebpAnimConfig> {
477    if data.len() < ANIM_PAYLOAD_SIZE {
478        return Err(CodecError::InvalidBitstream(format!(
479            "ANIM chunk too small: need {ANIM_PAYLOAD_SIZE}, got {}",
480            data.len()
481        )));
482    }
483    // Stored as BGRA LE.
484    let bb = data[0] as u32;
485    let gg = data[1] as u32;
486    let rr = data[2] as u32;
487    let aa = data[3] as u32;
488    let background_color = (aa << 24) | (rr << 16) | (gg << 8) | bb;
489    let loop_count = u16::from_le_bytes([data[4], data[5]]);
490    Ok(WebpAnimConfig {
491        loop_count,
492        background_color,
493    })
494}
495
496/// Decode an ANMF chunk payload into a WebpAnimFrame with decoded pixels.
497fn decode_anmf_chunk(data: &[u8]) -> CodecResult<WebpAnimFrame> {
498    if data.len() < ANMF_HEADER_SIZE {
499        return Err(CodecError::InvalidBitstream(format!(
500            "ANMF chunk too small: need {ANMF_HEADER_SIZE} bytes for header, got {}",
501            data.len()
502        )));
503    }
504
505    let x_offset = read_u24_le(&data[0..3]) * 2;
506    let y_offset = read_u24_le(&data[3..6]) * 2;
507    let width = read_u24_le(&data[6..9]) + 1;
508    let height = read_u24_le(&data[9..12]) + 1;
509    let timestamp_ms = read_u24_le(&data[12..15]);
510    let flags = data[15];
511
512    let dispose = (flags & 0x01) != 0;
513    let blend = (flags & 0x02) == 0;
514
515    // The remaining bytes should be a VP8L sub-chunk.
516    let frame_data = &data[ANMF_HEADER_SIZE..];
517    let pixels = decode_vp8l_subchunk(frame_data, width, height)?;
518
519    Ok(WebpAnimFrame {
520        pixels,
521        width,
522        height,
523        timestamp_ms,
524        x_offset,
525        y_offset,
526        blend,
527        dispose,
528    })
529}
530
531/// Decode pixels from a VP8L sub-chunk embedded in an ANMF payload.
532fn decode_vp8l_subchunk(data: &[u8], width: u32, height: u32) -> CodecResult<Vec<u8>> {
533    // Sub-chunk: FourCC(4) + size(4) + VP8L bitstream.
534    if data.len() < CHUNK_HEADER_SIZE {
535        return Err(CodecError::InvalidBitstream(
536            "ANMF frame data too small for sub-chunk header".into(),
537        ));
538    }
539    let fourcc = &data[0..4];
540    if fourcc != FOURCC_VP8L {
541        return Err(CodecError::InvalidBitstream(format!(
542            "Expected VP8L sub-chunk in ANMF, got '{}'",
543            String::from_utf8_lossy(fourcc)
544        )));
545    }
546    let chunk_size = read_u32_le(&data[4..8]) as usize;
547    if data.len() < CHUNK_HEADER_SIZE + chunk_size {
548        return Err(CodecError::InvalidBitstream(
549            "VP8L sub-chunk data truncated".into(),
550        ));
551    }
552    let vp8l_data = &data[CHUNK_HEADER_SIZE..CHUNK_HEADER_SIZE + chunk_size];
553    decode_vp8l_to_rgba(vp8l_data, width, height)
554}
555
556/// Decode a VP8L bitstream to RGBA pixels using the existing Vp8lDecoder.
557fn decode_vp8l_to_rgba(vp8l_data: &[u8], _width: u32, _height: u32) -> CodecResult<Vec<u8>> {
558    use crate::webp::vp8l_decoder::Vp8lDecoder;
559
560    let decoded = Vp8lDecoder::new()
561        .decode(vp8l_data)
562        .map_err(|e| CodecError::DecoderError(format!("VP8L decode failed: {e}")))?;
563
564    // decoded.pixels is Vec<u32> in ARGB order; convert to RGBA bytes.
565    let mut rgba = Vec::with_capacity(decoded.pixels.len() * 4);
566    for argb in &decoded.pixels {
567        let a = (argb >> 24) as u8;
568        let r = (argb >> 16) as u8;
569        let g = (argb >> 8) as u8;
570        let b = *argb as u8;
571        rgba.push(r);
572        rgba.push(g);
573        rgba.push(b);
574        rgba.push(a);
575    }
576    Ok(rgba)
577}
578
579/// Quick scan of chunk stream bytes for a given FourCC without full parsing.
580///
581/// `body` is the bytes immediately after the 12-byte RIFF+size+WEBP header,
582/// so the chunk stream starts at offset 0.
583fn has_chunk_fourcc(body: &[u8], target: &[u8; 4]) -> bool {
584    let mut offset = 0usize;
585    while offset + CHUNK_HEADER_SIZE <= body.len() {
586        let fourcc = &body[offset..offset + 4];
587        if fourcc == target.as_ref() {
588            return true;
589        }
590        let chunk_size = read_u32_le(&body[offset + 4..offset + 8]) as usize;
591        offset += CHUNK_HEADER_SIZE + chunk_size;
592        if chunk_size % 2 != 0 {
593            offset += 1;
594        }
595    }
596    false
597}
598
599// ── Wire format helpers ────────────────────────────────────────────────────────
600
601/// Write a RIFF chunk: FourCC + LE u32 size + payload + optional pad byte.
602fn write_chunk(buf: &mut Vec<u8>, fourcc: &[u8; 4], data: &[u8]) {
603    buf.extend_from_slice(fourcc);
604    write_u32_le(buf, data.len() as u32);
605    buf.extend_from_slice(data);
606    if data.len() % 2 != 0 {
607        buf.push(0);
608    }
609}
610
611/// Return the wire size of a chunk (header + payload + optional pad byte).
612fn chunk_wire_size(payload_len: usize) -> usize {
613    CHUNK_HEADER_SIZE + payload_len + (payload_len % 2)
614}
615
616fn write_u32_le(buf: &mut Vec<u8>, v: u32) {
617    buf.extend_from_slice(&v.to_le_bytes());
618}
619
620fn write_u24_le(buf: &mut Vec<u8>, v: u32) {
621    buf.push((v & 0xFF) as u8);
622    buf.push(((v >> 8) & 0xFF) as u8);
623    buf.push(((v >> 16) & 0xFF) as u8);
624}
625
626fn read_u32_le(data: &[u8]) -> u32 {
627    let mut b = [0u8; 4];
628    b.copy_from_slice(&data[..4]);
629    u32::from_le_bytes(b)
630}
631
632fn read_u24_le(data: &[u8]) -> u32 {
633    u32::from(data[0]) | (u32::from(data[1]) << 8) | (u32::from(data[2]) << 16)
634}
635
636// ── Tests ──────────────────────────────────────────────────────────────────────
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    /// Build a solid-color RGBA frame.
643    fn make_solid_frame(width: u32, height: u32, r: u8, g: u8, b: u8, a: u8) -> WebpAnimFrame {
644        let pixels = (0..width * height)
645            .flat_map(|_| [r, g, b, a])
646            .collect::<Vec<u8>>();
647        WebpAnimFrame {
648            pixels,
649            width,
650            height,
651            timestamp_ms: 0,
652            x_offset: 0,
653            y_offset: 0,
654            blend: true,
655            dispose: false,
656        }
657    }
658
659    /// Build a sequence of timed frames with distinct colours.
660    fn make_colour_frames() -> Vec<WebpAnimFrame> {
661        let colours: &[(u8, u8, u8, u8, u32)] = &[
662            (255, 0, 0, 255, 0),
663            (0, 255, 0, 255, 100),
664            (0, 0, 255, 255, 200),
665        ];
666        colours
667            .iter()
668            .map(|&(r, g, b, a, ts)| {
669                let mut frame = make_solid_frame(4, 4, r, g, b, a);
670                frame.timestamp_ms = ts;
671                frame
672            })
673            .collect()
674    }
675
676    // ── is_webp_anim ──────────────────────────────────────────────────
677
678    #[test]
679    fn test_is_webp_anim_true_after_encode() {
680        let frames = make_colour_frames();
681        let config = WebpAnimConfig::default();
682        let data = WebpAnimEncoder::encode(&frames, &config).expect("encode");
683        assert!(WebpAnimDecoder::is_webp_anim(&data));
684    }
685
686    #[test]
687    fn test_is_webp_anim_false_for_empty() {
688        assert!(!WebpAnimDecoder::is_webp_anim(&[]));
689    }
690
691    #[test]
692    fn test_is_webp_anim_false_for_garbage() {
693        let junk = vec![0xFFu8; 64];
694        assert!(!WebpAnimDecoder::is_webp_anim(&junk));
695    }
696
697    #[test]
698    fn test_is_webp_anim_false_for_truncated_riff() {
699        // Valid RIFF+WEBP magic but truncated — no ANIM chunk.
700        let mut data = vec![0u8; 20];
701        data[0..4].copy_from_slice(RIFF_MAGIC);
702        data[8..12].copy_from_slice(WEBP_MAGIC);
703        assert!(!WebpAnimDecoder::is_webp_anim(&data));
704    }
705
706    // ── frame_count ───────────────────────────────────────────────────
707
708    #[test]
709    fn test_frame_count_single() {
710        let frames = vec![make_solid_frame(2, 2, 128, 128, 128, 255)];
711        let config = WebpAnimConfig::default();
712        let data = WebpAnimEncoder::encode(&frames, &config).expect("encode");
713        let count = WebpAnimDecoder::frame_count(&data).expect("count");
714        assert_eq!(count, 1);
715    }
716
717    #[test]
718    fn test_frame_count_multiple() {
719        let frames = make_colour_frames();
720        let config = WebpAnimConfig::default();
721        let data = WebpAnimEncoder::encode(&frames, &config).expect("encode");
722        let count = WebpAnimDecoder::frame_count(&data).expect("count");
723        assert_eq!(count, 3);
724    }
725
726    #[test]
727    fn test_frame_count_error_on_non_anim() {
728        let data = b"RIFF\x00\x00\x00\x00WEBPnothing-here-at-all";
729        assert!(WebpAnimDecoder::frame_count(data).is_err());
730    }
731
732    // ── encode / decode roundtrip ─────────────────────────────────────
733
734    #[test]
735    fn test_roundtrip_single_frame() {
736        let frame = make_solid_frame(4, 4, 200, 100, 50, 255);
737        let config = WebpAnimConfig {
738            loop_count: 3,
739            background_color: 0xFF_FF0000,
740        };
741        let data = WebpAnimEncoder::encode(&[frame.clone()], &config).expect("encode");
742        let (decoded_frames, decoded_config) = WebpAnimDecoder::decode(&data).expect("decode");
743
744        assert_eq!(decoded_config.loop_count, 3);
745        assert_eq!(decoded_config.background_color, 0xFF_FF0000);
746        assert_eq!(decoded_frames.len(), 1);
747
748        let df = &decoded_frames[0];
749        assert_eq!(df.width, 4);
750        assert_eq!(df.height, 4);
751        assert_eq!(df.timestamp_ms, 0);
752        assert_eq!(df.x_offset, 0);
753        assert_eq!(df.y_offset, 0);
754        assert_eq!(df.pixels.len(), 4 * 4 * 4);
755    }
756
757    #[test]
758    fn test_roundtrip_multiple_frames() {
759        let frames = make_colour_frames();
760        let config = WebpAnimConfig {
761            loop_count: 0,
762            background_color: 0xFF_000000,
763        };
764        let data = WebpAnimEncoder::encode(&frames, &config).expect("encode");
765        let (decoded_frames, decoded_config) = WebpAnimDecoder::decode(&data).expect("decode");
766
767        assert_eq!(decoded_config.loop_count, 0);
768        assert_eq!(decoded_frames.len(), 3);
769
770        for (orig, decoded) in frames.iter().zip(decoded_frames.iter()) {
771            assert_eq!(decoded.width, orig.width);
772            assert_eq!(decoded.height, orig.height);
773            assert_eq!(decoded.timestamp_ms, orig.timestamp_ms);
774            assert_eq!(decoded.x_offset, orig.x_offset);
775            assert_eq!(decoded.y_offset, orig.y_offset);
776            assert_eq!(decoded.blend, orig.blend);
777            assert_eq!(decoded.dispose, orig.dispose);
778            assert_eq!(decoded.pixels.len(), orig.pixels.len());
779        }
780    }
781
782    #[test]
783    fn test_roundtrip_with_alpha() {
784        let frame = make_solid_frame(8, 8, 100, 150, 200, 128);
785        let config = WebpAnimConfig::default();
786        let data = WebpAnimEncoder::encode(&[frame], &config).expect("encode");
787        let (decoded_frames, _) = WebpAnimDecoder::decode(&data).expect("decode");
788        assert_eq!(decoded_frames.len(), 1);
789        assert_eq!(decoded_frames[0].pixels.len(), 8 * 8 * 4);
790    }
791
792    #[test]
793    fn test_roundtrip_dispose_and_blend_flags() {
794        let mut frame = make_solid_frame(4, 4, 0, 0, 0, 255);
795        frame.dispose = true;
796        frame.blend = false;
797        let config = WebpAnimConfig::default();
798        let data = WebpAnimEncoder::encode(&[frame], &config).expect("encode");
799        let (decoded_frames, _) = WebpAnimDecoder::decode(&data).expect("decode");
800        assert_eq!(decoded_frames[0].dispose, true);
801        assert_eq!(decoded_frames[0].blend, false);
802    }
803
804    #[test]
805    fn test_roundtrip_offsets() {
806        let mut frame = make_solid_frame(4, 4, 0, 255, 0, 255);
807        frame.x_offset = 4;
808        frame.y_offset = 6;
809        let config = WebpAnimConfig::default();
810        let data = WebpAnimEncoder::encode(&[frame], &config).expect("encode");
811        let (decoded_frames, _) = WebpAnimDecoder::decode(&data).expect("decode");
812        assert_eq!(decoded_frames[0].x_offset, 4);
813        assert_eq!(decoded_frames[0].y_offset, 6);
814    }
815
816    // ── validation errors ─────────────────────────────────────────────
817
818    #[test]
819    fn test_encode_empty_frames_error() {
820        let config = WebpAnimConfig::default();
821        let result = WebpAnimEncoder::encode(&[], &config);
822        assert!(result.is_err());
823    }
824
825    #[test]
826    fn test_encode_odd_x_offset_error() {
827        let mut frame = make_solid_frame(4, 4, 0, 0, 0, 255);
828        frame.x_offset = 3;
829        let config = WebpAnimConfig::default();
830        assert!(WebpAnimEncoder::encode(&[frame], &config).is_err());
831    }
832
833    #[test]
834    fn test_encode_odd_y_offset_error() {
835        let mut frame = make_solid_frame(4, 4, 0, 0, 0, 255);
836        frame.y_offset = 1;
837        let config = WebpAnimConfig::default();
838        assert!(WebpAnimEncoder::encode(&[frame], &config).is_err());
839    }
840
841    #[test]
842    fn test_encode_zero_dimension_error() {
843        let frame = WebpAnimFrame {
844            pixels: vec![],
845            width: 0,
846            height: 4,
847            timestamp_ms: 0,
848            x_offset: 0,
849            y_offset: 0,
850            blend: true,
851            dispose: false,
852        };
853        let config = WebpAnimConfig::default();
854        assert!(WebpAnimEncoder::encode(&[frame], &config).is_err());
855    }
856
857    #[test]
858    fn test_encode_wrong_pixel_length_error() {
859        let frame = WebpAnimFrame {
860            pixels: vec![0u8; 10], // wrong: should be 4*4*4=64
861            width: 4,
862            height: 4,
863            timestamp_ms: 0,
864            x_offset: 0,
865            y_offset: 0,
866            blend: true,
867            dispose: false,
868        };
869        let config = WebpAnimConfig::default();
870        assert!(WebpAnimEncoder::encode(&[frame], &config).is_err());
871    }
872
873    // ── decode error cases ────────────────────────────────────────────
874
875    #[test]
876    fn test_decode_too_short() {
877        assert!(WebpAnimDecoder::decode(&[0u8; 4]).is_err());
878    }
879
880    #[test]
881    fn test_decode_bad_magic() {
882        let mut data = vec![0u8; 32];
883        data[0..4].copy_from_slice(b"RIFT"); // wrong magic
884        assert!(WebpAnimDecoder::decode(&data).is_err());
885    }
886
887    #[test]
888    fn test_canvas_dimensions_from_multiple_frames() {
889        // Frame 1: 4×4 at (0,0), Frame 2: 4×4 at (4,4) — canvas should be 8×8.
890        let mut f1 = make_solid_frame(4, 4, 255, 0, 0, 255);
891        f1.x_offset = 0;
892        f1.y_offset = 0;
893        let mut f2 = make_solid_frame(4, 4, 0, 255, 0, 255);
894        f2.x_offset = 4;
895        f2.y_offset = 4;
896
897        let config = WebpAnimConfig::default();
898        let data = WebpAnimEncoder::encode(&[f1, f2], &config).expect("encode");
899
900        // Verify the VP8X canvas dims embedded in the file.
901        // VP8X chunk starts at offset 12 (RIFF header) + 8 (chunk header) = 20.
902        // canvas_width-1 is at bytes 4..7 of the VP8X payload (file offset 24).
903        let payload_offset = RIFF_HEADER_SIZE + CHUNK_HEADER_SIZE;
904        let w = u32::from(data[payload_offset + 4])
905            | (u32::from(data[payload_offset + 5]) << 8)
906            | (u32::from(data[payload_offset + 6]) << 16);
907        let h = u32::from(data[payload_offset + 7])
908            | (u32::from(data[payload_offset + 8]) << 8)
909            | (u32::from(data[payload_offset + 9]) << 16);
910        assert_eq!(w + 1, 8); // canvas_width = 8
911        assert_eq!(h + 1, 8); // canvas_height = 8
912
913        let count = WebpAnimDecoder::frame_count(&data).expect("count");
914        assert_eq!(count, 2);
915    }
916
917    #[test]
918    fn test_pixel_fidelity_solid_colour() {
919        // Solid green 2×2 — lossless encode/decode should round-trip perfectly.
920        let frame = make_solid_frame(2, 2, 0, 255, 0, 255);
921        let config = WebpAnimConfig::default();
922        let data = WebpAnimEncoder::encode(&[frame.clone()], &config).expect("encode");
923        let (decoded, _) = WebpAnimDecoder::decode(&data).expect("decode");
924        assert_eq!(decoded[0].pixels, frame.pixels);
925    }
926}