Skip to main content

webpkit/
lib.rs

1//! `webpkit` — a pure-Rust WebP codec: lossless (VP8L) and lossy (VP8) behind one API.
2//!
3//! This is the umbrella crate. [`decode`] reads any still WebP file, inspecting
4//! the container to route `VP8L` payloads to the [`crate::lossless`] (lossless) decoder and
5//! `VP8 ` payloads to the [`crate::lossy`] (lossy) decoder; both return the shared
6//! [`Image`] type. A lossy image's sibling `ALPH` alpha chunk is composited here,
7//! where both codecs are in scope. The type-state [`Encoder`] writes output with
8//! either codec — [`Encoder::lossless`] or [`Encoder::lossy`] — sharing the
9//! effort/metadata knobs (only the lossy builder has a `quality`). The container
10//! framing, image model, and error type are defined once in [`crate`] and
11//! re-exported here.
12//!
13//! Like the codec crates it wraps, this crate forbids `unsafe`, has zero required
14//! runtime dependencies, and targets `no_std` (with `alloc`).
15//!
16//! # Status
17//!
18//! Lossless decode/encode is complete (see [`crate::lossless`]). Lossy (VP8) decoding
19//! reconstructs baseline key frames (via [`crate::lossy`]), composites a separate
20//! `ALPH` alpha channel, and decodes lossy animations (frames dispatched into
21//! the `lossless` codec's compositor). The unified [`IncrementalDecoder`] streams any still or
22//! animation, row-streaming a bare lossy `VP8 ` still through [`crate::lossy`]. Lossy
23//! **encoding** ([`Encoder::lossy`]) writes a baseline `VP8 ` key frame, carrying a
24//! lossless `ALPH` alpha plane for non-opaque images and ICC/Exif/XMP [`Metadata`]
25//! via the extended `VP8X` container ([`Encoder::encode`] preserves a source
26//! [`Image`]'s metadata by default).
27#![forbid(unsafe_code)]
28#![cfg_attr(not(feature = "std"), no_std)]
29#![deny(
30    clippy::float_arithmetic,
31    reason = "the codecs must be bit-deterministic across platforms; floating-point \
32              rounding is not portable. Use fixed-point integer math."
33)]
34#![allow(
35    clippy::redundant_pub_crate,
36    reason = "pub(crate) is our honest internal visibility; this nursery lint conflicts \
37              with the rustc unreachable_pub lint that we also enable"
38)]
39
40#[cfg(feature = "alloc")]
41extern crate alloc;
42
43#[cfg(not(feature = "alloc"))]
44compile_error!("webp requires an allocator: enable the `alloc` feature (implied by `std`)");
45
46#[cfg(feature = "alloc")]
47use alloc::boxed::Box;
48#[cfg(feature = "alloc")]
49use alloc::vec::Vec;
50
51// ---- module tree ------------------------------------------------------------
52// The bitstream-agnostic shell (formerly the `webpkit-core` crate): container
53// framing, image model, error type, streaming vocabulary. Flattened at the crate
54// root so its `crate::`-relative code is unchanged. NOT named `core` — that would
55// shadow the `::core` std crate the no_std codecs use throughout.
56//
57// The shell and codec modules are `#[doc(hidden)] pub` so this workspace's own
58// test & tooling crates can still reach into them module-first (they used to be
59// separate public crates). They are NOT a stable, documented part of the public
60// API — the curated re-exports below are what external users depend on. (`prelude`
61// stays private: it was `pub(crate)` internal even as a separate crate.)
62#[doc(hidden)]
63pub mod alpha;
64#[doc(hidden)]
65pub mod anim;
66#[doc(hidden)]
67pub mod container;
68#[doc(hidden)]
69pub mod effort;
70mod encoder;
71#[doc(hidden)]
72pub mod error;
73#[doc(hidden)]
74pub mod image;
75// Optional `image`-crate interop (TryFrom conversions on `Image`). The impls attach
76// to the public types, so the module itself stays private.
77#[cfg(feature = "image")]
78mod interop;
79#[doc(hidden)]
80pub mod lossless;
81#[doc(hidden)]
82pub mod lossy;
83mod prelude;
84#[doc(hidden)]
85pub mod stream;
86#[cfg(feature = "work-count")]
87#[doc(hidden)]
88pub mod work_count;
89
90// ---- public facade re-exports ----------------------------------------------
91pub use crate::anim::{
92    AnimInfo, BlendMode, CompositedFrame, DisposalMode, Frame, FrameMeta,
93    decode_frames_with_decoder,
94};
95pub use crate::effort::Effort;
96pub use crate::error::{Codec, Error, Result};
97pub use crate::image::{
98    Dimensions, Image, ImageRef, MAX_DIMENSION, Metadata, MetadataPolicy, PixelLayout,
99};
100pub use crate::stream::{
101    DEFAULT_MAX_PIXELS, DecodeOptions, DecodedFrame, FrameDecoder, FramePayload, ImageInfo,
102    Progress, RowDrain,
103};
104pub use encoder::{Encoder, Lossless, Lossy};
105// Surface animation construction from the facade (previously reachable only
106// through the lossless crate).
107pub use crate::lossless::AnimationEncoder;
108
109// ---- imports used by the facade functions below -----------------------------
110use crate::alpha::{AlphaCompression, parse_header, unfilter};
111use crate::container::fourcc::FourCc;
112use crate::container::reader::{ImageChunk, read_container};
113use crate::container::scan::{declared_len, is_complete, scan_chunks};
114use crate::container::vp8x::{VP8X_PAYLOAD_LEN, Vp8xInfo};
115
116/// A lazy per-frame iterator over a WebP animation — each frame decoded by the
117/// both-codecs [`WebpFrameDecoder`] (lossless `VP8L` or lossy `VP8 `).
118#[cfg(feature = "alloc")]
119pub type Frames<'a> = crate::anim::Frames<'a, WebpFrameDecoder>;
120/// A compositing iterator that paints each animation frame onto the persistent
121/// canvas (see [`crate::anim::CompositedFrames`]).
122#[cfg(feature = "alloc")]
123pub type CompositedFrames<'a> = crate::anim::CompositedFrames<'a, WebpFrameDecoder>;
124
125/// Decode a still WebP file — lossless (`VP8L`) or lossy (`VP8 `) — into an
126/// [`Image`] (RGBA8 by default), dispatching on the container's image chunk.
127///
128/// A lossy `VP8 ` image accompanied by a sibling `ALPH` chunk is composited: the
129/// opaque RGB is decoded by [`crate::lossy`], the alpha plane is decompressed (raw, or
130/// a lossless `VP8L` stream) and spatially un-filtered, and the result is written
131/// into the image's alpha channel. Lossless (`VP8L`) images carry their own alpha.
132///
133/// An animated file returns its **first composited frame** (matching libwebp's
134/// `WebPDecode`); use [`decode_frames`] to walk every frame.
135///
136/// # Untrusted input
137///
138/// Safe by default: this caps the canvas at [`DEFAULT_MAX_PIXELS`] before any
139/// buffer is allocated, so a hostile header cannot exhaust memory. Raise the cap
140/// with [`decode_with`] + [`DecodeOptions::max_pixels`], or remove it for trusted
141/// input with [`DecodeOptions::unbounded`].
142///
143/// # Errors
144///
145/// [`Error::NotWebp`]/[`Error::Truncated`] for a non-WebP or short input,
146/// [`Error::MissingImage`] when the file has no image chunk, or a
147/// bitstream/container error from the selected decoder or the `ALPH` alpha stream.
148pub fn decode(input: &[u8]) -> Result<Image> {
149    decode_with(input, &DecodeOptions::default())
150}
151
152/// Decode a still WebP file into an [`Image`] with explicit [`DecodeOptions`]
153/// (output layout, pixel limit), dispatching on the container's image chunk.
154///
155/// Symmetric with the codec crates' `decode_with`: the selected decoder enforces
156/// `options.max_pixels` against the peeked header dimensions *before* any pixel or
157/// canvas buffer is allocated, and the limit is propagated into a lossy image's
158/// `ALPH` alpha decode. A default [`DecodeOptions`] caps at [`DEFAULT_MAX_PIXELS`];
159/// call [`DecodeOptions::unbounded`] to lift it for trusted input. An animated file
160/// returns its **first composited frame** (matching [`decode`]).
161///
162/// # Errors
163///
164/// The same errors as [`decode`], plus [`Error::LimitExceeded`] when
165/// `options.max_pixels` is exceeded.
166#[cfg(feature = "alloc")]
167pub fn decode_with(input: &[u8], options: &DecodeOptions) -> Result<Image> {
168    // One container walk yields the image chunk, any sibling `ALPH`, the `VP8X`
169    // header, and sidecar metadata — so the file is parsed exactly once (the codec
170    // is handed the located payload, not the whole file to re-parse).
171    let c = read_container(input, options.read_metadata)?;
172    // A clearly-animated file routes to its first composited frame, leaving the
173    // still-image path (and its exact error semantics) untouched otherwise.
174    if c.animated {
175        return first_composited_frame(input, options);
176    }
177    match c.image.ok_or(Error::MissingImage)? {
178        // A `VP8L` image encodes its own alpha, so any sibling `ALPH` does not
179        // apply; decode the located payload directly (no container re-parse).
180        ImageChunk::Lossless(payload) => {
181            let image = crate::lossless::decode_vp8l(payload, options)?;
182            // A VP8X canvas, if present, must agree with the decoded dimensions.
183            if c.vp8x.is_some_and(|vp8x| vp8x.canvas != image.dimensions()) {
184                return Err(Error::InvalidContainer);
185            }
186            Ok(image.with_metadata(c.metadata))
187        },
188        ImageChunk::Lossy(payload) => {
189            let mut image = crate::lossy::decode_with(payload, options)?;
190            if let Some(alph) = c.alpha {
191                let plane = alpha_plane_with(alph, image.width(), image.height(), options)?;
192                image.apply_alpha_plane(&plane)?;
193            }
194            // Surface any `VP8X` sidecar metadata (ICCP/EXIF/XMP) so a lossy
195            // decode → encode_image round trip preserves it, matching the lossless
196            // path (a bare `VP8 ` yields no metadata, leaving the image unchanged).
197            Ok(image.with_metadata(c.metadata))
198        },
199    }
200}
201
202/// Decode a still WebP straight to its **RGBA8** pixels and [`Dimensions`].
203///
204/// The raw-buffer companion to [`decode`], skipping the [`Image`] wrapper for
205/// callers that only want the bytes (any embedded metadata is dropped).
206///
207/// # Errors
208///
209/// The same errors as [`decode`].
210#[cfg(feature = "alloc")]
211pub fn decode_rgba(input: &[u8]) -> Result<(Dimensions, Vec<u8>)> {
212    let image = decode(input)?;
213    Ok((image.dimensions(), image.into_pixels()))
214}
215
216/// Decode a still WebP read from any [`std::io::Read`] source into an [`Image`].
217///
218/// The reader-based companion to [`decode`] (RGBA8 by default). The whole stream
219/// is buffered before decoding; for push-based streaming use
220/// [`incremental_decoder`].
221///
222/// # Errors
223///
224/// [`Error::Io`] if reading `reader` fails, otherwise the same errors as [`decode`].
225#[cfg(feature = "std")]
226pub fn decode_reader<R: std::io::Read>(mut reader: R) -> Result<Image> {
227    let mut buf = Vec::new();
228    reader.read_to_end(&mut buf)?;
229    decode(&buf)
230}
231
232/// Encode an **RGBA8** pixel buffer as a lossless (`VP8L`) WebP file.
233///
234/// The one-call companion to [`decode`], mirroring [`decode_rgba`] on the encode
235/// side. Uses the default [`Effort`]; for a different effort tier, embedded
236/// metadata, or a non-RGBA input layout, use the [`Encoder`] builder. `rgba` must
237/// be exactly `width * height * 4` bytes.
238///
239/// # Examples
240///
241/// ```
242/// let rgba = vec![0u8; 4 * 4 * 4]; // a 4x4 RGBA image
243/// let webp = webpkit::encode_lossless_rgba(4, 4, &rgba)?;
244/// let (dims, pixels) = webpkit::decode_rgba(&webp)?;
245/// assert_eq!((dims.width(), dims.height()), (4, 4));
246/// assert_eq!(pixels, rgba); // lossless is byte-exact
247/// # Ok::<(), webpkit::Error>(())
248/// ```
249///
250/// # Errors
251///
252/// [`Error::InvalidDimensions`] for a zero or over-large canvas,
253/// [`Error::PixelBufferMismatch`] if `rgba`'s length is wrong, otherwise any
254/// encode error.
255#[cfg(feature = "alloc")]
256pub fn encode_lossless_rgba(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>> {
257    let image = ImageRef::new(Dimensions::new(width, height)?, PixelLayout::Rgba8, rgba)?;
258    Encoder::lossless().encode_ref(image)
259}
260
261/// Encode an **RGBA8** pixel buffer as a lossy (`VP8 `) WebP file at `quality`.
262///
263/// The one-call companion to [`decode`]; `quality` is `0..=100` (clamped). For a
264/// different effort tier, embedded metadata, or a non-RGBA input layout, use the
265/// [`Encoder`] builder. `rgba` must be exactly `width * height * 4` bytes.
266///
267/// # Errors
268///
269/// The same errors as [`encode_lossless_rgba`].
270#[cfg(feature = "alloc")]
271pub fn encode_lossy_rgba(width: u32, height: u32, rgba: &[u8], quality: u8) -> Result<Vec<u8>> {
272    let image = ImageRef::new(Dimensions::new(width, height)?, PixelLayout::Rgba8, rgba)?;
273    Encoder::lossy().quality(quality).encode_ref(image)
274}
275
276/// Decode an `ALPH` chunk payload (including its 1-byte header) into a
277/// `width * height` alpha plane.
278///
279/// Parses the header, decompresses the plane (raw bytes, or a lossless `VP8L`
280/// stream via [`crate::lossless::decode_alpha`]), then reverses its spatial filter. The
281/// un-filter is applied identically for both compression methods — the filter is
282/// orthogonal to how the plane was stored.
283#[cfg(feature = "alloc")]
284fn alpha_plane(alph: &[u8], width: u32, height: u32) -> Result<Vec<u8>> {
285    alpha_plane_with(alph, width, height, &DecodeOptions::default())
286}
287
288/// Like [`alpha_plane`] but threading `options` so [`decode_with`] can propagate
289/// its `max_pixels` limit into the lossless `ALPH` alpha decode (checked before the
290/// plane is allocated). The plane is the same size as the already-limited image, so
291/// this is defense in depth rather than the primary guard.
292#[cfg(feature = "alloc")]
293fn alpha_plane_with(
294    alph: &[u8],
295    width: u32,
296    height: u32,
297    options: &DecodeOptions,
298) -> Result<Vec<u8>> {
299    let (w, h) = (width as usize, height as usize);
300    let count = w.checked_mul(h).ok_or(Error::Truncated)?;
301    let (header, data) = parse_header(alph)?;
302    let mut plane = match header.compression {
303        AlphaCompression::None => data.get(..count).ok_or(Error::Truncated)?.to_vec(),
304        AlphaCompression::Lossless => {
305            crate::lossless::decode_alpha_with(data, width, height, options)?
306        },
307    };
308    unfilter(header.filter, &mut plane, w, h);
309    Ok(plane)
310}
311
312/// Decode an animated WebP into a lazy [`Frames`] iterator.
313///
314/// Handles both lossless (`VP8L`) and lossy (`VP8 ` + optional `ALPH`) frames —
315/// the latter via [`crate::lossy`] with alpha compositing, injected into the `lossless`
316/// codec's animation walker.
317///
318/// # Untrusted input
319///
320/// Safe by default: like [`decode`], each frame's canvas is capped at
321/// [`DEFAULT_MAX_PIXELS`] before allocation. Use [`decode_frames_with`] with
322/// [`DecodeOptions::max_pixels`] to choose another cap, or
323/// [`DecodeOptions::unbounded`] for trusted input.
324///
325/// # Errors
326///
327/// [`Error::UnsupportedFeature`] if `input` is not an animation, or a
328/// container/bitstream error from a frame.
329#[cfg(feature = "alloc")]
330pub fn decode_frames(input: &[u8]) -> Result<Frames<'_>> {
331    decode_frames_with(input, &DecodeOptions::default())
332}
333
334/// Like [`decode_frames`] with explicit [`DecodeOptions`] (output layout,
335/// per-frame pixel limit); the both-codecs [`WebpFrameDecoder`] is always wired in.
336///
337/// # Errors
338///
339/// The same as [`decode_frames`], plus [`Error::LimitExceeded`] when a frame
340/// exceeds `options.max_pixels`.
341#[cfg(feature = "alloc")]
342pub fn decode_frames_with<'a>(input: &'a [u8], options: &DecodeOptions) -> Result<Frames<'a>> {
343    crate::decode_frames_with_decoder(input, options, WebpFrameDecoder)
344}
345
346/// A push-based [`IncrementalDecoder`] for still images and animations, with the
347/// lossy-frame hook wired so animated `VP8 ` frames decode.
348#[cfg(feature = "alloc")]
349#[must_use]
350pub fn incremental_decoder() -> IncrementalDecoder {
351    IncrementalDecoder::new()
352}
353
354/// The chosen streaming back end once the container kind is known.
355#[cfg(feature = "alloc")]
356enum Backend {
357    /// Not yet classified — buffering until the first image chunk is reachable.
358    Undecided,
359    /// Lossless still, or any animation: the `lossless` codec's incremental decoder (with the
360    /// lossy-frame hook injected so animated `VP8 ` frames decode). Boxed to keep the
361    /// enum small — the lossless decoder dwarfs the other variants.
362    Lossless(Box<crate::lossless::IncrementalDecoder<WebpFrameDecoder>>),
363    /// A bare lossy `VP8 ` still (no `VP8X`): true row streaming via [`crate::lossy`].
364    Lossy(Box<crate::lossy::IncrementalDecoder>),
365    /// An extended lossy still (`VP8X` + `VP8 `, possibly with `ALPH`): alpha /
366    /// metadata compositing is not row-streamable byte-identically, so buffer the
367    /// whole file and finish with a one-shot [`decode`].
368    Deferred,
369}
370
371/// How [`IncrementalDecoder::push`] should proceed once enough bytes are buffered.
372#[cfg(feature = "alloc")]
373enum Decision {
374    Lossless,
375    Lossy,
376    Deferred(ImageInfo),
377    NeedMore,
378}
379
380/// A push-based decoder for **any** still WebP or animation.
381///
382/// Dispatches on the container kind: lossless stills and all animations stream
383/// through [`crate::lossless`], a bare lossy `VP8 ` still row-streams through [`crate::lossy`],
384/// and an extended lossy still (which may carry `ALPH` alpha) is buffered and
385/// finished with a one-shot [`decode`]. The pixels and error semantics match
386/// [`decode`] exactly; the only new streaming capability over the
387/// lossless/animation paths is the bare lossy still. `Read`-free, so it works on
388/// `no_std + alloc`.
389#[cfg(feature = "alloc")]
390pub struct IncrementalDecoder {
391    buf: Vec<u8>,
392    options: DecodeOptions,
393    reported_header: bool,
394    image: Option<Image>,
395    backend: Backend,
396}
397
398#[cfg(feature = "alloc")]
399impl IncrementalDecoder {
400    /// A new decoder with default options.
401    #[must_use]
402    pub fn new() -> Self {
403        Self::with_options(DecodeOptions::default())
404    }
405
406    /// A new decoder with the given options (output layout, per-image pixel limit).
407    #[must_use]
408    pub const fn with_options(options: DecodeOptions) -> Self {
409        Self {
410            buf: Vec::new(),
411            options,
412            reported_header: false,
413            image: None,
414            backend: Backend::Undecided,
415        }
416    }
417
418    /// Feed the next slice of the file and report [`Progress`].
419    ///
420    /// # Errors
421    ///
422    /// The same errors as [`decode`], surfaced as soon as the buffered bytes make
423    /// them detectable.
424    pub fn push(&mut self, chunk: &[u8]) -> Result<Progress> {
425        // Once a back end is chosen, forward the raw chunk to it (its own buffer
426        // already holds everything up to here, replayed on the deciding push).
427        match &mut self.backend {
428            Backend::Lossless(be) => return be.push(chunk),
429            Backend::Lossy(be) => return be.push(chunk),
430            Backend::Deferred => {
431                self.buf.extend_from_slice(chunk);
432                return self.drive_deferred();
433            },
434            Backend::Undecided => {},
435        }
436        if self.image.is_some() {
437            return Ok(Progress::Finished);
438        }
439        self.buf.extend_from_slice(chunk);
440        match classify(&self.buf)? {
441            Decision::Lossless => {
442                // Inject the both-codecs frame decoder so animated `VP8 ` frames
443                // decode (a bare `lossless` decoder would reject them).
444                let mut be = crate::lossless::IncrementalDecoder::with_options_and_decoder(
445                    self.options.clone(),
446                    WebpFrameDecoder,
447                );
448                let progress = be.push(&self.buf)?;
449                self.backend = Backend::Lossless(Box::new(be));
450                Ok(progress)
451            },
452            Decision::Lossy => {
453                let mut be = crate::lossy::IncrementalDecoder::with_options(self.options.clone());
454                let progress = be.push(&self.buf)?;
455                self.backend = Backend::Lossy(Box::new(be));
456                Ok(progress)
457            },
458            Decision::Deferred(info) => {
459                self.backend = Backend::Deferred;
460                if !self.reported_header {
461                    self.reported_header = true;
462                    return Ok(Progress::HeaderReady(info));
463                }
464                self.drive_deferred()
465            },
466            Decision::NeedMore => {
467                // A complete-but-unclassifiable buffer (e.g. a malformed container)
468                // takes the one-shot path, preserving its exact error / image.
469                if is_complete(&self.buf) {
470                    self.image = Some(decode(&self.buf)?);
471                    Ok(Progress::Finished)
472                } else {
473                    Ok(Progress::NeedMoreInput)
474                }
475            },
476        }
477    }
478
479    /// Finish the deferred (extended-lossy) path once the whole RIFF is buffered.
480    fn drive_deferred(&mut self) -> Result<Progress> {
481        if is_complete(&self.buf) {
482            self.image = Some(decode(&self.buf)?);
483            Ok(Progress::Finished)
484        } else {
485            Ok(Progress::NeedMoreInput)
486        }
487    }
488
489    /// The most-recently composited animation frame, or `None` for a still image.
490    /// Mirrors the underlying decoder's `frame_image`.
491    #[must_use]
492    pub fn frame_image(&self) -> Option<&Image> {
493        match &self.backend {
494            Backend::Lossless(be) => be.frame_image(),
495            _ => None,
496        }
497    }
498
499    /// Borrow the finalized-but-not-yet-viewed rows of a streamed still image (a
500    /// non-consuming early view). `None` unless a row-streaming back end is active
501    /// (a lossless or bare-lossy still); the deferred and animation paths yield no
502    /// rows.
503    pub fn drain_rows(&mut self) -> Option<RowDrain<'_>> {
504        match &mut self.backend {
505            Backend::Lossless(be) => be.drain_rows(),
506            Backend::Lossy(be) => be.drain_rows(),
507            Backend::Undecided | Backend::Deferred => None,
508        }
509    }
510
511    /// Retrieve the complete decoded image (an animation's first composited frame)
512    /// once [`Progress::Finished`] has been reported.
513    ///
514    /// # Errors
515    ///
516    /// The same errors as [`decode`] when the buffer is not a fully-decoded image.
517    pub fn into_image(self) -> Result<Image> {
518        if let Some(image) = self.image {
519            return Ok(image);
520        }
521        match self.backend {
522            Backend::Lossless(be) => be.into_image(),
523            Backend::Lossy(be) => be.into_image(),
524            Backend::Undecided | Backend::Deferred => decode(&self.buf),
525        }
526    }
527}
528
529#[cfg(feature = "alloc")]
530impl Default for IncrementalDecoder {
531    fn default() -> Self {
532        Self::new()
533    }
534}
535
536/// Walk the buffered container to decide which streaming back end handles it.
537/// `Ok(Decision::NeedMore)` means the first image chunk is not yet reachable.
538#[cfg(feature = "alloc")]
539fn classify(buf: &[u8]) -> Result<Decision> {
540    if buf.len() < 12 {
541        return Ok(Decision::NeedMore);
542    }
543    if buf[0..4] != FourCc::RIFF.0 || buf[8..12] != FourCc::WEBP.0 {
544        return Err(Error::NotWebp);
545    }
546    // The header of a non-animated `VP8X`, if seen before the image chunk: its
547    // presence means an extended (possibly alpha-bearing) lossy still is deferred.
548    let mut vp8x_info: Option<ImageInfo> = None;
549    for chunk in scan_chunks(buf) {
550        match chunk.id {
551            FourCc::VP8X => {
552                let Some(data) =
553                    buf.get(chunk.payload_start..chunk.payload_start + VP8X_PAYLOAD_LEN)
554                else {
555                    return Ok(Decision::NeedMore);
556                };
557                let info = Vp8xInfo::parse(data)?;
558                if info.flags.is_animated() {
559                    return Ok(Decision::Lossless);
560                }
561                vp8x_info = Some(ImageInfo::new(
562                    info.canvas,
563                    info.flags.has_alpha(),
564                    info.flags.has_icc() || info.flags.has_exif() || info.flags.has_xmp(),
565                    false,
566                ));
567            },
568            // A lossless still (crate::lossless re-reads any VP8X sidecar) or an animation
569            // chunk: crate::lossless streams it.
570            FourCc::VP8L | FourCc::ANIM | FourCc::ANMF => return Ok(Decision::Lossless),
571            FourCc::VP8 => {
572                if let Some(info) = vp8x_info {
573                    return Ok(Decision::Deferred(info)); // extended lossy (has VP8X)
574                }
575                // Bare VP8: row-stream only if it spans the whole declared RIFF
576                // body (`chunk.next` is the padded end), so no trailing
577                // ALPH/metadata chunk can diverge the opaque stream from the
578                // one-shot decode; otherwise defer.
579                return match declared_len(buf) {
580                    Some(riff_end) if chunk.next >= riff_end => Ok(Decision::Lossy),
581                    Some(_) => Ok(bare_deferred(buf.get(chunk.payload_start..))),
582                    None => Ok(Decision::NeedMore),
583                };
584            },
585            _ => {},
586        }
587    }
588    Ok(Decision::NeedMore)
589}
590
591/// A bare `VP8 ` followed by trailing chunks (e.g. a malformed `ALPH` with no
592/// `VP8X`): defer to the one-shot decode, peeking the VP8 dimensions for the
593/// header report. `NeedMore` until the 10-byte VP8 header is buffered.
594#[cfg(feature = "alloc")]
595fn bare_deferred(vp8_payload: Option<&[u8]>) -> Decision {
596    vp8_payload
597        .and_then(|p| crate::lossy::peek_dimensions(p).ok())
598        .map_or(Decision::NeedMore, |dimensions| {
599            Decision::Deferred(ImageInfo::new(dimensions, false, false, false))
600        })
601}
602
603/// Decode an animation's first composited frame as a still [`Image`], honoring
604/// `options` (output layout, per-frame pixel limit).
605#[cfg(feature = "alloc")]
606fn first_composited_frame(input: &[u8], options: &DecodeOptions) -> Result<Image> {
607    decode_frames_with(input, options)?
608        .composited()
609        .next()
610        .ok_or(Error::MissingImage)?
611        .map(CompositedFrame::into_image)
612}
613
614/// The umbrella's [`FrameDecoder`]: the seam that
615/// drives **both** codecs.
616///
617/// It lets the `lossless` codec's codec-agnostic animation walker decode frames of
618/// either codec. A `VP8L` frame is decoded by the `lossless` codec (delegating to its
619/// [`Vp8lFrameDecoder`](crate::lossless::Vp8lFrameDecoder)); a lossy `VP8 ` (+
620/// optional sibling `ALPH`) frame is decoded by [`crate::lossy`], compositing the
621/// alpha plane into the pixels' top byte.
622#[cfg(feature = "alloc")]
623#[derive(Debug, Clone, Copy, Default)]
624pub struct WebpFrameDecoder;
625
626#[cfg(feature = "alloc")]
627impl crate::FrameDecoder for WebpFrameDecoder {
628    fn decode_frame(
629        &self,
630        frame: crate::FramePayload<'_>,
631        options: &DecodeOptions,
632    ) -> Result<crate::DecodedFrame> {
633        if frame.vp8l.is_some() {
634            // The VP8L path is codec-internal to `crate::lossless` — reuse it verbatim.
635            return crate::lossless::Vp8lFrameDecoder.decode_frame(frame, options);
636        }
637        let Some(payload) = frame.vp8 else {
638            return Err(Error::MissingImage);
639        };
640        // Guard the pixel budget against the frame's declared dimensions before the
641        // lossy decoder allocates its planes.
642        let pixels = frame.dims.pixel_count();
643        if let Some(limit) = options.max_pixels.filter(|&l| pixels > l) {
644            return Err(Error::LimitExceeded { pixels, limit });
645        }
646        let (dims, mut argb) = crate::lossy::decode_argb(payload)?;
647        if dims != frame.dims {
648            return Err(Error::InvalidContainer);
649        }
650        if let Some(alph) = frame.alph {
651            let plane = alpha_plane(alph, dims.width(), dims.height())?;
652            for (pixel, &a) in argb.iter_mut().zip(&plane) {
653                *pixel = (*pixel & 0x00FF_FFFF) | (u32::from(a) << 24);
654            }
655        }
656        // libwebp keys the compositor on whether an `ALPH` chunk is present.
657        Ok(crate::DecodedFrame {
658            argb,
659            alpha_used: frame.alph.is_some(),
660        })
661    }
662}
663
664/// Whether `input` is an animated WebP.
665///
666/// A cheap header probe (a `VP8X` animation flag, or an `ANIM`/`ANMF` chunk) that
667/// decodes no pixels and is codec-agnostic (it works for a lossy file the same as a
668/// lossless one).
669///
670/// # Errors
671///
672/// [`Error::NotWebp`]/[`Error::Truncated`] for a non-WebP or short input, or
673/// [`Error::InvalidContainer`] for a malformed `VP8X`.
674pub fn is_animated(input: &[u8]) -> Result<bool> {
675    crate::container::reader::is_animated(input)
676}
677
678/// The crate version, as reported by Cargo.
679#[must_use]
680pub const fn version() -> &'static str {
681    env!("CARGO_PKG_VERSION")
682}
683
684#[cfg(test)]
685mod tests {
686    use crate::container::fourcc::FourCc;
687    use crate::container::writer::{push_chunk, riff_envelope};
688
689    use super::{
690        BlendMode, DecodeOptions, Dimensions, DisposalMode, Effort, Encoder, Error, FrameMeta,
691        Image, ImageRef, IncrementalDecoder, Metadata, MetadataPolicy, PixelLayout, Progress,
692        decode, decode_with,
693    };
694
695    #[test]
696    fn round_trips_a_lossless_image_through_the_umbrella() {
697        // Encoder::lossless writes VP8L; decode() must route it back through crate::lossless.
698        let rgba = [10u8, 20, 30, 255, 40, 50, 60, 255];
699        let dims = Dimensions::new(2, 1).unwrap();
700        let img = ImageRef::new(dims, PixelLayout::Rgba8, &rgba).unwrap();
701        let file = Encoder::lossless().encode_ref(img).unwrap();
702        let decoded = decode(&file).unwrap();
703        assert_eq!(decoded.dimensions(), dims);
704        assert_eq!(decoded.as_bytes(), &rgba[..]);
705    }
706
707    #[test]
708    fn round_trips_a_lossy_image_through_the_umbrella() {
709        // Encoder::lossy writes a VP8 key frame; decode() routes it back
710        // through crate::lossy and returns an image of the right shape (lossy, so the
711        // pixels are close but not identical — only dimensions/opacity are pinned).
712        let mut rgba = Vec::new();
713        for y in 0..16u8 {
714            for x in 0..16u8 {
715                rgba.extend_from_slice(&[x * 16, y * 16, 128, 255]);
716            }
717        }
718        let dims = Dimensions::new(16, 16).unwrap();
719        let img = ImageRef::new(dims, PixelLayout::Rgba8, &rgba).unwrap();
720        let file = Encoder::lossy().quality(90).encode_ref(img).unwrap();
721        assert_eq!(&file[12..16], b"VP8 ", "lossy chunk fourcc");
722        let decoded = decode(&file).unwrap();
723        assert_eq!(decoded.dimensions(), dims);
724        assert!(decoded.as_bytes().chunks_exact(4).all(|p| p[3] == 0xff));
725    }
726
727    #[test]
728    fn lossy_alpha_round_trips_byte_exact_through_the_umbrella() {
729        // Encode a lossy image with a NON-TRIVIAL alpha channel (a radial-ish
730        // gradient plus fully-transparent and fully-opaque regions) and decode it
731        // back. Alpha is LOSSLESS, so the decoded alpha lane must equal the source
732        // byte-for-byte; the container must upgrade to the extended VP8X + ALPH form.
733        let (w, h) = (24u32, 20u32);
734        let mut rgba = Vec::new();
735        let mut source_alpha = Vec::new();
736        for y in 0..h {
737            for x in 0..w {
738                // Fully transparent top-left block, fully opaque bottom-right block,
739                // a smooth diagonal ramp elsewhere.
740                let a = if x < 4 && y < 4 {
741                    0
742                } else if x >= w - 4 && y >= h - 4 {
743                    255
744                } else {
745                    u8::try_from(((x + y) * 255) / (w + h - 2)).unwrap_or(255)
746                };
747                source_alpha.push(a);
748                let px = [
749                    u8::try_from((x * 9) & 0xff).unwrap_or(0),
750                    u8::try_from((y * 11) & 0xff).unwrap_or(0),
751                    100,
752                    a,
753                ];
754                rgba.extend_from_slice(&px);
755            }
756        }
757        let dims = Dimensions::new(w, h).unwrap();
758        let img = ImageRef::new(dims, PixelLayout::Rgba8, &rgba).unwrap();
759        let file = Encoder::lossy().quality(90).encode_ref(img).unwrap();
760        assert_eq!(
761            &file[12..16],
762            b"VP8X",
763            "alpha image must use the extended form"
764        );
765        assert!(
766            file.windows(4).any(|c| c == b"ALPH"),
767            "must carry an ALPH chunk"
768        );
769
770        let decoded = decode(&file).unwrap();
771        assert_eq!(decoded.dimensions(), dims);
772        assert!(decoded.has_alpha());
773        // The alpha lane (byte 3 of every Rgba8 pixel) is byte-exact vs the source.
774        let decoded_alpha: Vec<u8> = decoded.as_bytes().chunks_exact(4).map(|p| p[3]).collect();
775        assert_eq!(decoded_alpha, source_alpha, "alpha must be lossless");
776    }
777
778    #[test]
779    fn round_trips_each_lossy_effort_through_the_umbrella() {
780        // Every effort preset (the shared `Effort`, now common to both codecs)
781        // rides on the shared `Encoder::lossy` builder, so each effort must produce
782        // a decodable, correctly-sized, fully-opaque VP8 image.
783        let mut rgba = Vec::new();
784        for y in 0..16u8 {
785            for x in 0..16u8 {
786                rgba.extend_from_slice(&[x * 16, y * 16, 128, 255]);
787            }
788        }
789        let dims = Dimensions::new(16, 16).unwrap();
790        let img = ImageRef::new(dims, PixelLayout::Rgba8, &rgba).unwrap();
791        for effort in [Effort::Fast, Effort::Balanced, Effort::Best] {
792            let file = Encoder::lossy()
793                .quality(90)
794                .effort(effort)
795                .encode_ref(img)
796                .unwrap();
797            assert_eq!(&file[12..16], b"VP8 ", "{effort:?}: lossy chunk fourcc");
798            let decoded = decode(&file).unwrap();
799            assert_eq!(decoded.dimensions(), dims, "{effort:?}: dims");
800            assert!(
801                decoded.as_bytes().chunks_exact(4).all(|p| p[3] == 0xff),
802                "{effort:?}: not fully opaque"
803            );
804        }
805    }
806
807    #[test]
808    fn encode_image_lossy_preserves_metadata_through_the_umbrella() {
809        // `Encoder::lossy().encode(&Image)` routes an `Image` carrying metadata to
810        // the lossy encoder, which upgrades to the extended VP8X form and emits the
811        // ICCP/EXIF/XMP chunks. The lossless side is exercised by crate::lossless's own
812        // tests; here we confirm the lossy encode preserves metadata by default.
813        let mut rgba = Vec::new();
814        for y in 0..16u8 {
815            for x in 0..16u8 {
816                rgba.extend_from_slice(&[x * 16, y * 16, 128, 255]);
817            }
818        }
819        let dims = Dimensions::new(16, 16).unwrap();
820        let metadata = Metadata {
821            icc_profile: Some(b"icc".to_vec()),
822            exif: Some(b"exif".to_vec()),
823            xmp: Some(b"<x/>".to_vec()),
824        };
825        let img = Image::from_parts(dims, PixelLayout::Rgba8, rgba, false, metadata.clone());
826        let file = Encoder::lossy().quality(90).encode(&img).unwrap();
827        assert_eq!(
828            &file[12..16],
829            b"VP8X",
830            "metadata must force the extended form"
831        );
832        let find = |id: &[u8; 4]| -> Option<Vec<u8>> {
833            crate::container::reader::chunks(&file)
834                .unwrap()
835                .filter_map(Result::ok)
836                .find(|c| &c.id.0 == id)
837                .map(|c| c.data.to_vec())
838        };
839        assert_eq!(find(b"ICCP").as_deref(), metadata.icc_profile.as_deref());
840        assert_eq!(find(b"EXIF").as_deref(), metadata.exif.as_deref());
841        assert_eq!(find(b"XMP ").as_deref(), metadata.xmp.as_deref());
842        // The image still decodes to the right shape through the umbrella.
843        assert_eq!(decode(&file).unwrap().dimensions(), dims);
844    }
845
846    #[test]
847    fn lossy_decode_surfaces_vp8x_metadata_and_round_trips() {
848        // Decoding a lossy VP8X file must surface its ICCP/EXIF/XMP into the
849        // returned `Image`, so a decode → `encode_image` round trip preserves the
850        // color profile and sidecar metadata (matching the lossless path).
851        let mut rgba = Vec::new();
852        for y in 0..16u8 {
853            for x in 0..16u8 {
854                rgba.extend_from_slice(&[x * 16, y * 16, 200, 255]);
855            }
856        }
857        let dims = Dimensions::new(16, 16).unwrap();
858        let metadata = Metadata {
859            icc_profile: Some(b"icc-profile-bytes".to_vec()),
860            exif: Some(b"exif-bytes".to_vec()),
861            xmp: Some(b"<x:xmpmeta/>".to_vec()),
862        };
863        let img = Image::from_parts(dims, PixelLayout::Rgba8, rgba, false, metadata.clone());
864        let file = Encoder::lossy().quality(90).encode(&img).unwrap();
865
866        // A decoded lossy file carries its ICC/Exif/XMP metadata.
867        let decoded = decode(&file).unwrap();
868        assert_eq!(
869            decoded.metadata().icc_profile.as_deref(),
870            metadata.icc_profile.as_deref()
871        );
872        assert_eq!(decoded.metadata().exif.as_deref(), metadata.exif.as_deref());
873        assert_eq!(decoded.metadata().xmp.as_deref(), metadata.xmp.as_deref());
874
875        // Full round trip: re-encoding the decoded image preserves the metadata.
876        let refile = Encoder::lossy().quality(90).encode(&decoded).unwrap();
877        let round = decode(&refile).unwrap();
878        assert_eq!(
879            round.metadata(),
880            &metadata,
881            "decode → Encoder::lossy().encode must preserve metadata"
882        );
883
884        // A bare (metadata-free) lossy file still decodes to empty metadata.
885        let bare = Encoder::lossy()
886            .quality(90)
887            .encode_ref(ImageRef::new(dims, PixelLayout::Rgba8, img.as_bytes()).unwrap())
888            .unwrap();
889        assert_eq!(decode(&bare).unwrap().metadata(), &Metadata::none());
890    }
891
892    #[test]
893    fn dispatches_a_lossy_file_to_the_vp8_decoder() {
894        // A RIFF/`VP8 ` container with a valid key-frame header routes to
895        // crate::lossy, which reconstructs it to an image of the declared size.
896        let vp8_key_frame = [0x10u8, 0x00, 0x00, 0x9d, 0x01, 0x2a, 16, 0, 16, 0];
897        let mut body = Vec::new();
898        push_chunk(&mut body, FourCc::VP8, &vp8_key_frame);
899        let file = riff_envelope(&body);
900        let image = decode(&file).unwrap();
901        assert_eq!(image.dimensions(), Dimensions::new(16, 16).unwrap());
902    }
903
904    #[test]
905    fn composites_a_raw_alpha_chunk_onto_a_lossy_image() {
906        // A `VP8 ` 16x16 key-frame header plus a raw (method=0, filter=NONE) `ALPH`
907        // plane must decode to an image whose alpha channel is the plane's bytes.
908        let vp8_key_frame = [0x10u8, 0x00, 0x00, 0x9d, 0x01, 0x2a, 16, 0, 16, 0];
909        let mut alph = Vec::new();
910        alph.push(0x00u8); // method=0 (none), filter=0 (NONE), pre_processing=0
911        alph.resize(1 + 16 * 16, 0x80); // 256 alpha bytes, all 0x80
912        let mut body = Vec::new();
913        push_chunk(&mut body, FourCc::VP8, &vp8_key_frame);
914        push_chunk(&mut body, FourCc::ALPH, &alph);
915        let file = riff_envelope(&body);
916        let image = decode(&file).unwrap();
917        assert_eq!(image.dimensions(), Dimensions::new(16, 16).unwrap());
918        assert!(image.has_alpha());
919        // Default Rgba8 layout: the alpha lane is byte 3 of every pixel.
920        assert!(image.as_bytes().chunks_exact(4).all(|px| px[3] == 0x80));
921    }
922
923    #[test]
924    fn rejects_a_non_webp_input() {
925        // At least 12 bytes so the RIFF/WEBP magic check runs (a shorter input is
926        // reported as `Truncated` before the magic is even examined).
927        assert_eq!(
928            decode(b"definitely not a webp file").unwrap_err(),
929            Error::NotWebp
930        );
931    }
932
933    #[test]
934    fn unified_streams_a_bare_lossy_still_and_drains_rows() {
935        // A bare RIFF/`VP8 ` still routes to crate::lossy and row-streams: one-byte
936        // pushes reproduce the one-shot decode and expose rows via drain_rows. A
937        // real 32x24 stream (unlike a tiny header-only frame, whose payload only
938        // completes at EOF) sets up the still stream well before completion, so
939        // rows are genuinely drained incrementally.
940        let vp8 = include_bytes!("../tests/fixtures/noise_32x24_q30.vp8");
941        let mut body = Vec::new();
942        push_chunk(&mut body, FourCc::VP8, vp8);
943        let file = riff_envelope(&body);
944        let expected = decode(&file).unwrap();
945
946        let mut dec = IncrementalDecoder::new();
947        let mut drained = 0u32;
948        for byte in &file {
949            dec.push(core::slice::from_ref(byte)).unwrap();
950            if let Some(rows) = dec.drain_rows() {
951                drained += rows.rows;
952            }
953        }
954        assert!(drained > 0, "a lossy still must stream rows");
955        assert_eq!(dec.into_image().unwrap().as_bytes(), expected.as_bytes());
956    }
957
958    #[test]
959    fn unified_streams_a_lossless_still() {
960        // A VP8L still routes to crate::lossless and streams; the assembled image matches
961        // the one-shot decode.
962        let rgba: Vec<u8> = (0..16u8).flat_map(|i| [i * 3, i * 5, i * 7, 255]).collect();
963        let dims = Dimensions::new(4, 4).unwrap();
964        let file = Encoder::lossless()
965            .encode_ref(ImageRef::new(dims, PixelLayout::Rgba8, &rgba).unwrap())
966            .unwrap();
967        let expected = decode(&file).unwrap();
968
969        let mut dec = IncrementalDecoder::new();
970        for chunk in file.chunks(3) {
971            dec.push(chunk).unwrap();
972        }
973        assert_eq!(dec.into_image().unwrap().as_bytes(), expected.as_bytes());
974    }
975
976    #[test]
977    fn unified_defers_a_lossy_still_with_alpha() {
978        // `VP8 ` + `ALPH` (alpha present) is deferred to the one-shot decode: no
979        // rows stream, but into_image composites the alpha exactly like decode().
980        let vp8 = [0x10u8, 0x00, 0x00, 0x9d, 0x01, 0x2a, 16, 0, 16, 0];
981        let mut alph = vec![0x00u8]; // method=0, filter=NONE
982        alph.resize(1 + 16 * 16, 0x80);
983        let mut body = Vec::new();
984        push_chunk(&mut body, FourCc::VP8, &vp8);
985        push_chunk(&mut body, FourCc::ALPH, &alph);
986        let file = riff_envelope(&body);
987        let expected = decode(&file).unwrap();
988
989        let mut dec = IncrementalDecoder::new();
990        let mut any_drain = false;
991        for byte in &file {
992            dec.push(core::slice::from_ref(byte)).unwrap();
993            any_drain |= dec.drain_rows().is_some();
994        }
995        assert!(!any_drain, "the deferred alpha path streams no rows");
996        let image = dec.into_image().unwrap();
997        assert!(image.has_alpha());
998        assert_eq!(image.as_bytes(), expected.as_bytes());
999    }
1000
1001    #[test]
1002    fn decode_with_enforces_max_pixels_on_a_lossless_still() {
1003        // The umbrella decode_with propagates the pixel limit to the lossless
1004        // decoder, which rejects an 8x8 (64px) image *before* allocating pixels.
1005        let rgba: Vec<u8> = (0u8..64).flat_map(|i| [i, 0, 0, 255]).collect();
1006        let dims = Dimensions::new(8, 8).unwrap();
1007        let img = ImageRef::new(dims, PixelLayout::Rgba8, &rgba).unwrap();
1008        let file = Encoder::lossless().encode_ref(img).unwrap();
1009        assert_eq!(
1010            decode_with(&file, &DecodeOptions::default().max_pixels(10)).unwrap_err(),
1011            Error::LimitExceeded {
1012                pixels: 64,
1013                limit: 10,
1014            }
1015        );
1016        // A 64px image is far under the default `DEFAULT_MAX_PIXELS` cap, so the
1017        // default options still decode it.
1018        assert_eq!(
1019            decode_with(&file, &DecodeOptions::default())
1020                .unwrap()
1021                .dimensions(),
1022            dims
1023        );
1024    }
1025
1026    #[test]
1027    fn plain_decode_is_bounded_by_default_on_a_hostile_header() {
1028        // Safe by default: a bare `decode` (no options) must reject a header that
1029        // claims a huge canvas — here a 16383x16383 (≈268 Mpx) lossy `VP8 ` key
1030        // frame, well past `DEFAULT_MAX_PIXELS` (100 Mpx) — *before* any plane is
1031        // allocated, so only the 10-byte header is needed. A regression that let
1032        // `decode` bypass the default cap would allocate ≈1 GiB here instead.
1033        let vp8 = [0x10u8, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0xFF, 0x3F, 0xFF, 0x3F];
1034        let mut body = Vec::new();
1035        push_chunk(&mut body, FourCc::VP8, &vp8);
1036        let file = riff_envelope(&body);
1037        assert!(
1038            matches!(decode(&file), Err(Error::LimitExceeded { limit, .. })
1039                if limit == crate::DEFAULT_MAX_PIXELS),
1040            "plain decode must enforce DEFAULT_MAX_PIXELS on an oversized header"
1041        );
1042        // `.unbounded()` is the explicit escape hatch: the same header now passes the
1043        // pixel guard (and fails later on the truncated body, not on the cap).
1044        assert!(
1045            !matches!(
1046                decode_with(&file, &DecodeOptions::default().unbounded()),
1047                Err(Error::LimitExceeded { .. })
1048            ),
1049            "unbounded() must lift the pixel cap"
1050        );
1051    }
1052
1053    #[test]
1054    fn decode_with_enforces_max_pixels_on_lossy_still_and_frames() {
1055        // A bare 16x16 lossy `VP8 ` still: decode_with rejects it before planes.
1056        let vp8 = [0x10u8, 0x00, 0x00, 0x9d, 0x01, 0x2a, 16, 0, 16, 0];
1057        let mut body = Vec::new();
1058        push_chunk(&mut body, FourCc::VP8, &vp8);
1059        let file = riff_envelope(&body);
1060        assert_eq!(
1061            decode_with(&file, &DecodeOptions::default().max_pixels(4)).unwrap_err(),
1062            Error::LimitExceeded {
1063                pixels: 256,
1064                limit: 4,
1065            }
1066        );
1067
1068        // An animation: decode_with routes to the first composited frame through
1069        // decode_frames_with, which rejects a 2x2 (4px) frame under a 1-pixel cap.
1070        let canvas = Dimensions::new(2, 2).unwrap();
1071        let red = [255u8, 0, 0, 255].repeat(4);
1072        let meta = FrameMeta {
1073            x: 0,
1074            y: 0,
1075            dimensions: canvas,
1076            duration_ms: 100,
1077            blend: BlendMode::Blend,
1078            dispose: DisposalMode::Keep,
1079        };
1080        let anim = crate::lossless::AnimationEncoder::new(canvas)
1081            .add_frame(
1082                ImageRef::new(canvas, PixelLayout::Rgba8, &red).unwrap(),
1083                meta,
1084            )
1085            .unwrap()
1086            .finish();
1087        assert!(matches!(
1088            decode_with(&anim, &DecodeOptions::default().max_pixels(1)),
1089            Err(Error::LimitExceeded { .. })
1090        ));
1091    }
1092
1093    #[test]
1094    fn metadata_policy_is_reexported_from_the_umbrella() {
1095        // The umbrella re-exports the shared `MetadataPolicy` so a caller can name
1096        // the `Encoder::metadata_policy` argument type through `webpkit`.
1097        let dims = Dimensions::new(1, 1).unwrap();
1098        let metadata = Metadata {
1099            icc_profile: Some(vec![1]),
1100            exif: Some(vec![2]),
1101            xmp: Some(vec![3]),
1102        };
1103        let img = Image::from_parts(
1104            dims,
1105            PixelLayout::Rgba8,
1106            vec![0, 0, 0, 255],
1107            false,
1108            metadata,
1109        );
1110        let file = Encoder::lossless()
1111            .metadata_policy(MetadataPolicy::StripPrivate)
1112            .encode(&img)
1113            .unwrap();
1114        let decoded = decode(&file).unwrap();
1115        // StripPrivate keeps ICC, drops the privacy-bearing Exif/XMP sidecars.
1116        assert_eq!(decoded.metadata().icc_profile.as_deref(), Some(&[1][..]));
1117        assert_eq!(decoded.metadata().exif, None);
1118        assert_eq!(decoded.metadata().xmp, None);
1119    }
1120
1121    #[test]
1122    fn unified_streams_animation_frames() {
1123        // An animation routes to crate::lossless's frame walker: each frame composites and
1124        // exposes its canvas; into_image returns the first composited frame.
1125        let canvas = Dimensions::new(2, 2).unwrap();
1126        let red = [255u8, 0, 0, 255].repeat(4);
1127        let blue = [0u8, 0, 255, 255].repeat(4);
1128        let meta = |ms| FrameMeta {
1129            x: 0,
1130            y: 0,
1131            dimensions: canvas,
1132            duration_ms: ms,
1133            blend: BlendMode::Blend,
1134            dispose: DisposalMode::Keep,
1135        };
1136        let file = crate::lossless::AnimationEncoder::new(canvas)
1137            .add_frame(
1138                ImageRef::new(canvas, PixelLayout::Rgba8, &red).unwrap(),
1139                meta(100),
1140            )
1141            .unwrap()
1142            .add_frame(
1143                ImageRef::new(canvas, PixelLayout::Rgba8, &blue).unwrap(),
1144                meta(100),
1145            )
1146            .unwrap()
1147            .finish();
1148        let expected = decode(&file).unwrap();
1149
1150        let mut dec = IncrementalDecoder::new();
1151        let mut chunks = file.chunks(5);
1152        let mut frames = 0;
1153        loop {
1154            let progress = match chunks.next() {
1155                Some(chunk) => dec.push(chunk),
1156                None => dec.push(&[]),
1157            }
1158            .unwrap();
1159            match progress {
1160                Progress::FrameComplete(_) => {
1161                    frames += 1;
1162                    assert!(dec.frame_image().is_some());
1163                },
1164                Progress::Finished => break,
1165                _ => {},
1166            }
1167        }
1168        assert_eq!(frames, 2, "both frames composited");
1169        assert_eq!(dec.into_image().unwrap().as_bytes(), expected.as_bytes());
1170    }
1171}