Skip to main content

oxideav_webp/
registry.rs

1//! `oxideav-core` integration — `Decoder` trait impl, `Frame` / `Error`
2//! conversions, and the [`register`] entry point.
3//!
4//! Gated behind the default-on `registry` Cargo feature so consumers
5//! that just want the standalone walker / builder / decoder surface can
6//! depend on `oxideav-webp` with `default-features = false` and skip
7//! the `oxideav-core` dependency entirely.
8//!
9//! The module exposes:
10//!
11//! * [`register`] / [`register_codecs`] / [`register_containers`] — the
12//!   `CodecRegistry` / `ContainerRegistry` entry points the umbrella
13//!   `oxideav` crate calls during framework initialisation.
14//! * [`WebpDecoder`] — the `Decoder` trait impl that wraps the
15//!   framework-free [`crate::decode_webp_image`] entry point.
16//! * [`decode_webp_to_frame`] — a `VideoFrame`-flavoured wrapper around
17//!   [`crate::decode_webp_image`] preserved for callers that prefer the
18//!   direct conversion to the framework's frame type.
19//! * The `From<Error> for oxideav_core::Error` conversion that lets the
20//!   trait impl use `?` on errors returned by the framework-free decode
21//!   path.
22//!
23//! Per round 112, the registered decoder covers:
24//!
25//! * **§2.6 / §3.4 `VP8L` lossless** (simple or `VP8X`-extended) —
26//!   decoded all the way to interleaved 8-bit RGBA, surfaced as a single
27//!   planar [`VideoFrame`] with stride `width * 4` and
28//!   [`PixelFormat::Rgba`].
29//! * **§2.7.1.2 `ALPH`-over-`VP8L` alpha override** — the alpha plane
30//!   from an accompanying `ALPH` chunk overrides the per-pixel alpha
31//!   from the `VP8L` bitstream itself.
32//! * **§2.5 `VP8 ` lossy** — a clean `Error::Unsupported`. WebP's lossy
33//!   path is a VP8 bitstream; `oxideav-webp` deliberately does **not**
34//!   take a runtime dependency on `oxideav-vp8`. Callers that need lossy
35//!   should route the chunk via [`crate::extract_lossy_chunk`] to a
36//!   downstream VP8 decoder.
37//! * **Animations / header-only files** (no `VP8L`/`VP8 ` image-data
38//!   chunk) — a clean `Error::Unsupported`.
39
40use std::collections::VecDeque;
41
42use oxideav_core::{
43    CodecCapabilities, CodecId, CodecInfo, CodecParameters, CodecRegistry, CodecTag,
44    ContainerRegistry, Decoder, Encoder, Error as CoreError, Frame, MediaType, Packet, PixelFormat,
45    RuntimeContext, TimeBase, VideoFrame, VideoPlane,
46};
47
48use crate::{
49    decode_webp_image, encode_vp8l_argb_with_metadata, DecodedWebp, Error, UnsupportedKind,
50    WebpError, WebpMetadata, WebpMetadataOwned, CODEC_ID_VP8L,
51};
52
53/// Stable on-wire identifier this crate registers under in the codec
54/// registry. The single canonical value the framework uses to look up
55/// a WebP decoder is `"webp"`.
56pub const CODEC_ID_STR: &str = "webp";
57
58/// Bridge crate-local errors to the framework-wide `oxideav_core::Error`
59/// so trait impls can use `?` on the framework-free decode path.
60///
61/// `Error::Unsupported(LossyVp8)` and `Error::Unsupported(NoImageData)`
62/// both map to `oxideav_core::Error::Unsupported(...)`. Every other
63/// variant carries diagnostic text already built by the originating
64/// sub-module's `Display` impl — it's surfaced verbatim via
65/// `Error::InvalidData(...)`.
66impl From<Error> for CoreError {
67    fn from(e: Error) -> Self {
68        match e {
69            Error::Unsupported(kind) => CoreError::Unsupported(match kind {
70                UnsupportedKind::LossyVp8 => {
71                    "oxideav-webp: VP8 lossy bitstream (route to a VP8 decoder)".to_string()
72                }
73                UnsupportedKind::NoImageData => {
74                    "oxideav-webp: no VP8L/VP8 image-data chunk (animation or header-only)"
75                        .to_string()
76                }
77            }),
78            Error::NotImplemented => {
79                CoreError::Unsupported("oxideav-webp: code path not implemented yet".to_string())
80            }
81            // A VP8 inter-frame is a recognised-but-unsupported feature,
82            // not a corrupt bitstream; every other VP8 decode failure is
83            // a bitstream problem.
84            Error::Vp8(ref v) => match WebpError::from(v.clone()) {
85                WebpError::Unsupported => CoreError::Unsupported(e.to_string()),
86                _ => CoreError::InvalidData(e.to_string()),
87            },
88            other => CoreError::InvalidData(other.to_string()),
89        }
90    }
91}
92
93// ───────────────────────── Frame conversion ─────────────────────────
94
95/// Convert a fully-decoded [`DecodedWebp`] into a single-planar
96/// [`VideoFrame`] carrying interleaved 8-bit RGBA.
97///
98/// Stride is exactly `width * 4` bytes (no row padding). Stream-level
99/// width / height / pixel format live on [`CodecParameters`], not the
100/// frame — consumers read them from [`WebpDecoder::params`] (which is
101/// updated to the decoded dimensions after the first frame).
102fn decoded_webp_to_video_frame(img: DecodedWebp, pts: Option<i64>) -> VideoFrame {
103    let stride = (img.width as usize).saturating_mul(4);
104    VideoFrame {
105        pts,
106        planes: vec![VideoPlane {
107            stride,
108            data: img.rgba,
109        }],
110    }
111}
112
113/// `VideoFrame`-flavoured wrapper around [`decode_webp_image`].
114///
115/// Preserved for callers that already build [`VideoFrame`]s directly
116/// without going through the [`Decoder`] trait (e.g. container demuxers
117/// that want to drop a still WebP picture into a video stream).
118pub fn decode_webp_to_frame(bytes: &[u8], pts: Option<i64>) -> oxideav_core::Result<VideoFrame> {
119    let img = decode_webp_image(bytes)?;
120    Ok(decoded_webp_to_video_frame(img, pts))
121}
122
123// ───────────────────────── Decoder + factory ─────────────────────────
124
125/// Factory for the `Decoder` trait impl — installed in the codec
126/// registry and called by the framework when a `webp` packet stream
127/// needs decoding.
128pub fn make_decoder(params: &CodecParameters) -> oxideav_core::Result<Box<dyn Decoder>> {
129    Ok(Box::new(WebpDecoder::new(params.clone())))
130}
131
132/// WebP [`Decoder`] trait impl.
133///
134/// Each `send_packet` carries one complete `RIFF/WEBP` file (still
135/// image — animations not yet implemented). The matching `receive_frame`
136/// returns a [`Frame::Video`] holding interleaved 8-bit RGBA pixels.
137///
138/// The decoder caches the most recently observed image dimensions on
139/// its [`CodecParameters`] — see [`Self::params`] — so consumers can
140/// pull width / height / pixel format after the first
141/// `receive_frame`. Before a packet has been decoded the values come
142/// from whatever `CodecParameters` the factory was constructed with
143/// (typically empty width / height for a still-image codec).
144#[derive(Debug)]
145pub struct WebpDecoder {
146    /// Output stream parameters. `pixel_format` is fixed to
147    /// [`PixelFormat::Rgba`] at construction (every supported WebP
148    /// image kind decodes to RGBA today); `width` / `height` are
149    /// refreshed after each successful decode.
150    params: CodecParameters,
151    /// Most-recently received packet, consumed by the next
152    /// `receive_frame` call. The contract matches the framework's
153    /// one-packet-in / one-frame-out pattern for image codecs (see e.g.
154    /// the PNG / ICER impls).
155    pending: Option<Packet>,
156    /// `true` after `flush()` — the next `receive_frame` with an empty
157    /// pending slot returns `Eof` instead of `NeedMore`.
158    eof: bool,
159}
160
161impl WebpDecoder {
162    /// Build a decoder whose output [`CodecParameters`] start from
163    /// `params`. The factory always passes the caller-supplied
164    /// `CodecParameters` here; the dimensions and pixel format are
165    /// re-derived from each successfully decoded frame so the field is
166    /// authoritative after the first `receive_frame`.
167    pub fn new(params: CodecParameters) -> Self {
168        let mut p = params;
169        p.media_type = MediaType::Video;
170        p.codec_id = CodecId::new(CODEC_ID_STR);
171        p.pixel_format = Some(PixelFormat::Rgba);
172        Self {
173            params: p,
174            pending: None,
175            eof: false,
176        }
177    }
178
179    /// Reference to the decoder's [`CodecParameters`]. After the first
180    /// successful `receive_frame` the `width`, `height`, and
181    /// `pixel_format` fields reflect the decoded image; before that
182    /// they hold the factory-supplied values.
183    pub fn params(&self) -> &CodecParameters {
184        &self.params
185    }
186}
187
188impl Decoder for WebpDecoder {
189    fn codec_id(&self) -> &CodecId {
190        &self.params.codec_id
191    }
192
193    fn send_packet(&mut self, packet: &Packet) -> oxideav_core::Result<()> {
194        if self.pending.is_some() {
195            return Err(CoreError::other(
196                "oxideav-webp decoder: receive_frame must be called before sending another packet",
197            ));
198        }
199        self.pending = Some(packet.clone());
200        Ok(())
201    }
202
203    fn receive_frame(&mut self) -> oxideav_core::Result<Frame> {
204        let Some(pkt) = self.pending.take() else {
205            return if self.eof {
206                Err(CoreError::Eof)
207            } else {
208                Err(CoreError::NeedMore)
209            };
210        };
211        let img = decode_webp_image(&pkt.data)?;
212        // Surface the decoded geometry on the decoder's params so
213        // downstream consumers (filter graphs, sinks, the
214        // `oxideav probe` command) can read width / height after the
215        // first frame.
216        self.params.width = Some(img.width);
217        self.params.height = Some(img.height);
218        self.params.pixel_format = Some(PixelFormat::Rgba);
219        let vf = decoded_webp_to_video_frame(img, pkt.pts);
220        Ok(Frame::Video(vf))
221    }
222
223    fn flush(&mut self) -> oxideav_core::Result<()> {
224        self.eof = true;
225        Ok(())
226    }
227}
228
229// ───────────────────────── Encoder + factory ─────────────────────────
230
231/// Repack one interleaved-pixel [`VideoFrame`] plane into scan-line ARGB
232/// (`(a << 24) | (r << 16) | (g << 8) | b`), the layout the VP8L encoder
233/// consumes.
234///
235/// Accepts the two input pixel formats the published `webp_vp8l` codec
236/// declares: [`PixelFormat::Rgba`] (4 B/px) and [`PixelFormat::Rgb24`]
237/// (3 B/px, treated as fully opaque, streamed without a 3→4 expansion
238/// alloc). Both read the plane's `stride` so a padded source row is handled.
239fn video_frame_to_argb(
240    frame: &VideoFrame,
241    width: u32,
242    height: u32,
243    pix: PixelFormat,
244) -> oxideav_core::Result<(Vec<u32>, bool)> {
245    let plane = frame
246        .planes
247        .first()
248        .ok_or_else(|| CoreError::invalid("webp_vp8l encoder: frame has no planes"))?;
249    let w = width as usize;
250    let h = height as usize;
251    let stride = plane.stride;
252    let mut pixels = Vec::with_capacity(w * h);
253    let mut alpha_is_used = false;
254    match pix {
255        PixelFormat::Rgba => {
256            for y in 0..h {
257                let row = &plane.data[y * stride..];
258                for x in 0..w {
259                    let p = &row[x * 4..x * 4 + 4];
260                    let (r, g, b, a) = (p[0] as u32, p[1] as u32, p[2] as u32, p[3] as u32);
261                    if a != 0xff {
262                        alpha_is_used = true;
263                    }
264                    pixels.push((a << 24) | (r << 16) | (g << 8) | b);
265                }
266            }
267        }
268        PixelFormat::Rgb24 => {
269            for y in 0..h {
270                let row = &plane.data[y * stride..];
271                for x in 0..w {
272                    let p = &row[x * 3..x * 3 + 3];
273                    let (r, g, b) = (p[0] as u32, p[1] as u32, p[2] as u32);
274                    pixels.push((0xff << 24) | (r << 16) | (g << 8) | b);
275                }
276            }
277        }
278        other => {
279            return Err(CoreError::invalid(format!(
280                "webp_vp8l encoder: unsupported input pixel format {other:?} (want Rgba or Rgb24)"
281            )));
282        }
283    }
284    Ok((pixels, alpha_is_used))
285}
286
287/// Factory for the VP8L `Encoder` trait impl — installed in the codec
288/// registry under [`CODEC_ID_VP8L`] and called by the framework when a
289/// `webp_vp8l` encode is requested.
290///
291/// Reads `width` / `height` / `pixel_format` from `params`; the encoder
292/// accepts [`PixelFormat::Rgba`] or [`PixelFormat::Rgb24`] input and always
293/// emits a §2.6 `VP8L` lossless `.webp`. ICC / Exif / XMP metadata is
294/// carried as a [`WebpMetadataOwned`] derived from `params.extradata` is
295/// **not** assumed here — the framework path embeds no metadata; the direct
296/// factory [`make_encoder_with_metadata`] takes it explicitly.
297pub fn make_encoder(params: &CodecParameters) -> oxideav_core::Result<Box<dyn Encoder>> {
298    make_encoder_with_metadata(params, WebpMetadataOwned::default())
299}
300
301/// Direct factory: build a VP8L encoder embedding the supplied file-level
302/// metadata (ICC / Exif / XMP) into every encoded `.webp`.
303///
304/// The dual-API counterpart of [`make_encoder`] — the registry path uses the
305/// no-metadata form, while a direct caller that wants to embed an ICC
306/// profile / Exif / XMP block constructs the encoder through this factory.
307pub fn make_encoder_with_metadata(
308    params: &CodecParameters,
309    metadata: WebpMetadataOwned,
310) -> oxideav_core::Result<Box<dyn Encoder>> {
311    let width = params
312        .width
313        .ok_or_else(|| CoreError::invalid("webp_vp8l encoder: missing width"))?;
314    let height = params
315        .height
316        .ok_or_else(|| CoreError::invalid("webp_vp8l encoder: missing height"))?;
317    let pix = params.pixel_format.unwrap_or(PixelFormat::Rgba);
318    if !matches!(pix, PixelFormat::Rgba | PixelFormat::Rgb24) {
319        return Err(CoreError::invalid(format!(
320            "webp_vp8l encoder: unsupported input pixel format {pix:?} (want Rgba or Rgb24)"
321        )));
322    }
323
324    let mut output_params = params.clone();
325    output_params.media_type = MediaType::Video;
326    output_params.codec_id = CodecId::new(CODEC_ID_VP8L);
327    output_params.width = Some(width);
328    output_params.height = Some(height);
329    output_params.pixel_format = Some(pix);
330
331    Ok(Box::new(WebpVp8lEncoder {
332        output_params,
333        width,
334        height,
335        pix,
336        metadata,
337        pending_out: VecDeque::new(),
338        eof: false,
339    }))
340}
341
342/// WebP VP8L (lossless) [`Encoder`] trait impl.
343///
344/// One frame in → one `.webp` packet out. Each `send_frame` carries an
345/// interleaved RGBA / RGB24 picture; the matching `receive_packet` (after
346/// the frame, or on flush) emits a complete §2.6 / §2.7 `.webp` file. The
347/// output auto-promotes to the extended `VP8X` layout when the frame carries
348/// alpha or the encoder was constructed with non-empty metadata.
349#[derive(Debug)]
350pub struct WebpVp8lEncoder {
351    output_params: CodecParameters,
352    width: u32,
353    height: u32,
354    pix: PixelFormat,
355    metadata: WebpMetadataOwned,
356    pending_out: VecDeque<Packet>,
357    eof: bool,
358}
359
360impl Encoder for WebpVp8lEncoder {
361    fn codec_id(&self) -> &CodecId {
362        &self.output_params.codec_id
363    }
364
365    fn output_params(&self) -> &CodecParameters {
366        &self.output_params
367    }
368
369    fn send_frame(&mut self, frame: &Frame) -> oxideav_core::Result<()> {
370        let Frame::Video(v) = frame else {
371            return Err(CoreError::invalid("webp_vp8l encoder: video frames only"));
372        };
373        let (argb, frame_alpha) = video_frame_to_argb(v, self.width, self.height, self.pix)?;
374        let has_alpha = frame_alpha;
375        let meta = self.metadata.as_borrowed();
376        let bytes =
377            encode_vp8l_argb_with_metadata(self.width, self.height, &argb, has_alpha, &meta)
378                .map_err(|e| CoreError::InvalidData(e.to_string()))?;
379        let mut pkt = Packet::new(0, TimeBase::new(1, 1000), bytes);
380        pkt.pts = v.pts;
381        pkt.dts = v.pts;
382        pkt.flags.keyframe = true;
383        self.pending_out.push_back(pkt);
384        Ok(())
385    }
386
387    fn receive_packet(&mut self) -> oxideav_core::Result<Packet> {
388        if let Some(p) = self.pending_out.pop_front() {
389            return Ok(p);
390        }
391        if self.eof {
392            Err(CoreError::Eof)
393        } else {
394            Err(CoreError::NeedMore)
395        }
396    }
397
398    fn flush(&mut self) -> oxideav_core::Result<()> {
399        self.eof = true;
400        Ok(())
401    }
402}
403
404/// `Vec<u8>`-flavoured wrapper around [`encode_vp8l_argb_with_metadata`] —
405/// repacks a [`VideoFrame`] (Rgba / Rgb24) into ARGB and encodes a `.webp`.
406///
407/// Preserved alongside the [`Encoder`] trait impl for callers that already
408/// build [`VideoFrame`]s directly and want a one-shot `.webp` without the
409/// trait plumbing (the dual-API direct path).
410pub fn encode_vp8l_frame(
411    frame: &VideoFrame,
412    width: u32,
413    height: u32,
414    pix: PixelFormat,
415    metadata: &WebpMetadata<'_>,
416) -> oxideav_core::Result<Vec<u8>> {
417    let (argb, alpha_is_used) = video_frame_to_argb(frame, width, height, pix)?;
418    encode_vp8l_argb_with_metadata(width, height, &argb, alpha_is_used, metadata)
419        .map_err(|e| CoreError::InvalidData(e.to_string()))
420}
421
422// ───────────────────────── Registration ─────────────────────────
423
424/// Register the WebP decoder factory into a [`CodecRegistry`].
425///
426/// One [`CodecInfo`] is emitted under `CodecId("webp")` with the
427/// [`PixelFormat::Rgba`] output declared on its capabilities. The codec
428/// claims the `WEBP` FourCC on the off-chance a generic container
429/// wraps a WebP still-image payload under that tag; everyday WebP
430/// files live inside `RIFF/WEBP` and are routed via the file-extension
431/// hook installed by [`register_containers`].
432pub fn register_codecs(reg: &mut CodecRegistry) {
433    let caps = CodecCapabilities::video("webp_sw")
434        .with_intra_only(true)
435        .with_lossless(true)
436        .with_max_size(16384, 16384)
437        .with_pixel_formats(vec![PixelFormat::Rgba]);
438    reg.register(
439        CodecInfo::new(CodecId::new(CODEC_ID_STR))
440            .capabilities(caps)
441            .decoder(make_decoder)
442            .tag(CodecTag::fourcc(b"WEBP")),
443    );
444
445    // VP8L lossless encoder codec. Accepts Rgba / Rgb24 input and emits a
446    // §2.6 / §2.7 VP8L `.webp`; also exposes the decoder under the same id
447    // so a `webp_vp8l` stream round-trips through one codec entry.
448    let vp8l_caps = CodecCapabilities::video("webp_vp8l_sw")
449        .with_intra_only(true)
450        .with_lossless(true)
451        .with_max_size(16384, 16384)
452        .with_pixel_formats(vec![PixelFormat::Rgba, PixelFormat::Rgb24]);
453    reg.register(
454        CodecInfo::new(CodecId::new(CODEC_ID_VP8L))
455            .capabilities(vp8l_caps)
456            .decoder(make_decoder)
457            .encoder(make_encoder),
458    );
459}
460
461/// Register the `.webp` file extension so a `RuntimeContext` can map a
462/// filename hint back to the WebP codec id.
463///
464/// WebP is its own container (`RIFF/WEBP`); this crate handles the
465/// container walking via [`crate::parse_container`] directly rather
466/// than via a separate `Demuxer` registration, so only the extension
467/// hook is installed here.
468pub fn register_containers(reg: &mut ContainerRegistry) {
469    reg.register_extension("webp", CODEC_ID_STR);
470}
471
472/// Unified registration entry point: install both the WebP decoder
473/// factory and the `.webp` extension hint into the supplied
474/// [`RuntimeContext`].
475pub fn register(ctx: &mut RuntimeContext) {
476    register_codecs(&mut ctx.codecs);
477    register_containers(&mut ctx.containers);
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use oxideav_core::TimeBase;
484
485    const LOSSLESS_1X1: &[u8] = include_bytes!("../tests/data/lossless-1x1.webp");
486    const LOSSY_1X1: &[u8] = include_bytes!("../tests/data/lossy-1x1.webp");
487
488    #[test]
489    fn register_via_runtime_context_installs_decoder_factory() {
490        let mut ctx = RuntimeContext::new();
491        register(&mut ctx);
492        let id = CodecId::new(CODEC_ID_STR);
493        assert!(
494            ctx.codecs.has_decoder(&id),
495            "webp decoder factory not installed via RuntimeContext"
496        );
497        // Encoder side stays unwired in round 112.
498        assert!(!ctx.codecs.has_encoder(&id));
499        // .webp file-extension hint is wired via the same call.
500        assert_eq!(ctx.containers.container_for_extension("webp"), Some("webp"));
501        assert_eq!(ctx.containers.container_for_extension("WEBP"), Some("webp"));
502    }
503
504    #[test]
505    fn register_via_runtime_context_resolves_webp_fourcc_tag() {
506        // FourCC tag claim — confirms the codec can be looked up
507        // through `CodecRegistry::resolve_tag_ref` by an upstream
508        // demuxer that surfaces a `WEBP` tag.
509        use oxideav_core::ProbeContext;
510        let mut ctx = RuntimeContext::new();
511        register(&mut ctx);
512        let tag = CodecTag::fourcc(b"WEBP");
513        let id = ctx
514            .codecs
515            .resolve_tag_ref(&ProbeContext::new(&tag))
516            .expect("WEBP fourcc resolves to a registered codec");
517        assert_eq!(id.as_str(), CODEC_ID_STR);
518    }
519
520    #[test]
521    fn first_decoder_returns_a_webp_decoder() {
522        let mut ctx = RuntimeContext::new();
523        register(&mut ctx);
524        let params = CodecParameters::video(CodecId::new(CODEC_ID_STR));
525        let dec = ctx
526            .codecs
527            .first_decoder(&params)
528            .expect("webp decoder factory");
529        assert_eq!(dec.codec_id().as_str(), CODEC_ID_STR);
530    }
531
532    #[test]
533    fn end_to_end_lossless_decode_via_runtime_context() {
534        // The user-facing dispatch path: build the context, look up the
535        // factory by codec id, push one packet, read one frame.
536        let mut ctx = RuntimeContext::new();
537        register(&mut ctx);
538        let params = CodecParameters::video(CodecId::new(CODEC_ID_STR));
539        let mut dec = ctx
540            .codecs
541            .first_decoder(&params)
542            .expect("webp decoder factory");
543
544        let pkt = Packet::new(0, TimeBase::new(1, 1000), LOSSLESS_1X1.to_vec());
545        dec.send_packet(&pkt).expect("send_packet accepts file");
546        let frame = dec.receive_frame().expect("receive_frame yields a frame");
547        let v = match frame {
548            Frame::Video(v) => v,
549            other => panic!("expected Frame::Video, got {other:?}"),
550        };
551        assert_eq!(v.planes.len(), 1, "RGBA is a single interleaved plane");
552        assert_eq!(v.planes[0].stride, 4, "1px-wide × 4 bytes/pixel");
553        assert_eq!(v.planes[0].data.len(), 4, "1×1 image × 4 bytes/pixel");
554        // lossless-1x1 fixture: ARGB 0xFFB43C5A → RGBA bytes 0xB4 0x3C
555        // 0x5A 0xFF (R=180, G=60, B=90, A=255).
556        assert_eq!(v.planes[0].data, [0xB4, 0x3C, 0x5A, 0xFF]);
557
558        // After a successful decode we should be back to NeedMore.
559        let again = dec.receive_frame();
560        assert!(matches!(again, Err(CoreError::NeedMore)));
561    }
562
563    #[test]
564    fn vp8_lossy_packet_decodes_via_registered_decoder() {
565        // Round 124: the §2.5 `VP8 ` lossy path is decoded through the
566        // `oxideav-vp8` sibling crate, so the registered decoder now
567        // yields a frame (previously a clean Unsupported). The 1x1
568        // reference-encoder-produced 1x1 fixture decodes to a single 1px RGBA frame.
569        let mut ctx = RuntimeContext::new();
570        register(&mut ctx);
571        let params = CodecParameters::video(CodecId::new(CODEC_ID_STR));
572        let mut dec = ctx
573            .codecs
574            .first_decoder(&params)
575            .expect("webp decoder factory");
576        let pkt = Packet::new(0, TimeBase::new(1, 1000), LOSSY_1X1.to_vec());
577        dec.send_packet(&pkt).expect("send_packet accepts file");
578        let frame = dec
579            .receive_frame()
580            .expect("VP8 lossy now decodes via oxideav-vp8");
581        let v = match frame {
582            Frame::Video(v) => v,
583            other => panic!("expected Frame::Video, got {other:?}"),
584        };
585        assert_eq!(v.planes.len(), 1, "RGBA is a single interleaved plane");
586        assert_eq!(v.planes[0].data.len(), 4, "1×1 image × 4 bytes/pixel");
587        // No ALPH chunk on the simple-lossy fixture → opaque alpha.
588        assert_eq!(v.planes[0].data[3], 0xff);
589        // Back to NeedMore after the single frame.
590        let again = dec.receive_frame();
591        assert!(matches!(again, Err(CoreError::NeedMore)));
592    }
593
594    #[test]
595    fn decoder_params_carry_dims_and_pixel_format_after_first_frame() {
596        // Pre-decode: pixel format is set at construction; width and
597        // height are empty (the still-image codec doesn't know them
598        // before the first packet has been parsed).
599        let mut dec = WebpDecoder::new(CodecParameters::video(CodecId::new(CODEC_ID_STR)));
600        assert_eq!(dec.params().pixel_format, Some(PixelFormat::Rgba));
601        assert_eq!(dec.params().width, None);
602        assert_eq!(dec.params().height, None);
603
604        // Push the 1×1 fixture and pump the loop. Params should now
605        // reflect the decoded dimensions.
606        let pkt = Packet::new(0, TimeBase::new(1, 1000), LOSSLESS_1X1.to_vec());
607        dec.send_packet(&pkt).unwrap();
608        let _ = dec.receive_frame().expect("decodes");
609        assert_eq!(dec.params().width, Some(1));
610        assert_eq!(dec.params().height, Some(1));
611        assert_eq!(dec.params().pixel_format, Some(PixelFormat::Rgba));
612        // Codec id is forced to "webp" by the constructor regardless of
613        // what the factory params said.
614        assert_eq!(dec.params().codec_id.as_str(), CODEC_ID_STR);
615        assert_eq!(dec.params().media_type, MediaType::Video);
616    }
617
618    #[test]
619    fn double_send_packet_without_receive_is_rejected() {
620        // Image-codec contract: one packet → one frame. Two consecutive
621        // send_packet calls without a receive_frame between them is a
622        // caller bug and surfaces as Error::Other.
623        let mut dec = WebpDecoder::new(CodecParameters::video(CodecId::new(CODEC_ID_STR)));
624        let pkt = Packet::new(0, TimeBase::new(1, 1000), LOSSLESS_1X1.to_vec());
625        dec.send_packet(&pkt).unwrap();
626        let err = dec
627            .send_packet(&pkt)
628            .expect_err("second send_packet without receive_frame must fail");
629        // The framework's `Error::other(...)` constructor lands in the
630        // catch-all `Other` variant — we don't assert the variant
631        // directly because it isn't part of the public ABI.
632        let s = err.to_string();
633        assert!(
634            s.contains("receive_frame"),
635            "error message should mention receive_frame: {s}"
636        );
637    }
638
639    #[test]
640    fn flush_then_receive_with_no_pending_returns_eof() {
641        let mut dec = WebpDecoder::new(CodecParameters::video(CodecId::new(CODEC_ID_STR)));
642        dec.flush().unwrap();
643        let err = dec
644            .receive_frame()
645            .expect_err("post-flush, no pending packet → Eof");
646        assert!(matches!(err, CoreError::Eof));
647    }
648
649    #[test]
650    fn decode_webp_to_frame_returns_rgba_video_frame() {
651        // The framework-free helper, exercised directly (without the
652        // Decoder/CodecRegistry plumbing).
653        let frame = decode_webp_to_frame(LOSSLESS_1X1, Some(123)).expect("decodes");
654        assert_eq!(frame.pts, Some(123));
655        assert_eq!(frame.planes.len(), 1);
656        assert_eq!(frame.planes[0].stride, 4);
657        assert_eq!(frame.planes[0].data, [0xB4, 0x3C, 0x5A, 0xFF]);
658    }
659
660    #[test]
661    fn unsupported_error_conversion_maps_to_core_unsupported() {
662        // Lossy and NoImageData both flow through Error::Unsupported.
663        let lossy: CoreError = Error::Unsupported(UnsupportedKind::LossyVp8).into();
664        assert!(matches!(lossy, CoreError::Unsupported(_)));
665        let none: CoreError = Error::Unsupported(UnsupportedKind::NoImageData).into();
666        assert!(matches!(none, CoreError::Unsupported(_)));
667    }
668
669    // ───────────────────── VP8L encoder ─────────────────────
670
671    /// Build a small Rgba [`Frame`] (no stride padding).
672    fn rgba_frame(width: u32, height: u32, fill: impl Fn(u32, u32) -> [u8; 4]) -> Frame {
673        let mut data = Vec::with_capacity((width * height * 4) as usize);
674        for y in 0..height {
675            for x in 0..width {
676                data.extend_from_slice(&fill(x, y));
677            }
678        }
679        Frame::Video(VideoFrame {
680            pts: Some(0),
681            planes: vec![VideoPlane {
682                stride: (width * 4) as usize,
683                data,
684            }],
685        })
686    }
687
688    fn vp8l_params(width: u32, height: u32, pix: PixelFormat) -> CodecParameters {
689        let mut p = CodecParameters::video(CodecId::new(CODEC_ID_VP8L));
690        p.width = Some(width);
691        p.height = Some(height);
692        p.pixel_format = Some(pix);
693        p
694    }
695
696    #[test]
697    fn register_installs_vp8l_encoder_factory() {
698        let mut ctx = RuntimeContext::new();
699        register(&mut ctx);
700        let id = CodecId::new(CODEC_ID_VP8L);
701        assert!(
702            ctx.codecs.has_encoder(&id),
703            "webp_vp8l encoder factory not installed"
704        );
705        assert!(
706            ctx.codecs.has_decoder(&id),
707            "webp_vp8l decoder factory not installed"
708        );
709    }
710
711    #[test]
712    fn vp8l_encoder_round_trips_rgba_through_registry() {
713        // Encode an RGBA frame through the registered encoder, decode the
714        // resulting `.webp`, and assert the pixels survive exactly.
715        let (w, h) = (4u32, 3u32);
716        let frame = rgba_frame(w, h, |x, y| {
717            [(x * 40) as u8, (y * 60) as u8, ((x + y) * 25) as u8, 0xff]
718        });
719
720        let mut ctx = RuntimeContext::new();
721        register(&mut ctx);
722        let mut enc = ctx
723            .codecs
724            .first_encoder(&vp8l_params(w, h, PixelFormat::Rgba))
725            .expect("webp_vp8l encoder factory");
726        enc.send_frame(&frame).expect("send_frame");
727        let pkt = enc.receive_packet().expect("one packet out");
728
729        let img = crate::decode_webp(&pkt.data).expect("decode our own webp");
730        assert_eq!(img.frames.len(), 1);
731        assert_eq!(img.frames[0].width, w);
732        assert_eq!(img.frames[0].height, h);
733        // Re-derive expected RGBA.
734        let Frame::Video(v) = &frame else {
735            unreachable!()
736        };
737        assert_eq!(img.frames[0].rgba, v.planes[0].data);
738    }
739
740    #[test]
741    fn vp8l_encoder_streams_rgb24_as_opaque() {
742        // An Rgb24 frame is treated as fully opaque (alpha 0xff) and emits
743        // the simple (non-VP8X) layout.
744        let (w, h) = (3u32, 2u32);
745        let mut data = Vec::new();
746        for y in 0..h {
747            for x in 0..w {
748                data.extend_from_slice(&[(x * 50) as u8, (y * 70) as u8, 0x33]);
749            }
750        }
751        let frame = Frame::Video(VideoFrame {
752            pts: Some(0),
753            planes: vec![VideoPlane {
754                stride: (w * 3) as usize,
755                data,
756            }],
757        });
758
759        let mut enc =
760            make_encoder(&vp8l_params(w, h, PixelFormat::Rgb24)).expect("make_encoder rgb24");
761        enc.send_frame(&frame).unwrap();
762        let pkt = enc.receive_packet().unwrap();
763
764        // Simple lossless layout: no VP8X chunk.
765        let c = crate::parse_container(&pkt.data).unwrap();
766        assert!(c
767            .first_chunk_with_fourcc(crate::container::fourcc::VP8X)
768            .is_none());
769        let img = crate::decode_webp(&pkt.data).unwrap();
770        // Every pixel opaque, RGB preserved.
771        for px in img.frames[0].rgba.chunks_exact(4) {
772            assert_eq!(px[3], 0xff);
773        }
774    }
775
776    #[test]
777    fn vp8l_encoder_with_metadata_promotes_to_vp8x() {
778        let (w, h) = (2u32, 2u32);
779        let frame = rgba_frame(w, h, |x, _| [(x * 100) as u8, 0x10, 0x20, 0x80]);
780
781        let meta = WebpMetadataOwned {
782            icc: Some(b"icc-profile".to_vec()),
783            exif: Some(b"Exif\x00\x00II".to_vec()),
784            xmp: None,
785        };
786        let mut enc = make_encoder_with_metadata(&vp8l_params(w, h, PixelFormat::Rgba), meta)
787            .expect("make_encoder_with_metadata");
788        enc.send_frame(&frame).unwrap();
789        let pkt = enc.receive_packet().unwrap();
790
791        // Extended layout: VP8X present, metadata round-trips.
792        let c = crate::parse_container(&pkt.data).unwrap();
793        assert!(c
794            .first_chunk_with_fourcc(crate::container::fourcc::VP8X)
795            .is_some());
796        let read = crate::extract_metadata(&pkt.data).unwrap();
797        assert_eq!(read.icc.as_deref(), Some(&b"icc-profile"[..]));
798        assert_eq!(read.exif.as_deref(), Some(&b"Exif\x00\x00II"[..]));
799        assert_eq!(read.xmp, None);
800
801        // Pixels still round-trip through the alpha-bearing image.
802        let img = crate::decode_webp(&pkt.data).unwrap();
803        let Frame::Video(v) = &frame else {
804            unreachable!()
805        };
806        assert_eq!(img.frames[0].rgba, v.planes[0].data);
807    }
808
809    #[test]
810    fn vp8l_encoder_receive_before_send_is_need_more() {
811        let mut enc = make_encoder(&vp8l_params(1, 1, PixelFormat::Rgba)).unwrap();
812        assert!(matches!(enc.receive_packet(), Err(CoreError::NeedMore)));
813        enc.flush().unwrap();
814        assert!(matches!(enc.receive_packet(), Err(CoreError::Eof)));
815    }
816}