Skip to main content

oxideav_webp/
lib.rs

1//! # oxideav-webp
2//!
3//! Pure-Rust WebP image codec — clean-room scaffold built against
4//! RFC 9649 (WebP Image Format).
5//!
6//! Round 1 landed the **structural** RIFF/WEBP container walker
7//! ([`container::parse`]). Round 2 added typed field decoding for the
8//! `VP8X` extended-format header ([`vp8x::Vp8xHeader::parse`]). Round 3
9//! added typed field decoding for the §2.7.1.1 `ANIM` / §2.7.1.2 `ALPH`
10//! metadata chunks. Round 4 added typed field decoding for the
11//! per-frame §2.7.1.1 `ANMF` header. Round 5 added the **builder**
12//! side of the RIFF/WEBP container — the inverse of the walker — so
13//! external encoders can wrap a `VP8 ` / `VP8L` payload in a
14//! well-formed file. Round 6 adds a typed §2.5 `VP8 ` chunk handle
15//! ([`vp8_chunk::WebpLossyChunk`]) that lets container-layer callers
16//! route the VP8 payload to a downstream VP8 decoder **without**
17//! `oxideav-webp` taking a runtime dependency on `oxideav-vp8`.
18//!
19//! * [`alph::AlphHeader::parse`] — the `ALPH` info byte
20//!   (`Rsv|P|F|C`).
21//! * [`alph::decode_alpha`] — the §2.7.1.2 alpha-bitstream decode
22//!   (round 110): both compression methods (raw + headerless VP8L,
23//!   the latter lifting alpha from the GREEN channel) and the four
24//!   inverse filters (none / horizontal / vertical / gradient) with
25//!   the documented left-most / top-most edge cases, producing the
26//!   full-resolution alpha plane. [`decode_alpha_plane`] is the
27//!   container-level entry point: walk the file, take dimensions from
28//!   `VP8X` (or the `VP8 ` keyframe), find the `ALPH` chunk, decode.
29//! * [`anim::AnimHeader::parse`] — the `ANIM` 6-byte payload
30//!   (BGRA background colour + u16 loop count).
31//! * [`anmf::AnmfHeader::parse`] — the `ANMF` 16-byte per-frame
32//!   header (frame X / Y / width / height / duration plus
33//!   `Reserved|B|D` info byte).
34//! * [`build::build_chunk`] — generic §2.3 chunk writer (FourCC +
35//!   Size + payload + odd-size pad).
36//! * [`build::build_vp8x_chunk`] — §2.7.1 Figure 7 typed VP8X
37//!   payload writer.
38//! * [`build::build_webp_file`] — §2.4 file writer for simple
39//!   (`VP8 ` / `VP8L`) and extended (`VP8X` + `VP8 ` / `VP8L`)
40//!   layouts.
41//! * [`vp8_chunk::WebpLossyChunk`] — typed §2.5 `VP8 ` chunk
42//!   handle. Peeks the RFC 6386 §9.1 keyframe header (width /
43//!   height / version / first_partition_size / scale fields) and
44//!   exposes the chunk payload via [`vp8_chunk::WebpLossyChunk::bitstream`]
45//!   for routing to an external VP8 decoder.
46//! * [`vp8l_chunk::WebpLosslessChunk`] — typed §2.6 `VP8L` chunk
47//!   handle. Peeks the §3.4 / §7.1 5-byte VP8L image-header
48//!   (`0x2F` signature + 14-bit `width-1` + 14-bit `height-1` +
49//!   `alpha_is_used` bit + 3-bit `version`) and exposes the chunk
50//!   payload via [`vp8l_chunk::WebpLosslessChunk::bitstream`] for
51//!   routing to an external VP8L decoder.
52//! * [`vp8l_stream::TransformList`] — the §4 transform-presence loop
53//!   (round 99): each present transform's leading fixed fields, stopping
54//!   at the first §5 entropy-coded body.
55//! * [`vp8l_prefix::PrefixCode`] — the §6.2.1 prefix-code reader
56//!   (round 104): reads a single canonical prefix code's lengths off
57//!   the wire (simple or normal code length code) and decodes symbols
58//!   one at a time. This is the first piece of the §5 / §6 entropy
59//!   machinery the §4 transform bodies and the main image stream both
60//!   consume.
61//! * [`meta_prefix::MetaPrefixHeader`] — the §5.2.3 color-cache info,
62//!   §6.2.2 meta-prefix dispatch, and §6.2 5-prefix-code-group reader
63//!   (round 106). Surfaces either a fully-built single
64//!   [`meta_prefix::PrefixCodeGroup`] (the common case: single
65//!   meta-Huffman group, or any non-ARGB role) or, when an ARGB image
66//!   selects an entropy image, the entropy-image dimensions plus the
67//!   bit position at which the §5.2-encoded entropy image starts (for
68//!   the next round to resume from once §5.2 LZ77 + color-cache decode
69//!   lands).
70//! * [`vp8l_decode::decode_image`] — the §5.2 LZ77 backward-reference +
71//!   §5.2.3 color-cache per-pixel ARGB decode loop (round 107). Runs
72//!   the §6.2.3 GREEN symbol dispatch (literal / LZ77 length+distance /
73//!   color-cache code) over a single [`meta_prefix::PrefixCodeGroup`]
74//!   and produces a [`vp8l_decode::DecodedImage`] of ARGB pixels in
75//!   scan-line order (before any §4 inverse transform). Includes the
76//!   §5.2.2 prefix→value transform, the 120-element distance map, and
77//!   the §5.2.3 `0x1e35a7bd` color cache.
78//! * [`vp8l_decode::decode_argb`] — the §6.2.2 multi-group ARGB decode
79//!   (round 108). Reads the round-106 [`meta_prefix::MetaPrefixHeader`]
80//!   for the ARGB role and, when the meta-prefix bit selects multiple
81//!   groups, decodes the §6.2.2 *entropy image*
82//!   ([`vp8l_decode::decode_entropy_image`] →
83//!   [`vp8l_decode::MetaPrefixIndex`]), derives
84//!   `num_prefix_groups = max(entropy image) + 1`, reads that many
85//!   prefix-code groups, and runs the §6.2.3 loop selecting a group per
86//!   pixel block via
87//!   `meta_index[(y >> prefix_bits) * block_width + (x >> prefix_bits)]`.
88//!   Single-group images degrade to the round-107 path. Per §6.2.2 each
89//!   block's meta-prefix code is the red+green channels of its
90//!   entropy-image pixel (`(argb >> 8) & 0xffff`).
91//! * [`vp8l_transform::decode_lossless`] — the §4 inverse-transform
92//!   passes (round 109). Reads the §4 transform list (each transform's
93//!   fixed fields **and** its §5-encoded body), decodes the main ARGB
94//!   image at the (color-indexing-subsampled) width, then applies the
95//!   four inverse transforms in reverse read order: §4.1 predictor (14
96//!   prediction modes + border rules over the block grid), §4.2 color
97//!   (per-block `ColorTransformElement` add-back), §4.3 subtract-green
98//!   (add green into red/blue), and §4.4 color-indexing (palette lookup
99//!   plus ≤16-color pixel un-bundling). The container-level entry point,
100//!   [`decode_lossless_image`], walks the file, extracts the `VP8L`
101//!   chunk, and decodes to a [`vp8l_decode::DecodedImage`]. Bit-exact
102//!   against the `lossless-1x1`, `lossless-color-indexing-paletted`, and
103//!   `lossless-32x32-rgba` (SUBTRACT_GREEN + PREDICTOR + CROSS_COLOR +
104//!   color cache) fixture PNGs.
105//!
106//! * [`vp8_decode::decode_lossy_rgba`] — the §2.5 `VP8 ` (lossy) decode
107//!   path (round 124). Routes the `VP8 ` chunk payload to the
108//!   `oxideav-vp8` sibling crate's [`oxideav_vp8::decode_vp8`] entry
109//!   point, which reconstructs the loop-filtered I420 key-frame, then
110//!   converts it to interleaved RGBA via nearest-neighbour chroma
111//!   up-sampling and the RFC 6386 §9.2 ITU-R BT.601 full-range YCbCr→RGB
112//!   matrix.
113//! * [`decode_webp_image`] / [`decode_webp`] — the top-level still-image
114//!   entry points (round 111). They walk the container, decode a §2.6 /
115//!   §3.4 `VP8L` lossless image (simple or `VP8X`-extended) through the
116//!   full §4–§6 chain, optionally override its alpha from a §2.7.1.2
117//!   `ALPH` chunk, and return interleaved 8-bit `[R, G, B, A]` pixels
118//!   ([`DecodedWebp`]) — the `oxideav_core::PixelFormat::Rgba` layout
119//!   the workspace's image crates share. As of round 124 a §2.5 `VP8 `
120//!   lossy file is also decoded (via `oxideav-vp8`), with a §2.7.1.2
121//!   `ALPH` chunk layering the alpha plane over the opaque VP8 picture.
122//!
123//! Both the §2.5 `VP8 ` lossy and §2.6 `VP8L` lossless image-data paths
124//! now decode end-to-end (the lossy path through the `oxideav-vp8`
125//! sibling crate). The §2.7.1.2 ALPH alpha bitstream is also decoded
126//! end-to-end ([`alph::decode_alpha`] / [`decode_alpha_plane`]). VP8 /
127//! VP8L bitstream *encode* remains framing-only — the builders take an
128//! externally pre-computed codec payload.
129
130#![warn(missing_debug_implementations)]
131// Opt-in `std::simd` acceleration of the hottest pixel-repack /
132// inverse-transform loops. Nightly-only because `portable_simd` is
133// still an unstable feature; every SIMD path has a stable scalar
134// fallback that produces byte-identical output. See `BENCHMARKS.md`
135// and the `simd` cargo feature in `Cargo.toml`.
136#![cfg_attr(feature = "simd", feature(portable_simd))]
137
138pub mod alph;
139pub mod anim;
140pub mod anim_encode;
141pub mod anmf;
142pub mod build;
143pub mod container;
144pub mod decoder;
145pub mod demux;
146pub mod encoder;
147pub mod encoder_anim;
148pub mod encoder_vp8;
149pub mod error;
150pub mod meta_prefix;
151#[cfg(feature = "registry")]
152pub mod registry;
153pub mod riff;
154pub mod vp8_chunk;
155pub mod vp8_decode;
156pub mod vp8l;
157pub mod vp8l_chunk;
158pub mod vp8l_decode;
159pub mod vp8l_encode;
160pub mod vp8l_prefix;
161pub mod vp8l_stream;
162pub mod vp8l_transform;
163pub mod vp8x;
164
165#[cfg(feature = "registry")]
166use oxideav_core::RuntimeContext;
167
168/// Streaming [`oxideav_core::Decoder`] implementation — re-export of the
169/// in-crate [`registry::WebpDecoder`] under the published crate-root
170/// path per the published 0.1.2 surface.
171#[cfg(feature = "registry")]
172pub use registry::WebpDecoder;
173
174/// Crate-local error type.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum Error {
177    /// A code path that has not been wired up yet in this round.
178    NotImplemented,
179    /// The file is well-formed but carries an image kind this crate does
180    /// not decode yet. Currently this is the §2.5 `VP8 ` lossy
181    /// bitstream — routed out via [`extract_lossy_chunk`] to a downstream
182    /// VP8 decoder rather than decoded here.
183    Unsupported(UnsupportedKind),
184    /// The RIFF/WEBP container walker rejected the input.
185    Container(container::ContainerError),
186    /// The §2.7.1 VP8X chunk parser rejected the input.
187    Vp8x(vp8x::Vp8xError),
188    /// The §2.7.1.2 ALPH info-byte parser rejected the input.
189    Alph(alph::AlphError),
190    /// The §2.7.1.1 ANIM payload parser rejected the input.
191    Anim(anim::AnimError),
192    /// The §2.7.1.1 ANMF per-frame header parser rejected the input.
193    Anmf(anmf::AnmfError),
194    /// The §2.3 / §2.4 / §2.7.1 RIFF/WEBP builders rejected the input.
195    Build(build::BuildError),
196    /// The §2.5 typed `VP8 ` chunk handle rejected the chunk payload.
197    Lossy(vp8_chunk::WebpLossyError),
198    /// The §2.5 `VP8 ` lossy bitstream decode (delegated to the
199    /// `oxideav-vp8` sibling crate) rejected the payload.
200    ///
201    /// Wraps `oxideav-vp8`'s published [`oxideav_vp8::DecodeError`]. (Once
202    /// vp8 publishes its `Vp8Error` umbrella — currently on vp8 master
203    /// but not on crates.io — this can widen to that type.)
204    Vp8(oxideav_vp8::DecodeError),
205    /// The §2.6 typed `VP8L` chunk handle rejected the chunk payload.
206    Lossless(vp8l_chunk::WebpLosslessError),
207    /// The §4 VP8L transform-list reader rejected the bitstream.
208    Vp8lTransform(vp8l_stream::TransformListError),
209    /// The §6.2.1 VP8L prefix-code reader rejected the bitstream.
210    Vp8lPrefix(vp8l_prefix::PrefixError),
211    /// The §5.2.3 / §6.2.2 VP8L meta-prefix header reader rejected the
212    /// bitstream.
213    Vp8lMetaPrefix(meta_prefix::MetaPrefixError),
214    /// The §5.2 VP8L per-pixel ARGB decode loop rejected the bitstream.
215    Vp8lDecode(vp8l_decode::DecodeError),
216    /// The §3.7 / §3.8 VP8L lossless encoder rejected the input.
217    Vp8lEncode(vp8l_encode::EncodeError),
218}
219
220/// Which image kind [`decode_webp`] declined to decode.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum UnsupportedKind {
223    /// The file's image data is a §2.5 `VP8 ` lossy bitstream that the
224    /// caller has chosen to route out-of-crate.
225    ///
226    /// As of round 124 the default still-image decode path decodes `VP8 `
227    /// lossy via the `oxideav-vp8` sibling crate, so this variant is no
228    /// longer produced by [`decode_webp`] / [`decode_webp_image`]. It is
229    /// retained for callers that explicitly route the raw VP8 bitstream
230    /// elsewhere via [`extract_lossy_chunk`].
231    LossyVp8,
232    /// The file carries neither a `VP8L` nor a `VP8 ` image-data chunk
233    /// (e.g. an animation: the pixels live inside per-frame `ANMF`
234    /// sub-RIFFs, which this still-image entry point does not assemble).
235    NoImageData,
236}
237
238impl core::fmt::Display for UnsupportedKind {
239    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
240        match self {
241            Self::LossyVp8 => f.write_str("VP8 lossy bitstream (route to a VP8 decoder)"),
242            Self::NoImageData => {
243                f.write_str("no VP8L/VP8 image-data chunk (animation or header-only)")
244            }
245        }
246    }
247}
248
249impl core::fmt::Display for Error {
250    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
251        match self {
252            Self::NotImplemented => f.write_str("oxideav-webp: pixel decode not implemented yet"),
253            Self::Unsupported(k) => write!(f, "oxideav-webp: unsupported image kind: {k}"),
254            Self::Container(e) => write!(f, "oxideav-webp container: {e}"),
255            Self::Vp8x(e) => write!(f, "oxideav-webp vp8x: {e}"),
256            Self::Alph(e) => write!(f, "oxideav-webp alph: {e}"),
257            Self::Anim(e) => write!(f, "oxideav-webp anim: {e}"),
258            Self::Anmf(e) => write!(f, "oxideav-webp anmf: {e}"),
259            Self::Build(e) => write!(f, "oxideav-webp build: {e}"),
260            Self::Lossy(e) => write!(f, "oxideav-webp lossy: {e}"),
261            Self::Vp8(e) => write!(f, "oxideav-webp vp8: {e}"),
262            Self::Lossless(e) => write!(f, "oxideav-webp lossless: {e}"),
263            Self::Vp8lTransform(e) => write!(f, "oxideav-webp vp8l-transform: {e}"),
264            Self::Vp8lPrefix(e) => write!(f, "oxideav-webp vp8l-prefix: {e}"),
265            Self::Vp8lMetaPrefix(e) => write!(f, "oxideav-webp vp8l-meta-prefix: {e}"),
266            Self::Vp8lDecode(e) => write!(f, "oxideav-webp vp8l-decode: {e}"),
267            Self::Vp8lEncode(e) => write!(f, "oxideav-webp vp8l-encode: {e}"),
268        }
269    }
270}
271
272impl std::error::Error for Error {}
273
274impl From<container::ContainerError> for Error {
275    fn from(e: container::ContainerError) -> Self {
276        Self::Container(e)
277    }
278}
279
280impl From<vp8x::Vp8xError> for Error {
281    fn from(e: vp8x::Vp8xError) -> Self {
282        Self::Vp8x(e)
283    }
284}
285
286impl From<alph::AlphError> for Error {
287    fn from(e: alph::AlphError) -> Self {
288        Self::Alph(e)
289    }
290}
291
292impl From<anim::AnimError> for Error {
293    fn from(e: anim::AnimError) -> Self {
294        Self::Anim(e)
295    }
296}
297
298impl From<anmf::AnmfError> for Error {
299    fn from(e: anmf::AnmfError) -> Self {
300        Self::Anmf(e)
301    }
302}
303
304impl From<build::BuildError> for Error {
305    fn from(e: build::BuildError) -> Self {
306        Self::Build(e)
307    }
308}
309
310impl From<vp8_chunk::WebpLossyError> for Error {
311    fn from(e: vp8_chunk::WebpLossyError) -> Self {
312        Self::Lossy(e)
313    }
314}
315
316impl From<oxideav_vp8::DecodeError> for Error {
317    fn from(e: oxideav_vp8::DecodeError) -> Self {
318        Self::Vp8(e)
319    }
320}
321
322impl From<vp8l_chunk::WebpLosslessError> for Error {
323    fn from(e: vp8l_chunk::WebpLosslessError) -> Self {
324        Self::Lossless(e)
325    }
326}
327
328impl From<vp8l_stream::TransformListError> for Error {
329    fn from(e: vp8l_stream::TransformListError) -> Self {
330        Self::Vp8lTransform(e)
331    }
332}
333
334impl From<vp8l_prefix::PrefixError> for Error {
335    fn from(e: vp8l_prefix::PrefixError) -> Self {
336        Self::Vp8lPrefix(e)
337    }
338}
339
340impl From<meta_prefix::MetaPrefixError> for Error {
341    fn from(e: meta_prefix::MetaPrefixError) -> Self {
342        Self::Vp8lMetaPrefix(e)
343    }
344}
345
346impl From<vp8l_decode::DecodeError> for Error {
347    fn from(e: vp8l_decode::DecodeError) -> Self {
348        Self::Vp8lDecode(e)
349    }
350}
351
352impl From<vp8l_encode::EncodeError> for Error {
353    fn from(e: vp8l_encode::EncodeError) -> Self {
354        Self::Vp8lEncode(e)
355    }
356}
357
358/// Walk a `RIFF/WEBP` container per RFC 9649 §2.3–§2.7 and return
359/// the structural chunk list. This is the round-1 surface: it does
360/// not decode any payload.
361pub fn parse_container(bytes: &[u8]) -> Result<container::WebpContainer, Error> {
362    container::parse(bytes).map_err(Into::into)
363}
364
365/// Decode the §2.7.1 `VP8X` chunk payload to a typed
366/// [`vp8x::Vp8xHeader`].
367///
368/// The argument is the **payload** of a `VP8X` chunk — exactly the
369/// 10 bytes following the 8-byte chunk header. The recommended call
370/// pattern is to walk the container first, locate the chunk whose
371/// FourCC is [`container::fourcc::VP8X`], borrow its payload via
372/// [`container::WebpChunk::payload`], and hand that slice to this
373/// function.
374pub fn parse_vp8x_header(payload: &[u8]) -> Result<vp8x::Vp8xHeader, Error> {
375    vp8x::Vp8xHeader::parse(payload).map_err(Into::into)
376}
377
378/// Decode the §2.7.1.2 `ALPH` chunk info byte to a typed
379/// [`alph::AlphHeader`].
380///
381/// The argument is the **payload** of an `ALPH` chunk — i.e. the
382/// slice returned by [`container::WebpChunk::payload`] for a chunk
383/// whose FourCC is [`container::fourcc::ALPH`]. Only the first byte
384/// is consumed by this layer; the rest of the payload is the alpha
385/// bitstream proper, which is decoded by [`alph::decode_alpha`].
386pub fn parse_alph_header(payload: &[u8]) -> Result<alph::AlphHeader, Error> {
387    alph::AlphHeader::parse(payload).map_err(Into::into)
388}
389
390/// Walk a `RIFF/WEBP` buffer and, if it carries a §2.7.1.2 `ALPH`
391/// chunk, fully decode the alpha bitstream to a `width * height` plane
392/// of 8-bit alpha values in scan order.
393///
394/// The alpha-plane dimensions are taken from the file in this priority
395/// order, matching how a still image carries its canvas size:
396///
397/// 1. the §2.7.1 `VP8X` canvas dimensions, if a `VP8X` chunk exists;
398/// 2. otherwise the §2.5 `VP8 ` keyframe dimensions (a simple-lossy
399///    file with an `ALPH` chunk but no `VP8X`).
400///
401/// Returns `Ok(None)` if the file is well-formed but carries no `ALPH`
402/// chunk. The decode covers both §2.7.1.2 compression methods
403/// (raw + VP8L-lossless) and all four filtering methods — see
404/// [`alph::decode_alpha`].
405///
406/// This handles the **still-image** alpha path. Per-frame (`ANMF`)
407/// alpha planes are addressed by walking the `ANMF` frame data with
408/// [`alph::decode_alpha`] directly, using the frame dimensions.
409pub fn decode_alpha_plane(bytes: &[u8]) -> Result<Option<Vec<u8>>, Error> {
410    let c = container::parse(bytes)?;
411    let alph_chunk = match c.first_chunk_with_fourcc(container::fourcc::ALPH) {
412        Some(chunk) => chunk,
413        None => return Ok(None),
414    };
415
416    // Dimensions: VP8X canvas first, else the VP8 keyframe header.
417    let (width, height) = if let Some(vp8x) = c.first_chunk_with_fourcc(container::fourcc::VP8X) {
418        let hdr = vp8x::Vp8xHeader::parse(vp8x.payload(bytes))?;
419        (hdr.canvas_width, hdr.canvas_height)
420    } else if let Some(vp8) = c.first_chunk_with_fourcc(container::fourcc::VP8) {
421        let lossy = vp8_chunk::WebpLossyChunk::from_chunk(bytes, vp8)?;
422        (u32::from(lossy.width()), u32::from(lossy.height()))
423    } else {
424        // No dimension source — an ALPH with neither VP8X nor VP8 is
425        // not a shape RFC 9649 §2.5/§2.7 describes for still images.
426        return Err(Error::Alph(alph::AlphError::EmptyPayload));
427    };
428
429    let plane = alph::decode_alpha(alph_chunk.payload(bytes), width, height)?;
430    Ok(Some(plane))
431}
432
433/// Decode the §2.7.1.1 `ANIM` chunk payload to a typed
434/// [`anim::AnimHeader`].
435///
436/// The argument is the 6-byte chunk payload — the BGRA background
437/// colour followed by the little-endian u16 loop count.
438pub fn parse_anim_header(payload: &[u8]) -> Result<anim::AnimHeader, Error> {
439    anim::AnimHeader::parse(payload).map_err(Into::into)
440}
441
442/// Decode the §2.7.1.1 `ANMF` per-frame header to a typed
443/// [`anmf::AnmfHeader`].
444///
445/// The argument is the **payload** of an `ANMF` chunk — the slice
446/// returned by [`container::WebpChunk::payload`] for a chunk whose
447/// FourCC is [`container::fourcc::ANMF`]. Only the first 16 bytes
448/// are consumed; the remainder is the per-frame `Frame Data`
449/// sub-RIFF, which is not decoded here.
450pub fn parse_anmf_header(payload: &[u8]) -> Result<anmf::AnmfHeader, Error> {
451    anmf::AnmfHeader::parse(payload).map_err(Into::into)
452}
453
454/// Assemble a `RIFF/WEBP` file around a single bitstream payload per
455/// RFC 9649 §2.4 + §2.5 / §2.6 / §2.7. Convenience wrapper over
456/// [`build::build_webp_file`] returning the crate-wide [`Error`].
457pub fn build_webp_file(
458    payload: &[u8],
459    image_kind: build::ImageKind,
460    canvas_width: u32,
461    canvas_height: u32,
462) -> Result<Vec<u8>, Error> {
463    build::build_webp_file(payload, image_kind, canvas_width, canvas_height).map_err(Into::into)
464}
465
466/// Build the 10-byte §2.7.1 `VP8X` chunk payload (flags + reserved +
467/// canvas dims). Convenience wrapper over [`build::build_vp8x_chunk`]
468/// returning the crate-wide [`Error`].
469pub fn build_vp8x_chunk(
470    canvas_width: u32,
471    canvas_height: u32,
472    flags: build::Vp8xFlags,
473) -> Result<Vec<u8>, Error> {
474    build::build_vp8x_chunk(canvas_width, canvas_height, flags).map_err(Into::into)
475}
476
477/// Walk a `RIFF/WEBP` buffer and, if it carries a §2.5 simple-lossy
478/// `VP8 ` chunk (or a §2.7 extended-lossy file with a `VP8 ` chunk
479/// alongside `VP8X`), return a typed [`vp8_chunk::WebpLossyChunk`]
480/// handle whose [`bitstream`](vp8_chunk::WebpLossyChunk::bitstream)
481/// slice can be routed to an external VP8 decoder.
482///
483/// Returns `Ok(None)` if the file is well-formed but carries no
484/// `VP8 ` chunk (e.g. a `VP8L`-only simple-lossless file).
485///
486/// The returned handle borrows out of `bytes`, so the slice must
487/// outlive the handle.
488///
489/// This is the round-6 routing API — `oxideav-webp` deliberately
490/// does **not** take a runtime dependency on `oxideav-vp8`; the
491/// caller picks which VP8 decoder consumes the borrowed payload.
492pub fn extract_lossy_chunk(bytes: &[u8]) -> Result<Option<vp8_chunk::WebpLossyChunk<'_>>, Error> {
493    let c = container::parse(bytes)?;
494    vp8_chunk::extract_lossy(bytes, &c).map_err(Into::into)
495}
496
497/// Walk a `RIFF/WEBP` buffer and, if it carries a §2.6 simple-lossless
498/// `VP8L` chunk (or a §2.7 extended-lossless file with a `VP8L` chunk
499/// alongside `VP8X`), return a typed [`vp8l_chunk::WebpLosslessChunk`]
500/// handle whose [`bitstream`](vp8l_chunk::WebpLosslessChunk::bitstream)
501/// slice can be routed to an external VP8L decoder.
502///
503/// Returns `Ok(None)` if the file is well-formed but carries no
504/// `VP8L` chunk (e.g. a `VP8 `-only simple-lossy file).
505///
506/// The returned handle borrows out of `bytes`, so the slice must
507/// outlive the handle.
508///
509/// This is the round-7 routing API — `oxideav-webp` deliberately
510/// does **not** take a runtime dependency on a VP8L decoder; the
511/// caller picks which lossless-WebP decoder consumes the borrowed
512/// payload.
513pub fn extract_lossless_chunk(
514    bytes: &[u8],
515) -> Result<Option<vp8l_chunk::WebpLosslessChunk<'_>>, Error> {
516    let c = container::parse(bytes)?;
517    vp8l_chunk::extract_lossless(bytes, &c).map_err(Into::into)
518}
519
520/// Walk a `RIFF/WEBP` buffer, extract its §2.6 / §3.4 `VP8L` chunk,
521/// and read the §4 transform-presence list that follows the 5-byte
522/// VP8L image-header.
523///
524/// Returns `Ok(None)` if the file carries no `VP8L` chunk. Otherwise
525/// returns the parsed [`vp8l_stream::TransformList`] — the transforms
526/// in read order plus the bit position where the §5 entropy-coded
527/// image data (or the first transform's §5 body) begins.
528///
529/// This is the round-99 surface: it reads each transform's leading
530/// fixed-size fields (predictor / color `size_bits`, color-indexing
531/// `color_table_size`) but does **not** decode the §5 entropy-coded
532/// transform bodies or image data — those are returned-to boundaries
533/// for the next layer.
534pub fn read_vp8l_transform_list(bytes: &[u8]) -> Result<Option<vp8l_stream::TransformList>, Error> {
535    let c = container::parse(bytes)?;
536    let chunk = match vp8l_chunk::extract_lossless(bytes, &c)? {
537        Some(chunk) => chunk,
538        None => return Ok(None),
539    };
540    let mut reader = vp8l_stream::BitReader::new_after_image_header(chunk.bitstream());
541    let list = vp8l_stream::TransformList::read(&mut reader)?;
542    Ok(Some(list))
543}
544
545/// Walk a `RIFF/WEBP` buffer, extract its §2.6 / §3.4 `VP8L` chunk, and
546/// fully decode it to ARGB pixels.
547///
548/// This runs the round-108 §5/§6 entropy decode of the main ARGB image
549/// then applies the round-109 §4 inverse-transform chain
550/// ([`vp8l_transform::decode_lossless`]): predictor, color, subtract-green,
551/// and color-indexing, applied in reverse of the order the transforms
552/// were read.
553///
554/// Returns `Ok(None)` if the file carries no `VP8L` chunk. Otherwise the
555/// returned [`vp8l_decode::DecodedImage`] holds `width * height` ARGB
556/// pixels in scan-line order, each `(alpha << 24) | (red << 16) |
557/// (green << 8) | blue`.
558pub fn decode_lossless_image(bytes: &[u8]) -> Result<Option<vp8l_decode::DecodedImage>, Error> {
559    let c = container::parse(bytes)?;
560    let chunk = match vp8l_chunk::extract_lossless(bytes, &c)? {
561        Some(chunk) => chunk,
562        None => return Ok(None),
563    };
564    let width = chunk.width();
565    let height = chunk.height();
566    let image = vp8l_transform::decode_lossless(chunk.bitstream(), width, height)?;
567    Ok(Some(image))
568}
569
570/// §3.4 still-image dimension ceiling (16384 = `1 << 14`), the per-side
571/// maximum a §2.6 `VP8L` image header can encode (the 14-bit
572/// `width - 1` / `height - 1` fields plus one). Used as the eager-
573/// allocation bound on the §2.7.1.1 animation canvas: a `VP8X` canvas
574/// dimension above this can never be fully covered by a spec-valid
575/// `ANMF` sub-frame (each sub-frame is itself a `VP8L` image), so it is
576/// rejected before the full-canvas buffer is allocated rather than
577/// trusting the much larger §2.7.1 24-bit-per-side / 2^32-1-product cap.
578const MAX_DECODE_DIMENSION: u32 = 1 << 14;
579
580/// A fully decoded still WebP image: 8-bit RGBA pixels plus dimensions.
581///
582/// `rgba` is `width * height * 4` bytes in scan-line (top-to-bottom,
583/// left-to-right) order, each pixel laid out `[R, G, B, A]`. This is the
584/// canonical interleaved-RGBA surface
585/// (`oxideav_core::PixelFormat::Rgba`) the workspace's image crates
586/// emit, so a `VideoFrame` wrapper is a single 1-plane copy away.
587#[derive(Debug, Clone, PartialEq, Eq)]
588pub struct DecodedWebp {
589    /// Image width in pixels (the §2.7.1 `VP8X` canvas width, or the
590    /// §3.4 `VP8L` image width for a simple-lossless file).
591    pub width: u32,
592    /// Image height in pixels.
593    pub height: u32,
594    /// `width * height * 4` interleaved `[R, G, B, A]` bytes, scan order.
595    pub rgba: Vec<u8>,
596}
597
598/// Decode a still WebP file to a typed [`DecodedWebp`] (RGBA + dims).
599///
600/// Handles the two cases this crate can fully decode today:
601///
602/// 1. **Simple lossless** — a §2.6 `VP8L` chunk (optionally fronted by a
603///    §2.7.1 `VP8X` header): decoded to ARGB via
604///    [`vp8l_transform::decode_lossless`], with alpha carried inside the
605///    `VP8L` bitstream itself.
606/// 2. **Extended lossless** — a §2.7 `VP8X` file whose image data is a
607///    `VP8L` chunk. If the (spec-discouraged, per RFC 9649 §2.7.1.2) case
608///    of an accompanying §2.7.1.2 `ALPH` chunk is present, its decoded
609///    alpha plane overrides the per-pixel alpha channel.
610///
611/// As of round 124 a §2.5 `VP8 ` lossy bitstream is also decoded here —
612/// the `VP8 ` chunk payload is routed to the `oxideav-vp8` sibling crate
613/// ([`vp8_decode::decode_lossy_rgba`]) and a §2.7.1.2 `ALPH` chunk, when
614/// present, overrides the opaque alpha channel. (The standalone
615/// [`extract_lossy_chunk`] routing API remains available for callers that
616/// want the raw VP8 bitstream slice.)
617///
618/// Animations and header-only files (no `VP8L`/`VP8 ` chunk) return
619/// [`Error::Unsupported`]`(`[`UnsupportedKind::NoImageData`]`)`.
620pub fn decode_webp_image(bytes: &[u8]) -> Result<DecodedWebp, Error> {
621    let c = container::parse(bytes)?;
622
623    // §2.6 / §3.4: the VP8L lossless image. If absent, fall back to the
624    // §2.5 `VP8 ` lossy path (decoded via the `oxideav-vp8` sibling crate),
625    // and only then to "no image data".
626    let vp8l = vp8l_chunk::extract_lossless(bytes, &c)?;
627    let Some(chunk) = vp8l else {
628        // No VP8L. A §2.5 `VP8 ` lossy chunk is decoded through
629        // `oxideav-vp8` (round 124); anything else has no still-image
630        // pixel data.
631        if let Some(vp8) = c.first_chunk_with_fourcc(container::fourcc::VP8) {
632            return decode_lossy_image(bytes, &c, vp8);
633        }
634        return Err(Error::Unsupported(UnsupportedKind::NoImageData));
635    };
636
637    let width = chunk.width();
638    let height = chunk.height();
639    let mut image = vp8l_transform::decode_lossless(chunk.bitstream(), width, height)?;
640
641    // §2.7.1.2: an ALPH chunk alongside a VP8L image is discouraged by
642    // the spec ("A frame containing a 'VP8L' Chunk SHOULD NOT contain
643    // this chunk"), but is not forbidden. When present, its decoded alpha
644    // plane overrides the VP8L per-pixel alpha. The plane dimensions come
645    // from the VP8X canvas, which for a well-formed file equals the VP8L
646    // image dimensions.
647    if let Some(alph) = c.first_chunk_with_fourcc(container::fourcc::ALPH) {
648        let plane = alph::decode_alpha(alph.payload(bytes), width, height)?;
649        let pixels = image.pixels_mut();
650        if plane.len() == pixels.len() {
651            for (px, &a) in pixels.iter_mut().zip(plane.iter()) {
652                *px = (*px & 0x00ff_ffff) | (u32::from(a) << 24);
653            }
654        }
655    }
656
657    Ok(DecodedWebp {
658        width,
659        height,
660        rgba: argb_to_rgba(image.pixels()),
661    })
662}
663
664/// Decode a §2.5 `VP8 ` lossy chunk (simple-lossy or `VP8X`-extended
665/// lossy) to a [`DecodedWebp`].
666///
667/// The `VP8 ` payload is routed to the `oxideav-vp8` sibling crate's
668/// [`oxideav_vp8::decode_vp8`] entry point (round 124) via
669/// [`vp8_decode::decode_lossy_rgba`], which reconstructs the I420
670/// key-frame and converts it to interleaved RGBA. A §2.7.1.2 `ALPH`
671/// chunk alongside the `VP8 ` image (the §2.7 extended-lossy + alpha
672/// shape, e.g. `lossy-with-alpha-128x128.webp`) overrides the
673/// opaque-filled alpha channel with the decoded alpha plane.
674fn decode_lossy_image(
675    bytes: &[u8],
676    c: &container::WebpContainer,
677    vp8: &container::WebpChunk,
678) -> Result<DecodedWebp, Error> {
679    let (width, height, mut rgba) = vp8_decode::decode_lossy_rgba(vp8.payload(bytes))?;
680
681    // §2.7.1.2: an ALPH chunk alongside a VP8 lossy image carries the
682    // alpha plane (the VP8 bitstream itself is opaque YUV). Override the
683    // opaque-filled alpha with the decoded plane when the dimensions match.
684    if let Some(alph) = c.first_chunk_with_fourcc(container::fourcc::ALPH) {
685        let plane = alph::decode_alpha(alph.payload(bytes), width, height)?;
686        if plane.len() == (width as usize) * (height as usize) {
687            for (px, &a) in rgba.chunks_exact_mut(4).zip(plane.iter()) {
688                px[3] = a;
689            }
690        }
691    }
692
693    Ok(DecodedWebp {
694        width,
695        height,
696        rgba,
697    })
698}
699
700// ─────────────────────── Published-shape decode API ───────────────────────
701//
702// The free `decode_webp` path the published crates.io releases exposed —
703// the flat, `image`-crate-compatible RGBA surface downstream consumers
704// depend on. The `WebpImage` / `WebpFrame` /
705// `WebpFileMetadata` / `WebpError` shapes here are the published shapes;
706// the round-115 `DecodedWebp` / `decode_webp_image` / `decode_lossless_image`
707// helpers above are the rebuild's own low-level surface and stay as
708// additional API.
709
710/// A fully decoded WebP file: one frame for a still image, N frames for an
711/// animation, plus the file-level metadata and animation parameters.
712///
713/// This is the published-API decode result. The single most important
714/// consumer property is the flat-buffer shape of each [`WebpFrame::rgba`]:
715/// `width * height * 4` tightly packed `[R, G, B, A]` bytes, no per-row
716/// stride padding, so it drops straight into
717/// `image::ImageBuffer::from_raw(width, height, rgba)`.
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub struct WebpImage {
720    /// Canvas width in pixels (the §2.7.1 `VP8X` canvas width for an
721    /// extended file, or the §3.4 / RFC 6386 §9.1 image-header width
722    /// for a simple-lossless / simple-lossy file). Matches the first
723    /// frame's width for a single-frame image.
724    pub width: u32,
725    /// Canvas height in pixels — see [`Self::width`] for the spec
726    /// citation.
727    pub height: u32,
728    /// Decoded frames. A still image yields exactly one frame; an
729    /// animation yields one per `ANMF` chunk (animation decode is not
730    /// rebuilt yet — see [`decode_webp`]).
731    pub frames: Vec<WebpFrame>,
732    /// File-level metadata (ICC / Exif / XMP), each `None` when absent.
733    pub metadata: WebpFileMetadata,
734    /// The §2.7.1.1 `ANIM` background colour as `[R, G, B, A]`, or `None`
735    /// for a non-animated file.
736    pub anim_background_rgba: Option<[u8; 4]>,
737    /// The §2.7.1.1 `ANIM` loop count (`0` = loop forever), or `None` for
738    /// a non-animated file.
739    pub anim_loop_count: Option<u16>,
740}
741
742/// A single decoded WebP frame: a flat RGBA pixel buffer plus its size
743/// and (for animations) its display duration.
744#[derive(Debug, Clone, PartialEq, Eq)]
745pub struct WebpFrame {
746    /// `width * height * 4` interleaved `[R, G, B, A]` bytes in scan-line
747    /// (top-to-bottom, left-to-right) order, no stride padding.
748    pub rgba: Vec<u8>,
749    /// Frame width in pixels.
750    pub width: u32,
751    /// Frame height in pixels.
752    pub height: u32,
753    /// Per-frame display duration in milliseconds (the §2.7.1.1 `ANMF`
754    /// frame delay). `0` for a still image.
755    pub duration_ms: u32,
756}
757
758/// File-level metadata chunks, each carrying the raw chunk payload bytes
759/// when present.
760#[derive(Debug, Clone, Default, PartialEq, Eq)]
761pub struct WebpFileMetadata {
762    /// §2.7.1.4 `ICCP` ICC color-profile payload, if present.
763    pub icc: Option<Vec<u8>>,
764    /// §2.7.1.5 `EXIF` Exif payload, if present.
765    pub exif: Option<Vec<u8>>,
766    /// §2.7.1.5 `XMP ` XMP payload, if present.
767    pub xmp: Option<Vec<u8>>,
768}
769
770/// Borrowed file-level metadata for the encode side — the §2.7.1.4 `ICCP`,
771/// §2.7.1.5 `EXIF`, and §2.7.1.5 `XMP ` payloads to embed, each `None` to
772/// omit the corresponding chunk.
773///
774/// This is the borrowed form: the slices are not copied until the encoder
775/// frames them. The owned counterpart is [`WebpMetadataOwned`]. The default
776/// is all-`None` — embed no metadata — so a `VP8L` encode with
777/// `WebpMetadata::default()` emits the simple (non-`VP8X`) layout.
778#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
779pub struct WebpMetadata<'a> {
780    /// §2.7.1.4 `ICCP` ICC color-profile payload to embed, if any.
781    pub icc: Option<&'a [u8]>,
782    /// §2.7.1.5 `EXIF` Exif payload to embed, if any.
783    pub exif: Option<&'a [u8]>,
784    /// §2.7.1.5 `XMP ` XMP payload to embed, if any.
785    pub xmp: Option<&'a [u8]>,
786}
787
788impl<'a> WebpMetadata<'a> {
789    /// True if every field is `None` — encoding can stay on the simple
790    /// (non-`VP8X`) layout when no alpha is present either.
791    pub fn is_empty(&self) -> bool {
792        self.icc.is_none() && self.exif.is_none() && self.xmp.is_none()
793    }
794}
795
796/// Owned file-level metadata — the registry-side counterpart of the borrowed
797/// [`WebpMetadata`].
798///
799/// Carries owned `Vec<u8>` payloads so it can be stored on an encoder /
800/// codec-parameters struct without borrowing the caller's buffers. Convert
801/// to the borrowed form for an encode call with [`Self::as_borrowed`].
802#[derive(Debug, Clone, Default, PartialEq, Eq)]
803pub struct WebpMetadataOwned {
804    /// §2.7.1.4 `ICCP` ICC color-profile payload to embed, if any.
805    pub icc: Option<Vec<u8>>,
806    /// §2.7.1.5 `EXIF` Exif payload to embed, if any.
807    pub exif: Option<Vec<u8>>,
808    /// §2.7.1.5 `XMP ` XMP payload to embed, if any.
809    pub xmp: Option<Vec<u8>>,
810}
811
812impl WebpMetadataOwned {
813    /// Borrow this owned metadata as a [`WebpMetadata`] for an encode call.
814    pub fn as_borrowed(&self) -> WebpMetadata<'_> {
815        WebpMetadata {
816            icc: self.icc.as_deref(),
817            exif: self.exif.as_deref(),
818            xmp: self.xmp.as_deref(),
819        }
820    }
821
822    /// True if every field is `None`.
823    pub fn is_empty(&self) -> bool {
824        self.icc.is_none() && self.exif.is_none() && self.xmp.is_none()
825    }
826}
827
828impl From<WebpMetadataOwned> for WebpFileMetadata {
829    fn from(m: WebpMetadataOwned) -> Self {
830        WebpFileMetadata {
831            icc: m.icc,
832            exif: m.exif,
833            xmp: m.xmp,
834        }
835    }
836}
837
838/// The published-API error type for the flat [`decode_webp`] /
839/// [`extract_metadata`] decode paths.
840///
841/// This is intentionally coarse-grained — the stable shape downstream
842/// consumers match on. The internal [`Error`] enum (with its per-module
843/// variants) remains the richer surface for the low-level
844/// [`decode_webp_image`] / [`decode_lossless_image`] helpers; it maps into
845/// `WebpError` via the `From<Error>` impl.
846#[derive(Debug, Clone, PartialEq, Eq)]
847pub enum WebpError {
848    /// The input is not a well-formed WebP file (bad magic, malformed
849    /// chunk structure, a sub-decoder rejected the bitstream, …).
850    InvalidData,
851    /// The file is well-formed but carries an image kind this build does
852    /// not decode yet — currently the §2.5 `VP8 ` lossy bitstream and
853    /// animation frame assembly.
854    Unsupported,
855    /// The input ended before a complete image could be read.
856    Eof,
857    /// More input is required to complete the decode (streaming callers).
858    NeedMore,
859}
860
861impl core::fmt::Display for WebpError {
862    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
863        let s = match self {
864            Self::InvalidData => "oxideav-webp: invalid WebP data",
865            Self::Unsupported => "oxideav-webp: unsupported WebP feature",
866            Self::Eof => "oxideav-webp: unexpected end of input",
867            Self::NeedMore => "oxideav-webp: more input required",
868        };
869        f.write_str(s)
870    }
871}
872
873impl std::error::Error for WebpError {}
874
875impl WebpError {
876    /// Build an `InvalidData` variant from any string-like message.
877    ///
878    /// The 0.1.2 published `WebpError::InvalidData` carried a `String`
879    /// payload; the current rebuild collapses every malformed-bitstream
880    /// failure to the unit variant so the historical *constructor* shape
881    /// `WebpError::invalid("message")` keeps compiling. The message
882    /// itself is discarded; callers that need the underlying diagnostic
883    /// can match on the richer in-crate [`Error`] instead.
884    pub fn invalid<S: Into<String>>(_msg: S) -> Self {
885        // The message is intentionally dropped — see the doc comment for
886        // why the unit-variant rebuild surfaces the constructor only.
887        Self::InvalidData
888    }
889
890    /// Build an `Unsupported` variant from any string-like message — the
891    /// constructor counterpart of [`Self::invalid`].
892    pub fn unsupported<S: Into<String>>(_msg: S) -> Self {
893        Self::Unsupported
894    }
895}
896
897/// Map the rich internal [`Error`] onto the coarse published [`WebpError`].
898///
899/// `Unsupported` / `NotImplemented` collapse to [`WebpError::Unsupported`];
900/// every other variant (a malformed container or a sub-decoder rejecting
901/// the bitstream) is [`WebpError::InvalidData`].
902impl From<Error> for WebpError {
903    fn from(e: Error) -> Self {
904        match e {
905            Error::Unsupported(_) | Error::NotImplemented => WebpError::Unsupported,
906            _ => WebpError::InvalidData,
907        }
908    }
909}
910
911/// Map an `oxideav-vp8` decode failure onto the coarse published
912/// [`WebpError`].
913///
914/// The `oxideav-vp8` decoder refuses an inter-frame
915/// ([`oxideav_vp8::DecodeError::Unsupported`]), which collapses to
916/// [`WebpError::Unsupported`]. Every other decode failure — a malformed
917/// frame header, truncated partition, bad token stream — is a bitstream
918/// problem and maps to [`WebpError::InvalidData`].
919///
920/// Note: the published surface specifies a `From<oxideav_vp8::Vp8Error>` adapter
921/// (the umbrella type), but `Vp8Error` is not yet published on crates.io
922/// (it landed on vp8 master after the v0.2.0 tag). This `DecodeError`
923/// adapter covers the live decode path against the published 0.2.0 API;
924/// the `Vp8Error` adapter is a follow-up once vp8 publishes it.
925impl From<oxideav_vp8::DecodeError> for WebpError {
926    fn from(e: oxideav_vp8::DecodeError) -> Self {
927        match e {
928            oxideav_vp8::DecodeError::Unsupported(_) => WebpError::Unsupported,
929            _ => WebpError::InvalidData,
930        }
931    }
932}
933
934/// Map the `oxideav-vp8` umbrella [`oxideav_vp8::Vp8Error`] onto the
935/// coarse published [`WebpError`].
936///
937/// The four variants share names with [`WebpError`] so the mapping is a
938/// straight 1-to-1 collapse — the `String` payloads on
939/// `InvalidData` / `Unsupported` are dropped (the unit-variant rebuild
940/// surfaces the variant only). Wired up against `oxideav-vp8 0.2.1`
941/// (the release that first exports `Vp8Error` at the crate root).
942///
943/// The
944/// compile-time signature assertion lives in
945/// `tests/api_compat_0_1_2.rs::crate_root_webp_error_from_vp8_error`.
946impl From<oxideav_vp8::Vp8Error> for WebpError {
947    fn from(e: oxideav_vp8::Vp8Error) -> Self {
948        match e {
949            oxideav_vp8::Vp8Error::InvalidData(_) => WebpError::InvalidData,
950            oxideav_vp8::Vp8Error::Unsupported(_) => WebpError::Unsupported,
951            oxideav_vp8::Vp8Error::Eof => WebpError::Eof,
952            oxideav_vp8::Vp8Error::NeedMore => WebpError::NeedMore,
953        }
954    }
955}
956
957/// Decode a WebP file to the published flat-RGBA [`WebpImage`] shape.
958///
959/// This is the `image`-crate-compatible entry point: every returned
960/// [`WebpFrame::rgba`] is `width * height * 4` tightly packed `[R, G, B, A]`
961/// bytes (no stride padding), so a frame wraps zero-copy as
962/// `image::ImageBuffer::from_raw(frame.width, frame.height, frame.rgba)`.
963///
964/// Supported today (built on the rebuilt §4–§6 VP8L decoder + the
965/// `oxideav-vp8` lossy decoder):
966///
967/// * **Simple / extended lossless** (`VP8L`, optionally `VP8X`-fronted,
968///   with optional `ALPH`-over-`VP8L` alpha) → a single-frame `WebpImage`.
969/// * **Simple / extended lossy** (`VP8 `, optionally `VP8X`-fronted, with
970///   optional `ALPH`-over-`VP8 ` alpha) → a single-frame `WebpImage`,
971///   decoded through `oxideav-vp8` (round 124).
972///
973/// Not yet rebuilt (returns [`WebpError::Unsupported`], never a fake
974/// decode):
975///
976/// * **Animation** — `ANMF` frame assembly with `VP8 ` lossy sub-chunks
977///   (lossless `ANMF` frames decode).
978///
979/// The low-level [`decode_webp_image`] / [`decode_lossless_image`] helpers
980/// expose the rebuild's internal [`DecodedWebp`] / [`vp8l_decode::DecodedImage`]
981/// surfaces for callers that want them.
982pub fn decode_webp(bytes: &[u8]) -> Result<WebpImage, WebpError> {
983    // Parse the container once so we can read both pixels and metadata.
984    let c = container::parse(bytes).map_err(|_| WebpError::InvalidData)?;
985
986    // Animated file: an ANIM chunk introduces a sequence of ANMF frames.
987    // Decode every frame's VP8L-lossless bitstream into a separate WebpFrame.
988    if c.first_chunk_with_fourcc(container::fourcc::ANIM).is_some() {
989        return decode_animation(bytes, &c);
990    }
991
992    // Pixel data: the rebuilt path decodes VP8L (simple or extended).
993    // VP8 lossy and animation are reported Unsupported, not faked.
994    let decoded = decode_webp_image(bytes).map_err(WebpError::from)?;
995
996    let frame = WebpFrame {
997        rgba: decoded.rgba,
998        width: decoded.width,
999        height: decoded.height,
1000        duration_ms: 0,
1001    };
1002
1003    let metadata = metadata_from_container(bytes, &c);
1004
1005    // Non-animated file: ANIM fields are None. (Animation assembly is not
1006    // rebuilt yet, so any animated file already errored Unsupported above
1007    // via the NoImageData path.)
1008    Ok(WebpImage {
1009        width: frame.width,
1010        height: frame.height,
1011        frames: vec![frame],
1012        metadata,
1013        anim_background_rgba: None,
1014        anim_loop_count: None,
1015    })
1016}
1017
1018/// Decode an animated WebP (`ANIM` + per-frame `ANMF`) to a multi-frame
1019/// [`WebpImage`], compositing each frame's sub-rectangle onto a shared
1020/// canvas per RFC 9649 §2.7.1.1.
1021///
1022/// Each §2.7.1.1 `ANMF` chunk carries a 16-byte header
1023/// ([`anmf::AnmfHeader`]) followed by its "Frame Data" — a padded §2.3
1024/// sub-RIFF holding the frame's bitstream. This decoder handles the
1025/// §2.6 `VP8L` lossless sub-chunk (the path the in-crate animation encoder
1026/// produces); an `ANMF` carrying only a §2.5 `VP8 ` lossy sub-chunk is
1027/// [`WebpError::Unsupported`] (the VP8 lossy decoder is not rebuilt yet).
1028///
1029/// **Canvas compositing (round 127):** the canvas is sized from the
1030/// §2.7.1 `VP8X` chunk and initialised to the §2.7.1.1 `ANIM`
1031/// `Background Color`. Each frame's pixels are then placed at its
1032/// `(Frame X, Frame Y)` offset with the §2.7.1.1 disposal/blending
1033/// rules:
1034///
1035/// * Before drawing a frame, the **previous** frame's disposal method
1036///   is applied (only to that previous frame's sub-rectangle). `None`
1037///   leaves the canvas as is; `Background` fills the previous rect with
1038///   the `ANIM` background colour.
1039/// * The current frame is then drawn into its rect using its blending
1040///   method: `Overwrite` replaces the rect's pixels byte-for-byte;
1041///   `AlphaBlend` composites RGBA over the existing canvas using the
1042///   §2.7.1.1 "Alpha-blending" formula
1043///   `blend.A = src.A + dst.A * (1 - src.A / 255)` (8-bit integer
1044///   approximation, no gamma linearisation).
1045///
1046/// The per-frame `Frame Duration` populates each
1047/// [`WebpFrame::duration_ms`]; `width` / `height` on each returned
1048/// frame are the **canvas** dimensions (every frame is a full-canvas
1049/// snapshot after rendering — what an animation player would display).
1050/// The §2.7.1.1 `ANIM` background colour and loop count populate
1051/// [`WebpImage::anim_background_rgba`] / [`WebpImage::anim_loop_count`].
1052fn decode_animation(bytes: &[u8], c: &container::WebpContainer) -> Result<WebpImage, WebpError> {
1053    // §2.7.1.1 ANIM: global background colour + loop count.
1054    let anim_chunk = c
1055        .first_chunk_with_fourcc(container::fourcc::ANIM)
1056        .ok_or(WebpError::InvalidData)?;
1057    let anim =
1058        anim::AnimHeader::parse(anim_chunk.payload(bytes)).map_err(|_| WebpError::InvalidData)?;
1059    let bg = anim.background_color;
1060
1061    // §2.7.1 VP8X canvas dimensions — the canvas every ANMF composites onto.
1062    let vp8x_chunk = c
1063        .first_chunk_with_fourcc(container::fourcc::VP8X)
1064        .ok_or(WebpError::InvalidData)?;
1065    let vp8x =
1066        vp8x::Vp8xHeader::parse(vp8x_chunk.payload(bytes)).map_err(|_| WebpError::InvalidData)?;
1067    let canvas_w = vp8x.canvas_width;
1068    let canvas_h = vp8x.canvas_height;
1069
1070    // §2.7.1 permits a `VP8X` canvas up to 2^24 per side (product capped at
1071    // 2^32 - 1). That spec cap is far larger than is safe to pre-allocate:
1072    // a ~480-byte file declaring a 16 777 154 × 64 canvas demands a ~4 GiB
1073    // buffer below before a single frame is decoded. Every §2.7.1.1 `ANMF`
1074    // sub-frame is itself a §2.6 `VP8L` image, whose §3.4 dimensions are
1075    // capped at 16384 per side — so a canvas exceeding that in either
1076    // dimension can never be fully covered by a spec-valid frame and is
1077    // rejected here rather than eagerly allocated. This bounds the canvas
1078    // buffer at the §3.4 still-image ceiling (16384 × 16384 × 4 = 1 GiB).
1079    if canvas_w > MAX_DECODE_DIMENSION || canvas_h > MAX_DECODE_DIMENSION {
1080        return Err(WebpError::InvalidData);
1081    }
1082
1083    // Initialise canvas to the ANIM background colour (RGBA, scan order).
1084    let bg_rgba = [bg.red, bg.green, bg.blue, bg.alpha];
1085    let canvas_bytes = canvas_w
1086        .checked_mul(canvas_h)
1087        .and_then(|n| n.checked_mul(4))
1088        .ok_or(WebpError::InvalidData)? as usize;
1089    let mut canvas: Vec<u8> = Vec::with_capacity(canvas_bytes);
1090    for _ in 0..(canvas_bytes / 4) {
1091        canvas.extend_from_slice(&bg_rgba);
1092    }
1093
1094    // Track the previous frame's rect + dispose for the §2.7.1.1
1095    // "Before rendering each frame, the previous frame's Disposal method
1096    // is applied" rule.
1097    let mut prev_rect: Option<(u32, u32, u32, u32, anmf::DisposalMethod)> = None;
1098
1099    let mut frames = Vec::new();
1100    for anmf_chunk in c.chunks_with_fourcc(container::fourcc::ANMF) {
1101        let payload = anmf_chunk.payload(bytes);
1102        let header = anmf::AnmfHeader::parse(payload).map_err(|_| WebpError::InvalidData)?;
1103        let frame_data = &payload[header.frame_data_offset()..];
1104
1105        // The Frame Data sub-RIFF is a flat list of §2.3 padded chunks. Find
1106        // the VP8L bitstream sub-chunk (lossy VP8 is not decoded here).
1107        let vp8l = find_subchunk(frame_data, container::fourcc::VP8L);
1108        let Some(vp8l_payload) = vp8l else {
1109            // A VP8 lossy sub-chunk is recognized-but-unsupported.
1110            if find_subchunk(frame_data, container::fourcc::VP8).is_some() {
1111                return Err(WebpError::Unsupported);
1112            }
1113            return Err(WebpError::InvalidData);
1114        };
1115
1116        let chunk = vp8l_chunk::WebpLosslessChunk::from_payload(vp8l_payload)
1117            .map_err(|e| WebpError::from(Error::from(e)))?;
1118        let sub_w = chunk.width();
1119        let sub_h = chunk.height();
1120        let image = vp8l_transform::decode_lossless(chunk.bitstream(), sub_w, sub_h)
1121            .map_err(|e| WebpError::from(Error::from(e)))?;
1122
1123        // An optional ALPH sub-chunk overrides the VP8L per-pixel alpha.
1124        let mut pixels = image;
1125        if let Some(alph_payload) = find_subchunk(frame_data, container::fourcc::ALPH) {
1126            if let Ok(plane) = alph::decode_alpha(alph_payload, sub_w, sub_h) {
1127                let px = pixels.pixels_mut();
1128                if plane.len() == px.len() {
1129                    for (p, &a) in px.iter_mut().zip(plane.iter()) {
1130                        *p = (*p & 0x00ff_ffff) | (u32::from(a) << 24);
1131                    }
1132                }
1133            }
1134        }
1135        let sub_rgba = argb_to_rgba(pixels.pixels());
1136
1137        // §2.7.1.1: "Before rendering each frame, the previous frame's
1138        // Disposal method is applied" — clears the previous rect to bg
1139        // for dispose=Background; no-op for dispose=None.
1140        if let Some((px, py, pw, ph, anmf::DisposalMethod::Background)) = prev_rect {
1141            fill_canvas_rect(&mut canvas, canvas_w, px, py, pw, ph, bg_rgba);
1142        }
1143
1144        // §2.7.1.1: the frame must fit inside the canvas. Reject any
1145        // frame that overflows the canvas — that's a malformed file.
1146        let right = header.x.checked_add(sub_w).ok_or(WebpError::InvalidData)?;
1147        let bottom = header.y.checked_add(sub_h).ok_or(WebpError::InvalidData)?;
1148        if right > canvas_w || bottom > canvas_h {
1149            return Err(WebpError::InvalidData);
1150        }
1151
1152        // Draw the current frame into its rect using its blending method.
1153        match header.blend {
1154            anmf::BlendingMethod::Overwrite => {
1155                blit_rect_overwrite(
1156                    &mut canvas,
1157                    canvas_w,
1158                    header.x,
1159                    header.y,
1160                    sub_w,
1161                    sub_h,
1162                    &sub_rgba,
1163                );
1164            }
1165            anmf::BlendingMethod::AlphaBlend => {
1166                blit_rect_alpha_blend(
1167                    &mut canvas,
1168                    canvas_w,
1169                    header.x,
1170                    header.y,
1171                    sub_w,
1172                    sub_h,
1173                    &sub_rgba,
1174                );
1175            }
1176        }
1177
1178        // Snapshot the full canvas as this frame's display state.
1179        frames.push(WebpFrame {
1180            rgba: canvas.clone(),
1181            width: canvas_w,
1182            height: canvas_h,
1183            duration_ms: header.duration_ms,
1184        });
1185
1186        prev_rect = Some((header.x, header.y, sub_w, sub_h, header.dispose));
1187    }
1188
1189    if frames.is_empty() {
1190        return Err(WebpError::InvalidData);
1191    }
1192
1193    Ok(WebpImage {
1194        width: canvas_w,
1195        height: canvas_h,
1196        frames,
1197        metadata: metadata_from_container(bytes, c),
1198        anim_background_rgba: Some([bg.red, bg.green, bg.blue, bg.alpha]),
1199        anim_loop_count: Some(anim.loop_count),
1200    })
1201}
1202
1203/// Fill an axis-aligned rectangle of `canvas` with `rgba`. Bounds are
1204/// pre-validated by the caller (`x + w <= canvas_w`).
1205fn fill_canvas_rect(
1206    canvas: &mut [u8],
1207    canvas_w: u32,
1208    x: u32,
1209    y: u32,
1210    w: u32,
1211    h: u32,
1212    rgba: [u8; 4],
1213) {
1214    let canvas_w = canvas_w as usize;
1215    let cw_bytes = canvas_w * 4;
1216    let x = x as usize;
1217    let y = y as usize;
1218    let w = w as usize;
1219    let h = h as usize;
1220    for row in 0..h {
1221        let off = (y + row) * cw_bytes + x * 4;
1222        for col in 0..w {
1223            canvas[off + col * 4] = rgba[0];
1224            canvas[off + col * 4 + 1] = rgba[1];
1225            canvas[off + col * 4 + 2] = rgba[2];
1226            canvas[off + col * 4 + 3] = rgba[3];
1227        }
1228    }
1229}
1230
1231/// Copy `src` (flat `w*h*4` RGBA) into `canvas` at `(x, y)`, replacing the
1232/// destination pixels byte-for-byte (§2.7.1.1 blending method `1`).
1233fn blit_rect_overwrite(
1234    canvas: &mut [u8],
1235    canvas_w: u32,
1236    x: u32,
1237    y: u32,
1238    w: u32,
1239    h: u32,
1240    src: &[u8],
1241) {
1242    let canvas_w = canvas_w as usize;
1243    let cw_bytes = canvas_w * 4;
1244    let x = x as usize;
1245    let y = y as usize;
1246    let w = w as usize;
1247    let h = h as usize;
1248    let sw_bytes = w * 4;
1249    for row in 0..h {
1250        let src_off = row * sw_bytes;
1251        let dst_off = (y + row) * cw_bytes + x * 4;
1252        canvas[dst_off..dst_off + sw_bytes].copy_from_slice(&src[src_off..src_off + sw_bytes]);
1253    }
1254}
1255
1256/// Composite `src` over `canvas` at `(x, y)` per the §2.7.1.1
1257/// "Alpha-blending" formula (8-bit integer approximation, sRGB space,
1258/// no gamma linearisation — matching the spec's stated 8-bit formula).
1259fn blit_rect_alpha_blend(
1260    canvas: &mut [u8],
1261    canvas_w: u32,
1262    x: u32,
1263    y: u32,
1264    w: u32,
1265    h: u32,
1266    src: &[u8],
1267) {
1268    let canvas_w = canvas_w as usize;
1269    let cw_bytes = canvas_w * 4;
1270    let x = x as usize;
1271    let y = y as usize;
1272    let w = w as usize;
1273    let h = h as usize;
1274    for row in 0..h {
1275        for col in 0..w {
1276            let src_off = (row * w + col) * 4;
1277            let dst_off = (y + row) * cw_bytes + (x + col) * 4;
1278            let sr = src[src_off] as u32;
1279            let sg = src[src_off + 1] as u32;
1280            let sb = src[src_off + 2] as u32;
1281            let sa = src[src_off + 3] as u32;
1282            // Fast path: fully-opaque source → equivalent to overwrite
1283            // (matches "If the current frame does not have an alpha
1284            // channel, assume the alpha value is 255, effectively
1285            // replacing the rectangle").
1286            if sa == 255 {
1287                canvas[dst_off] = sr as u8;
1288                canvas[dst_off + 1] = sg as u8;
1289                canvas[dst_off + 2] = sb as u8;
1290                canvas[dst_off + 3] = 255;
1291                continue;
1292            }
1293            // Fully-transparent source → leave dst unchanged.
1294            if sa == 0 {
1295                continue;
1296            }
1297            let dr = canvas[dst_off] as u32;
1298            let dg = canvas[dst_off + 1] as u32;
1299            let db = canvas[dst_off + 2] as u32;
1300            let da = canvas[dst_off + 3] as u32;
1301            // §2.7.1.1: blend.A = src.A + dst.A * (1 - src.A / 255)
1302            // Done in 8-bit fixed point: dst_factor = dst.A * (255 - src.A) / 255
1303            let dst_factor = (da * (255 - sa) + 127) / 255;
1304            let out_a = sa + dst_factor;
1305            // blend.RGB = (src.RGB * src.A + dst.RGB * dst.A
1306            //              * (1 - src.A / 255)) / blend.A
1307            // out_a == 0 path: both src and dst are fully transparent → RGB
1308            // is undefined and the spec sets blend.RGB := 0; checked_div
1309            // returns None, which we fold into a 0 RGB output.
1310            let out_r = (sr * sa + dr * dst_factor + out_a / 2)
1311                .checked_div(out_a)
1312                .unwrap_or(0);
1313            let out_g = (sg * sa + dg * dst_factor + out_a / 2)
1314                .checked_div(out_a)
1315                .unwrap_or(0);
1316            let out_b = (sb * sa + db * dst_factor + out_a / 2)
1317                .checked_div(out_a)
1318                .unwrap_or(0);
1319            canvas[dst_off] = out_r.min(255) as u8;
1320            canvas[dst_off + 1] = out_g.min(255) as u8;
1321            canvas[dst_off + 2] = out_b.min(255) as u8;
1322            canvas[dst_off + 3] = out_a.min(255) as u8;
1323        }
1324    }
1325}
1326
1327/// Walk a flat §2.3 sub-chunk list (the `ANMF` "Frame Data" sub-RIFF — no
1328/// outer `RIFF`/`WEBP` header) and return the payload of the first chunk with
1329/// `target` FourCC. Returns `None` on a truncated header or no match.
1330fn find_subchunk(mut data: &[u8], target: container::FourCc) -> Option<&[u8]> {
1331    while data.len() >= 8 {
1332        let fourcc: container::FourCc = data[0..4].try_into().ok()?;
1333        let size = u32::from_le_bytes(data[4..8].try_into().ok()?) as usize;
1334        let payload_start = 8usize;
1335        let payload_end = payload_start.checked_add(size)?;
1336        if payload_end > data.len() {
1337            return None;
1338        }
1339        if fourcc == target {
1340            return Some(&data[payload_start..payload_end]);
1341        }
1342        // §2.3: odd Size is followed by one pad byte not counted in Size.
1343        let advance = payload_end + (size & 1);
1344        if advance > data.len() {
1345            return None;
1346        }
1347        data = &data[advance..];
1348    }
1349    None
1350}
1351
1352/// Read the file-level metadata chunks (ICC / Exif / XMP) without
1353/// decoding any pixels.
1354///
1355/// Walks the container and lifts the raw payloads of the §2.7.1.4 `ICCP`,
1356/// §2.7.1.5 `EXIF`, and §2.7.1.5 `XMP ` chunks (each `None` when absent).
1357pub fn extract_metadata(bytes: &[u8]) -> Result<WebpFileMetadata, WebpError> {
1358    let c = container::parse(bytes).map_err(|_| WebpError::InvalidData)?;
1359    Ok(metadata_from_container(bytes, &c))
1360}
1361
1362/// Lift the ICC / Exif / XMP payloads out of an already-parsed container.
1363fn metadata_from_container(bytes: &[u8], c: &container::WebpContainer) -> WebpFileMetadata {
1364    let payload_of = |fourcc| {
1365        c.first_chunk_with_fourcc(fourcc)
1366            .map(|chunk| chunk.payload(bytes).to_vec())
1367    };
1368    WebpFileMetadata {
1369        icc: payload_of(container::fourcc::ICCP),
1370        exif: payload_of(container::fourcc::EXIF),
1371        xmp: payload_of(container::fourcc::XMP),
1372    }
1373}
1374
1375/// Encode an interleaved 8-bit RGBA image to a complete RIFF/WEBP file
1376/// carrying a §2.6 simple-lossless `VP8L` chunk.
1377///
1378/// `rgba` is `width * height * 4` bytes in scan-line (top-to-bottom,
1379/// left-to-right) order, each pixel `[R, G, B, A]` — exactly the
1380/// [`DecodedWebp::rgba`] layout [`decode_webp_image`] returns. The encoded
1381/// file decodes back to the same bytes through [`decode_webp`], a
1382/// pixel-exact round trip.
1383///
1384/// This is the round-115 encoder, extended in round 119 with §5.2.2 LZ77
1385/// backward-reference matching and in round 120 with the §3.5.3 / §3.8.2
1386/// subtract-green forward transform. The encoder evaluates the no-transform
1387/// and subtract-green paths per image and emits whichever is smaller; the
1388/// LZ77 matcher runs in both. Still pass-through: §3.8.2 predictor / color
1389/// / color-indexing transforms and §3.8.3 color cache. The §3.7.2 canonical
1390/// prefix codes are built per-image from the pixel frequencies. See
1391/// [`vp8l_encode::encode_webp_lossless`].
1392pub fn encode_webp_lossless(rgba: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Error> {
1393    vp8l_encode::encode_webp_lossless(rgba, width, height).map_err(Into::into)
1394}
1395
1396// ─────────────────────── Published-shape VP8L encode API ───────────────────────
1397//
1398// The published-0.1.5 lossless-encode public names, mapped onto the
1399// round-115 in-crate VP8L encoder. `encode_vp8l_argb` / `_with` produce a
1400// **bare** VP8L bitstream (no RIFF wrapper); `encode_vp8l_argb_with_metadata`
1401// produces a complete `.webp`, auto-promoting to the §2.7 `VP8X` layout when
1402// alpha or any metadata field is set.
1403
1404/// Encode an ARGB image to a **bare** §2.6 / §3.4 `VP8L` bitstream — the
1405/// chunk payload (image-header + image stream), with **no** RIFF/WEBP
1406/// wrapper.
1407///
1408/// `argb` is `width * height` packed ARGB values in scan-line order, each
1409/// `(alpha << 24) | (red << 16) | (green << 8) | blue` — the same layout
1410/// [`vp8l_decode::DecodedImage::pixels`] produces. The §3.4 `alpha_is_used`
1411/// header bit is auto-detected (set iff any pixel's alpha is not `0xff`);
1412/// use [`encode_vp8l_argb_with`] to set it explicitly.
1413///
1414/// Wrapping the returned bytes in `build::build_webp_file(.., ImageKind::Lossless, ..)`
1415/// (or `build::build_chunk(fourcc::VP8L, ..)`) yields a complete `.webp` that
1416/// decodes back to the input pixels exactly via [`decode_webp`].
1417pub fn encode_vp8l_argb(argb: &[u32], width: u32, height: u32) -> Result<Vec<u8>, WebpError> {
1418    vp8l_encode::encode_vp8l_argb(argb, width, height)
1419        .map_err(Error::from)
1420        .map_err(WebpError::from)
1421}
1422
1423/// Encode an ARGB image to a bare §2.6 / §3.4 `VP8L` bitstream with the
1424/// §3.4 `alpha_is_used` header bit set **explicitly** by the caller.
1425///
1426/// The fixed (non-RDO) form of [`encode_vp8l_argb`]: `has_alpha` becomes the
1427/// header bit verbatim instead of being scanned from the pixels. The alpha
1428/// values are carried in the §3.7.3 ARGB literals regardless of the bit, so
1429/// the round trip is exact either way.
1430pub fn encode_vp8l_argb_with(
1431    argb: &[u32],
1432    width: u32,
1433    height: u32,
1434    has_alpha: bool,
1435) -> Result<Vec<u8>, WebpError> {
1436    vp8l_encode::encode_vp8l_argb_with(argb, width, height, has_alpha)
1437        .map_err(Error::from)
1438        .map_err(WebpError::from)
1439}
1440
1441/// Encode an ARGB image to a complete `.webp` file carrying a §2.6 `VP8L`
1442/// lossless bitstream, embedding any supplied file-level metadata.
1443///
1444/// `argb` is `width * height` packed ARGB values in scan-line order. The
1445/// output layout is chosen automatically:
1446///
1447/// * **Simple `VP8L`** (`RIFF`/`WEBP` + `VP8L`) when `has_alpha` is `false`
1448///   **and** `meta` is empty — the smallest spec-conformant still image.
1449/// * **Extended `VP8X`** (`RIFF`/`WEBP` + `VP8X` + `ICCP` + `VP8L` +
1450///   `EXIF` + `XMP `) when `has_alpha` is `true` **or** any metadata
1451///   field is set. The §2.7.1 `VP8X` flag octet declares exactly the
1452///   features present (`L`/`I`/`E`/`X`), and the metadata chunks are emitted
1453///   in the §2.7 order (`ICCP` before the image, `EXIF`/`XMP ` after).
1454///
1455/// The bitstream's own §3.4 `alpha_is_used` header bit is set from
1456/// `has_alpha`. Decoding the result through [`decode_webp`] reproduces the
1457/// input pixels exactly; [`extract_metadata`] reads back the embedded
1458/// ICC / Exif / XMP payloads.
1459pub fn encode_vp8l_argb_with_metadata(
1460    width: u32,
1461    height: u32,
1462    argb: &[u32],
1463    has_alpha: bool,
1464    meta: &WebpMetadata<'_>,
1465) -> Result<Vec<u8>, WebpError> {
1466    // Bare VP8L bitstream (image-header + image stream).
1467    let payload = encode_vp8l_argb_with(argb, width, height, has_alpha)?;
1468
1469    // Simple layout when there is nothing to declare in a VP8X.
1470    if !has_alpha && meta.is_empty() {
1471        return build::build_webp_file(&payload, build::ImageKind::Lossless, width, height)
1472            .map_err(Error::from)
1473            .map_err(WebpError::from);
1474    }
1475
1476    // Extended layout: VP8X header declaring exactly the present features,
1477    // then ICCP, the VP8L image, EXIF, XMP — the §2.7 order.
1478    let flags = build::Vp8xFlags {
1479        has_iccp: meta.icc.is_some(),
1480        has_alpha,
1481        has_exif: meta.exif.is_some(),
1482        has_xmp: meta.xmp.is_some(),
1483        has_animation: false,
1484    };
1485    let vp8x_payload = build::build_vp8x_chunk(width, height, flags)
1486        .map_err(Error::from)
1487        .map_err(WebpError::from)?;
1488
1489    let mut body = Vec::new();
1490    let mut push_chunk = |fourcc, payload: &[u8]| -> Result<(), WebpError> {
1491        let chunk = build::build_chunk(fourcc, payload)
1492            .map_err(Error::from)
1493            .map_err(WebpError::from)?;
1494        body.extend_from_slice(&chunk);
1495        Ok(())
1496    };
1497
1498    push_chunk(container::fourcc::VP8X, &vp8x_payload)?;
1499    if let Some(icc) = meta.icc {
1500        push_chunk(container::fourcc::ICCP, icc)?;
1501    }
1502    push_chunk(container::fourcc::VP8L, &payload)?;
1503    if let Some(exif) = meta.exif {
1504        push_chunk(container::fourcc::EXIF, exif)?;
1505    }
1506    if let Some(xmp) = meta.xmp {
1507        push_chunk(container::fourcc::XMP, xmp)?;
1508    }
1509
1510    // §2.4 file framing around the assembled body.
1511    let file_size = (body.len() as u64) + 4;
1512    if file_size > u64::from(u32::MAX) {
1513        return Err(WebpError::InvalidData);
1514    }
1515    let mut out = Vec::with_capacity(12 + body.len());
1516    out.extend_from_slice(&container::fourcc::RIFF);
1517    out.extend_from_slice(&(file_size as u32).to_le_bytes());
1518    out.extend_from_slice(&container::fourcc::WEBP);
1519    out.extend_from_slice(&body);
1520    Ok(out)
1521}
1522
1523// ─────────────────────── Published-shape animation encode API ───────────────────────
1524//
1525// The published-0.1.5 `build_animated_webp` surface, rebuilt on top of the
1526// in-crate VP8L encoder + the §2.7.1.1 ANIM / ANMF container framing. Only the
1527// VP8L-lossless path (`AnimFrameMode::Lossless`) is wired up; `Auto` / `Delta`
1528// return `WebpError::Unsupported` (the VP8 lossy + delta paths are blocked on
1529// `oxideav-vp8`, workspace task #1041).
1530
1531#[doc(inline)]
1532pub use anim_encode::{
1533    build_animated_webp, build_animated_webp_with_options, AnimEncoderOptions, AnimFrame,
1534    AnimFrameMode, DeltaConfig, DownsampleKernel,
1535};
1536
1537/// Stable codec identifier the VP8L lossless encoder registers under in the
1538/// codec registry — the published `"webp_vp8l"` name.
1539pub const CODEC_ID_VP8L: &str = "webp_vp8l";
1540
1541/// Stable codec identifier the VP8 lossy encoder registers under in the
1542/// codec registry — the published `"webp_vp8"` name. The encoder itself
1543/// is blocked on the `oxideav-vp8` Phase-2 lossy encoder (workspace task
1544/// #1041); the id is reserved so consumers can look it up today and the
1545/// registry slots in the factory once the encoder lands.
1546pub const CODEC_ID_VP8: &str = "webp_vp8";
1547
1548// `Result` is published at `oxideav_webp::error::Result` (see
1549// `crate::error`). It is NOT re-exported at the crate root because the
1550// crate's source uses `Result<T, E>` extensively with two type
1551// parameters (the std prelude form); shadowing that name at the root
1552// would break those call sites. The published-0.1.2 documented path is
1553// the qualified `oxideav_webp::error::Result`.
1554
1555/// Repack a scan-line-order ARGB pixel buffer (`(a<<24)|(r<<16)|(g<<8)|b`)
1556/// into interleaved 8-bit `[R, G, B, A]` bytes — the
1557/// `oxideav_core::PixelFormat::Rgba` layout.
1558fn argb_to_rgba(pixels: &[u32]) -> Vec<u8> {
1559    // Write four bytes per pixel into a pre-sized buffer via
1560    // `chunks_exact_mut(4)`, the same shape `Vp8lImage::to_rgba_scalar`
1561    // adopted: it drops the per-`push` capacity-check + bounds-check the
1562    // one-byte-at-a-time loop incurred and lets the compiler auto-
1563    // vectorise the strided channel stores. The produced bytes are
1564    // byte-for-byte identical to the prior push loop — `[R, G, B, A]`
1565    // order matching `oxideav_core::PixelFormat::Rgba`.
1566    let mut out = vec![0u8; pixels.len() * 4];
1567    for (chunk, &argb) in out.chunks_exact_mut(4).zip(pixels.iter()) {
1568        chunk[0] = (argb >> 16) as u8; // R
1569        chunk[1] = (argb >> 8) as u8; // G
1570        chunk[2] = argb as u8; // B
1571        chunk[3] = (argb >> 24) as u8; // A
1572    }
1573    out
1574}
1575
1576/// Install the WebP decoder factory and the `.webp` extension hint into
1577/// `ctx` per round 112.
1578///
1579/// Wraps [`registry::register`]; see that module for the full breakdown
1580/// of what lands in the codec / container sub-registries. The decoder
1581/// covers the §2.6 / §3.4 `VP8L` lossless image (simple or
1582/// `VP8X`-extended) with optional §2.7.1.2 `ALPH`-over-`VP8L` alpha
1583/// override, and (round 124) the §2.5 `VP8 ` lossy image decoded via the
1584/// `oxideav-vp8` sibling crate (with optional `ALPH`-over-`VP8 ` alpha).
1585/// The standalone [`extract_lossy_chunk`] routing API stays available for
1586/// callers that want the raw VP8 bitstream slice.
1587#[cfg(feature = "registry")]
1588pub fn register(ctx: &mut RuntimeContext) {
1589    registry::register(ctx);
1590}
1591
1592/// Install only the WebP **codec** factories into `ctx` — the
1593/// per-codec `Decoder` / `Encoder` impls under the `"webp"`, `"webp_vp8l"`,
1594/// and `"webp_vp8"` ids.
1595///
1596/// This is the `RuntimeContext`-typed crate-root form per the
1597/// published 0.1.2 surface; for callers driving the registry
1598/// piece-wise the lower-level
1599/// [`registry::register_codecs`]`(&mut ctx.codecs)` form is still
1600/// available.
1601#[cfg(feature = "registry")]
1602pub fn register_codecs(ctx: &mut RuntimeContext) {
1603    registry::register_codecs(&mut ctx.codecs);
1604}
1605
1606/// Install only the WebP **container** hooks into `ctx` — the `.webp`
1607/// file-extension mapping that lets a demuxer-discovery pass route a
1608/// `.webp` file back to the WebP codec id.
1609///
1610/// `RuntimeContext`-typed counterpart of
1611/// [`registry::register_containers`]`(&mut ctx.containers)`.
1612#[cfg(feature = "registry")]
1613pub fn register_containers(ctx: &mut RuntimeContext) {
1614    registry::register_containers(&mut ctx.containers);
1615}
1616
1617#[cfg(feature = "registry")]
1618oxideav_core::register!("webp", register);