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}