Skip to main content

oxideav_webp/
anim.rs

1//! Typed parser for the `ANIM` chunk payload per RFC 9649 §2.7.1.1
2//! (Figure 8).
3//!
4//! ANIM carries the **global animation parameters** that apply to
5//! every `ANMF` frame in the file:
6//!
7//! ```text
8//!  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
9//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
10//! |                       Background Color                        |
11//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
12//! |          Loop Count           |
13//! +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
14//! ```
15//!
16//! * `Background Color` — 32 bits, **uint32** stored as four bytes in
17//!   `[Blue, Green, Red, Alpha]` order. §2.7.1.1 explicitly calls out
18//!   the byte order; this module surfaces the four components in a
19//!   separated `BackgroundColor` struct so callers don't have to
20//!   second-guess endianness.
21//! * `Loop Count` — 16 bits, **uint16** little-endian (consistent with
22//!   every other multi-byte field in RFC 9649). `0` means "loop
23//!   infinitely" per §2.7.1.1.
24//!
25//! The ANIM chunk's `Size` is fixed at 6 bytes by Figure 8; the
26//! parser rejects any other length.
27//!
28//! ## Cross-check
29//!
30//! `docs/image/webp/fixtures/animated-with-alpha/trace.txt`:
31//!
32//! ```text
33//! ANIM    bgcolor=0xffffffff   loop_count=0
34//! ```
35//!
36//! That is, the on-disk bytes `ff ff ff ff` decode to B=R=G=A=255 and
37//! `00 00` decodes to `loop_count=0` (infinite). Same trace appears in
38//! `animated-3-frames-rgb`.
39
40use core::fmt;
41
42/// Background color from §2.7.1.1 — the `Background Color` field,
43/// laid out on disk in `[Blue, Green, Red, Alpha]` byte order.
44///
45/// §2.7.1.1: "This color MAY be used to fill the unused space on the
46/// canvas around the frames, as well as the transparent pixels of the
47/// first frame." It may carry a non-opaque alpha even when the
48/// `VP8X.L` alpha flag is unset.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct BackgroundColor {
51    /// Blue channel (on-disk byte 0).
52    pub blue: u8,
53    /// Green channel (on-disk byte 1).
54    pub green: u8,
55    /// Red channel (on-disk byte 2).
56    pub red: u8,
57    /// Alpha channel (on-disk byte 3). 255 = opaque.
58    pub alpha: u8,
59}
60
61impl BackgroundColor {
62    /// Pack the four components as a `uint32` in **on-disk** byte
63    /// order — i.e. the same little-endian-loaded value the trace
64    /// records as `bgcolor=0xXXXXXXXX`.
65    ///
66    /// For `ff ff ff ff` on disk this returns `0xffff_ffff`; for the
67    /// degenerate "all zero" payload it returns `0x0000_0000`. The
68    /// integer's byte layout (LSB→MSB) is exactly `[B, G, R, A]`.
69    pub const fn as_u32_le(&self) -> u32 {
70        (self.blue as u32)
71            | ((self.green as u32) << 8)
72            | ((self.red as u32) << 16)
73            | ((self.alpha as u32) << 24)
74    }
75}
76
77/// Errors raised by the §2.7.1.1 ANIM parser.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum AnimError {
80    /// The ANIM payload was not exactly 6 bytes — Figure 8 fixes the
81    /// layout at 4-byte background + 2-byte loop count.
82    BadPayloadLength {
83        /// Actual payload length observed.
84        got: usize,
85    },
86}
87
88impl fmt::Display for AnimError {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            Self::BadPayloadLength { got } => write!(
92                f,
93                "ANIM payload must be 6 bytes per §2.7.1.1 Figure 8, got {got}"
94            ),
95        }
96    }
97}
98
99impl std::error::Error for AnimError {}
100
101/// Decoded §2.7.1.1 `ANIM` chunk — global animation parameters.
102///
103/// Constructed via [`AnimHeader::parse`].
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct AnimHeader {
106    /// `Background Color` field, broken into BGRA components.
107    pub background_color: BackgroundColor,
108    /// `Loop Count` field. `0` means "loop infinitely" per §2.7.1.1.
109    pub loop_count: u16,
110}
111
112impl AnimHeader {
113    /// Parse the 6-byte `ANIM` chunk payload per RFC 9649 §2.7.1.1.
114    ///
115    /// `payload` is the slice returned by
116    /// [`crate::container::WebpChunk::payload`] for a chunk whose
117    /// FourCC is [`crate::container::fourcc::ANIM`].
118    pub fn parse(payload: &[u8]) -> Result<Self, AnimError> {
119        if payload.len() != 6 {
120            return Err(AnimError::BadPayloadLength { got: payload.len() });
121        }
122        // §2.7.1.1: BGRA byte order for the background color uint32.
123        let background_color = BackgroundColor {
124            blue: payload[0],
125            green: payload[1],
126            red: payload[2],
127            alpha: payload[3],
128        };
129        // §2.7.1.1: 16-bit Loop Count (RFC 9649 multi-byte fields are
130        // little-endian throughout — see §2.3 "all data is stored
131        // little-endian unless explicitly noted").
132        let loop_count = u16::from_le_bytes([payload[4], payload[5]]);
133        Ok(Self {
134            background_color,
135            loop_count,
136        })
137    }
138
139    /// `true` when `Loop Count == 0`, which §2.7.1.1 defines as
140    /// "infinite playback".
141    pub const fn loops_forever(&self) -> bool {
142        self.loop_count == 0
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    /// Build a 6-byte ANIM payload from its component fields.
151    fn anim(b: u8, g: u8, r: u8, a: u8, loop_count: u16) -> Vec<u8> {
152        let mut v = Vec::with_capacity(6);
153        v.push(b);
154        v.push(g);
155        v.push(r);
156        v.push(a);
157        v.extend_from_slice(&loop_count.to_le_bytes());
158        v
159    }
160
161    #[test]
162    fn payload_must_be_exactly_six_bytes() {
163        // Figure 8 fixes the layout at 4 + 2 = 6 bytes.
164        assert_eq!(
165            AnimHeader::parse(&[]),
166            Err(AnimError::BadPayloadLength { got: 0 })
167        );
168        assert_eq!(
169            AnimHeader::parse(&[0u8; 5]),
170            Err(AnimError::BadPayloadLength { got: 5 })
171        );
172        assert_eq!(
173            AnimHeader::parse(&[0u8; 7]),
174            Err(AnimError::BadPayloadLength { got: 7 })
175        );
176    }
177
178    #[test]
179    fn all_zero_payload_decodes_to_transparent_black_infinite_loop() {
180        // bgcolor=0x00000000 (B=G=R=A=0, fully transparent black),
181        // loop_count=0 (infinite). The simplest legal ANIM.
182        let h = AnimHeader::parse(&[0u8; 6]).unwrap();
183        assert_eq!(h.background_color.blue, 0);
184        assert_eq!(h.background_color.green, 0);
185        assert_eq!(h.background_color.red, 0);
186        assert_eq!(h.background_color.alpha, 0);
187        assert_eq!(h.background_color.as_u32_le(), 0);
188        assert_eq!(h.loop_count, 0);
189        assert!(h.loops_forever());
190    }
191
192    #[test]
193    fn background_color_byte_order_is_bgra() {
194        // Distinct values for each channel — pinning down which byte
195        // index maps to which component.
196        let h = AnimHeader::parse(&anim(0x10, 0x20, 0x30, 0x40, 5)).unwrap();
197        assert_eq!(h.background_color.blue, 0x10);
198        assert_eq!(h.background_color.green, 0x20);
199        assert_eq!(h.background_color.red, 0x30);
200        assert_eq!(h.background_color.alpha, 0x40);
201        // Trace-style uint32 with the on-disk order in LSB→MSB:
202        // 0x40 30 20 10.
203        assert_eq!(h.background_color.as_u32_le(), 0x4030_2010);
204    }
205
206    #[test]
207    fn loop_count_is_little_endian_u16() {
208        // 0x0102 stored as `02 01` little-endian.
209        let h = AnimHeader::parse(&[0, 0, 0, 0, 0x02, 0x01]).unwrap();
210        assert_eq!(h.loop_count, 0x0102);
211        assert!(!h.loops_forever());
212    }
213
214    #[test]
215    fn maximum_loop_count_decodes_and_is_not_infinite() {
216        // 0xFFFF is the largest finite loop count; only 0 is infinite.
217        let h = AnimHeader::parse(&[0, 0, 0, 0, 0xFF, 0xFF]).unwrap();
218        assert_eq!(h.loop_count, 0xFFFF);
219        assert!(!h.loops_forever());
220    }
221
222    #[test]
223    fn fixture_animated_with_alpha_decodes_to_white_opaque_infinite() {
224        // docs/image/webp/fixtures/animated-with-alpha/trace.txt
225        //   ANIM bgcolor=0xffffffff loop_count=0
226        //
227        // On-disk: ff ff ff ff 00 00.
228        let h = AnimHeader::parse(&[0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00]).unwrap();
229        assert_eq!(h.background_color.blue, 0xFF);
230        assert_eq!(h.background_color.green, 0xFF);
231        assert_eq!(h.background_color.red, 0xFF);
232        assert_eq!(h.background_color.alpha, 0xFF);
233        assert_eq!(h.background_color.as_u32_le(), 0xFFFF_FFFF);
234        assert_eq!(h.loop_count, 0);
235        assert!(h.loops_forever());
236    }
237}