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}