Skip to main content

oxideav_avif/
decoder.rs

1//! AVIF `Decoder` implementation — registry-gated.
2//!
3//! The decoder does the full container-side composition pass: it parses
4//! HEIF box hierarchy, decodes the primary item's AV1 OBU stream via
5//! `oxideav_av1`'s registry decoder (each AV1 Image Item Data payload
6//! is one §7.5 temporal-unit body, exactly the packet shape that
7//! decoder accepts), then stitches grid tiles (HEIF §6.6.2), applies
8//! `clap` / `irot` / `imir` post-transforms, and composites an
9//! auxiliary alpha plane when one is present. Decode errors from the
10//! underlying AV1 crate bubble up unchanged.
11//!
12//! This module is gated behind the default-on `registry` Cargo feature
13//! because it pulls in `oxideav_av1` (which transitively pulls in
14//! `oxideav_core`) and exposes the `oxideav_core::Decoder` trait surface.
15//! With the feature off the standalone container parser
16//! ([`crate::inspect`], [`crate::parse`], [`crate::parse_header`],
17//! [`crate::parse_avis`], plus the composition layer in
18//! [`crate::grid`] / [`crate::alpha`] / [`crate::transform`] working on
19//! [`crate::image::AvifFrame`]) is still the public surface.
20
21use oxideav_core::frame::{VideoFrame, VideoPlane};
22use oxideav_core::Decoder;
23use oxideav_core::{
24    CodecId, CodecParameters, ColorSignal, Error, ExecutionContext, Frame, Packet, PixelFormat,
25    Result, TimeBase,
26};
27
28use crate::api::DecodeOptions;
29use crate::av1_config::{parse_av1c, Av1CodecConfig};
30
31use crate::alpha::find_alpha_item_id;
32use crate::box_parser::{b, BoxType};
33use crate::cicp::effective_cicp;
34use crate::derived::{ImageOverlay, MAX_DERIVATION_DEPTH};
35use crate::frame_bridge::to_heif;
36use crate::grid::ImageGrid;
37use crate::image::{AvifFrame, AvifPixelFormat, AvifPlane};
38use crate::inspect::AvifInfo;
39use crate::meta::{Property, ITEM_TYPE_IDEN, ITEM_TYPE_IOVL, ITEM_TYPE_TMAP};
40use crate::parser::{classify_brands, AvifHeader, ITEM_TYPE_AV01, ITEM_TYPE_GRID};
41use crate::signal::{color_signal_for, labelled_pixel_format};
42use crate::transform::{clap_extent, crop_top_left, rotation_keeps_layout};
43use oxideav_heif::compose;
44use oxideav_heif::gainmap::{
45    reconstruct_tone_map, GainMapMetadata, DEFAULT_HDR_REFERENCE_WHITE_NITS,
46};
47use oxideav_heif::props as hprops;
48use oxideav_heif::{Chroma, HeifFrame, HeifPixelFormat, Plane};
49
50/// Re-export the `inspect` entry point so the registry-gated public API
51/// keeps its historical shape (`oxideav_avif::inspect`).
52pub use crate::inspect::{inspect, transforms_for};
53
54// `inspect`, `transforms_for`, and `AvifInfo` live in [`crate::inspect`]
55// — they don't need the `oxideav-core`/`oxideav-av1` dependency tree.
56// The decoder simply re-exports them from there.
57
58/// Inverse of [`to_core_pix`] for the set of formats the AV1 decoder
59/// actually emits. The composition path only ever feeds frames through
60/// its own [`AvifPixelFormat`] variants.
61fn from_core_pix(fmt: PixelFormat) -> Result<AvifPixelFormat> {
62    AvifPixelFormat::try_from(fmt).map_err(core_err)
63}
64
65/// Convert a framework [`VideoFrame`] (returned by `oxideav_av1`) into
66/// the crate-local [`AvifFrame`] the composition layer consumes. Plane
67/// data is moved, not copied.
68pub(crate) fn core_to_avif_frame(vf: VideoFrame) -> AvifFrame {
69    // Only the image planes: a side-channel record the AV1 decoder
70    // may attach (significant bits, colour signal) is not a plane the
71    // composition layer can place.
72    let image_planes = vf.image_plane_count();
73    AvifFrame {
74        pts: vf.pts,
75        planes: vf
76            .planes
77            .into_iter()
78            .take(image_planes)
79            .map(|p| AvifPlane {
80                stride: p.stride,
81                data: p.data,
82            })
83            .collect(),
84    }
85}
86
87/// Infer `(format, width, height)` from a decoded AV1 [`VideoFrame`]
88/// plus the item's `av1C` record. `oxideav-av1` emits planar Y/U/V
89/// with a tight byte stride per plane — one byte per sample for an
90/// 8-bit stream, a little-endian 16-bit word per sample when the
91/// record declares `high_bitdepth` (§5.5.2 flag pair) — so plane
92/// geometry (scaled by the record's storage width) reverses back to a
93/// `PixelFormat` at the record's bit depth.
94pub(crate) fn infer_av1_pixmap(
95    frame: &VideoFrame,
96    cfg: &Av1CodecConfig,
97) -> Result<(PixelFormat, u32, u32)> {
98    if frame.planes.is_empty() {
99        return Err(Error::invalid("avif: AV1 frame has no planes"));
100    }
101    let depth = cfg.bit_depth();
102    let bps = if cfg.high_bitdepth { 2usize } else { 1 };
103    let y = &frame.planes[0];
104    if y.stride == 0 || y.stride % bps != 0 {
105        return Err(Error::invalid(format!(
106            "avif: AV1 frame Y plane stride {} incompatible with {depth}-bit storage",
107            y.stride
108        )));
109    }
110    let width = (y.stride / bps) as u32;
111    let height = (y.data.len() / y.stride) as u32;
112    let format = match frame.image_plane_count() {
113        1 => match depth {
114            8 => PixelFormat::Gray8,
115            10 => PixelFormat::Gray10Le,
116            _ => PixelFormat::Gray12Le,
117        },
118        3 => {
119            let u = &frame.planes[1];
120            if u.stride == 0 {
121                return Err(Error::invalid("avif: AV1 frame U plane has zero stride"));
122            }
123            // 4:2:0 — chroma stride is half luma; chroma data len is
124            // chroma_stride * (height / 2 ceil). The byte-stride ratio
125            // is depth-independent (both planes scale by the same
126            // storage width).
127            let chroma_h = u.data.len() / u.stride;
128            // Use checked / saturating arithmetic here so a corrupt AV1
129            // decoder output (e.g. a stride that overflows when doubled)
130            // can't trigger a debug-build panic before we surface the
131            // mismatch as an InvalidData error.
132            let u_stride_doubled = u.stride.saturating_mul(2);
133            let chroma_h_doubled = chroma_h.saturating_mul(2);
134            let sub = if u_stride_doubled == y.stride && chroma_h_doubled >= height as usize {
135                if chroma_h as u32 == height.div_ceil(2) {
136                    (1u8, 1u8)
137                } else {
138                    (1, 0)
139                }
140            } else if u.stride == y.stride {
141                (0, 0)
142            } else {
143                return Err(Error::unsupported(format!(
144                    "avif: cannot infer AV1 frame format (Y stride {}, U stride {}, U rows {})",
145                    y.stride, u.stride, chroma_h
146                )));
147            };
148            match (sub, depth) {
149                ((1, 1), 8) => PixelFormat::Yuv420P,
150                ((1, 1), 10) => PixelFormat::Yuv420P10Le,
151                ((1, 1), _) => PixelFormat::Yuv420P12Le,
152                ((1, 0), 8) => PixelFormat::Yuv422P,
153                ((1, 0), 10) => PixelFormat::Yuv422P10Le,
154                ((1, 0), _) => PixelFormat::Yuv422P12Le,
155                ((_, _), 8) => PixelFormat::Yuv444P,
156                ((_, _), 10) => PixelFormat::Yuv444P10Le,
157                ((_, _), _) => PixelFormat::Yuv444P12Le,
158            }
159        }
160        n => {
161            return Err(Error::unsupported(format!(
162                "avif: AV1 frame has {n} planes, expected 1 or 3"
163            )))
164        }
165    };
166    Ok((format, width, height))
167}
168
169const AV1C: BoxType = b(b"av1C");
170const ISPE: BoxType = b(b"ispe");
171const DIMG: BoxType = b(b"dimg");
172
173/// `Decoder` trait impl registered under codec id `avif`.
174pub struct AvifDecoder {
175    codec_id: CodecId,
176    /// Frames ready to hand out via `receive_frame()`.
177    pending: Vec<Frame>,
178    /// The AvifInfo of the last decoded file, retained for `info()`.
179    info: Option<AvifInfo>,
180    /// The colour signal of the last decoded file (still: the
181    /// primary's output image; sequence: the sample entry's `colr`).
182    color_signal: Option<ColorSignal>,
183    /// The framework label of the last decoded file's frames.
184    output_format: Option<PixelFormat>,
185    /// The contract options the decode runs with (gain-map
186    /// application, reference white, limits, strictness, layer,
187    /// an explicit `threads`).
188    options: DecodeOptions,
189    /// The worker budget of the last [`Decoder::set_execution_context`]
190    /// (serial until one is granted). An explicit `options.threads`
191    /// wins over it; see [`Self::effective_options`].
192    exec_threads: usize,
193}
194
195impl AvifDecoder {
196    pub fn new(codec_id: CodecId) -> Self {
197        Self {
198            codec_id,
199            pending: Vec::new(),
200            info: None,
201            color_signal: None,
202            output_format: None,
203            options: DecodeOptions::default(),
204            exec_threads: 1,
205        }
206    }
207
208    /// The options a decode runs with: [`Self::options`] with the
209    /// execution context's worker budget filled in when no explicit
210    /// `threads` was set (the `threads` codec option or
211    /// [`DecodeOptions::with_threads`]).
212    pub fn effective_options(&self) -> DecodeOptions {
213        let mut o = self.options;
214        if o.threads.is_none() && self.exec_threads > 1 {
215            o.threads = Some(self.exec_threads);
216        }
217        o
218    }
219
220    /// The worker budget the next decode runs the AV1 decoder with.
221    pub fn threads(&self) -> usize {
222        self.effective_options().worker_count()
223    }
224
225    /// Build a decoder from a parameter set, its options parsed
226    /// strictly against [`AvifDecoderOptions`]'s schema: `gain_map`
227    /// (`base`, the default, or `apply`) selects
228    /// [`set_tone_mapped`](Self::set_tone_mapped); `reference_white`
229    /// (cd/m², default 203) sets
230    /// [`set_reference_white`](Self::set_reference_white); `threads`
231    /// fixes the AV1 worker budget (`0`, the default, follows the
232    /// execution context); `max_width` / `max_height` / `max_pixels` /
233    /// `max_bytes` (`0` = unlimited), `strict` and `layer` (`-1` =
234    /// the file's `lsel`) set the matching [`DecodeOptions`] fields.
235    /// An unknown key or a malformed value is refused.
236    pub fn with_params(params: &CodecParameters) -> Result<Self> {
237        let o = oxideav_core::parse_options::<AvifDecoderOptions>(&params.options)?;
238        let mut d = Self::new(params.codec_id.clone());
239        d.options = o.apply_to(d.options);
240        d.set_reference_white(o.reference_white)?;
241        Ok(d)
242    }
243
244    /// Gain-map application (av1-avif §4.2.2, HEIF Amd 1:2025
245    /// §6.6.2.4, ISO 21496-1 §6): with `true` the decoder yields the
246    /// `tmap` alternative of the primary — the `tmap` item that is the
247    /// primary itself, or the one in an `altr` group with it (MIAF
248    /// Amd 1 §7.3.11.5) whose base input it is — reconstructed with the
249    /// gain map fully applied (the weight of 1.0 / −1.0 of §6.6.2.4.1)
250    /// and re-encoded in the `tmap` item's own `colr` at its `pixi`
251    /// depth (the base's alpha carried over). Default `false`: the
252    /// base image, what readers without tone-map support show. A
253    /// `tmap` that is the input of another derived item is always
254    /// applied (§6.6.2.4.1). Files without a `tmap` decode the same
255    /// either way.
256    pub fn set_tone_mapped(&mut self, tone_mapped: bool) {
257        self.options.tone_mapped = tone_mapped;
258    }
259
260    /// Builder form of [`set_tone_mapped`](Self::set_tone_mapped).
261    pub fn with_tone_mapped(mut self, tone_mapped: bool) -> Self {
262        self.options.tone_mapped = tone_mapped;
263        self
264    }
265
266    /// Whether gain maps are applied (see
267    /// [`set_tone_mapped`](Self::set_tone_mapped)).
268    pub fn tone_mapped(&self) -> bool {
269        self.options.tone_mapped
270    }
271
272    /// The full [`DecodeOptions`] this decoder runs the contract
273    /// functions with ([`crate::decode_with`] / [`crate::decode_all`]).
274    pub fn options(&self) -> &DecodeOptions {
275        &self.options
276    }
277
278    /// Replace the [`DecodeOptions`] (limits, strictness, gain-map
279    /// application, reference white, layer selection).
280    pub fn set_options(&mut self, options: DecodeOptions) {
281        self.options = options;
282    }
283
284    /// Builder form of [`set_options`](Self::set_options).
285    pub fn with_options(mut self, options: DecodeOptions) -> Self {
286        self.options = options;
287        self
288    }
289
290    /// The HDR reference white, in cd/m², a PQ-coded reconstruction is
291    /// anchored at (ISO 21496-1 fixes none; 203 is the value its
292    /// headroom definition uses as the example and the default).
293    /// Finite and positive, else refused.
294    pub fn set_reference_white(&mut self, nits: f64) -> Result<()> {
295        if !(nits.is_finite() && nits > 0.0) {
296            return Err(Error::invalid(format!(
297                "avif: reference white {nits} cd/m² is not a positive finite luminance"
298            )));
299        }
300        self.options.reference_white_nits = nits;
301        Ok(())
302    }
303
304    /// The colour signal (`oxideav-core` [`ColorSignal`]: range +
305    /// H.273 primaries / transfer / matrix) of the last decoded file —
306    /// the primary item's effective `colr` under MIAF §7.3.6.4 (its
307    /// `nclx`, else the MIAF default: full range, BT.709 / sRGB /
308    /// BT.601) for a still, the `av01` sample entry's `colr` for a
309    /// sequence. Every frame this decoder emits carries the same value
310    /// as its colour-signal side channel. `None` before the first
311    /// decode.
312    pub fn color_signal(&self) -> Option<ColorSignal> {
313        self.color_signal
314    }
315
316    /// The framework pixel-format label of the frames of the last
317    /// decoded file: planar RGB (`Gbrp*` / `Gbrap*`, planes G B R [A])
318    /// for an identity-matrix 4:4:4 picture, `YuvJ*` for a full-range
319    /// 8-bit Y′CbCr still, the storage layout otherwise (see
320    /// [`crate::signal::labelled_pixel_format`]). `None` before the
321    /// first decode.
322    pub fn output_format(&self) -> Option<PixelFormat> {
323        self.output_format
324    }
325
326    /// Parse an AVIF file and decode the primary item's **output
327    /// image** (HEIF §6.3): the reconstructed image of a coded `av01`
328    /// item or of a derived item (`grid` tile stitch, `iovl` overlay
329    /// composition, `iden` identity — recursively over their inputs),
330    /// with the alpha auxiliary composited and the item's
331    /// transformative properties applied in `ipma` order. Returns the
332    /// resolved `AvifInfo` on success.
333    pub fn decode_file(&mut self, file: &[u8]) -> Result<AvifInfo> {
334        // The contract implementation does the work; this adapter only
335        // re-labels the result for the framework.
336        let decoded =
337            crate::api_codec::decode_primary(file, &self.effective_options()).map_err(core_err)?;
338        let format = decoded.image.format;
339        let signal = color_signal_for(Some(&decoded.image.color.to_colr()));
340        let out = VideoFrame::from(decoded.image);
341        self.color_signal = Some(signal);
342        self.output_format = Some(labelled_pixel_format(format, &signal));
343        self.pending.push(Frame::Video(out));
344        self.info = Some(decoded.info.clone());
345        Ok(decoded.info)
346    }
347
348    pub fn info(&self) -> Option<&AvifInfo> {
349        self.info.as_ref()
350    }
351
352    /// Decode every frame of an AVIF Image Sequence (AVIS) — av1-avif
353    /// §6.3 + ISO/IEC 14496-12 §8 (movie / track / sample-table boxes).
354    ///
355    /// Walks the track's `stbl` to recover the sample byte ranges and
356    /// `(duration, is_sync)` per sample, lifts the
357    /// `AV1CodecConfigurationRecord` from `stsd` → `av01` → `av1C`, and
358    /// fans every sample through a single shared AV1 registry decoder
359    /// instance so inter-prediction across samples is preserved (when
360    /// the underlying av1 crate supports it).
361    ///
362    /// Each successfully decoded sample is queued on `pending` with a
363    /// `pts` derived from the cumulative `stts` duration so the
364    /// `Decoder::receive_frame` consumer can pull frames in
365    /// presentation order. Samples that the av1 decoder rejects are
366    /// surfaced as the first error returned by this function — the
367    /// caller can then `flush` and re-try, or treat the sequence as
368    /// partially-decodable. Returns the count of frames queued.
369    ///
370    /// AVIS files that lack a `moov` (and thus look like still images
371    /// with no sequence track) bubble up `Error::InvalidData` from
372    /// [`parse_avis`]; callers detecting this should fall back to the
373    /// still-image [`decode_file`] path.
374    pub fn decode_avis_file(&mut self, file: &[u8]) -> Result<usize> {
375        let mut frames_queued = 0usize;
376        let opts = self.effective_options();
377        let seq =
378            crate::api_codec::decode_sequence_with(file, &opts, None, |frame, seq, _duration| {
379                self.pending.push(with_signal(frame, seq.signal));
380                frames_queued += 1;
381                Ok(())
382            })
383            .map_err(core_err)?;
384        self.color_signal = Some(seq.signal);
385        self.output_format = seq.layout.map(|l| labelled_pixel_format(l, &seq.signal));
386        Ok(frames_queued)
387    }
388}
389
390/// Attach the sequence's colour signal to a decoded sample frame.
391fn with_signal(frame: Frame, signal: ColorSignal) -> Frame {
392    match frame {
393        Frame::Video(v) => Frame::Video(v.with_color_signal(signal)),
394        other => other,
395    }
396}
397
398/// Bridge: convert a crate-local [`crate::error::AvifError`] into the
399/// framework `Error` variants. The decoder calls this on every
400/// container-side `Result<T>` so the trait surface still returns
401/// `oxideav_core::Result`.
402pub(crate) fn core_err(e: crate::error::AvifError) -> Error {
403    Error::from(e)
404}
405
406/// Defensive sanity check on a parsed `av1C` record before its bytes are
407/// handed to the AV1 decoder. AVIF §2.2.1 + AV1 §A.4 constrain the fields
408/// the record carries; corrupt values are a strong signal that the rest of
409/// the OBU stream is adversarial. Rejecting here keeps the host from
410/// feeding garbage into the AV1 entropy decoder, where prior fuzzing has
411/// surfaced arithmetic-overflow panics (see fuzz_regressions tests).
412///
413/// Checks (spec citations):
414///
415/// * AV1 §A.4 — `seq_profile` ∈ {0,1,2}.
416/// * AV1 §A.3 — `seq_level_idx_0` ∈ \[0..=23\] ∪ {31}; values 24..=30 are reserved.
417/// * AV1 §5.5.2 — at most one of `monochrome` / chroma-subsampling needs
418///   coherence: `monochrome == 1` requires both `chroma_subsampling_x` and
419///   `chroma_subsampling_y` set to 1 (4:0:0 carries no chroma planes).
420/// * AV1 §5.5.2 — 4:2:2 (sub_x=1, sub_y=0) is only valid for `seq_profile == 2`.
421/// * AV1 §5.5.2 — 4:4:4 (sub_x=0, sub_y=0) is only valid for `seq_profile ∈ {1, 2}`.
422pub(crate) fn validate_av1_config(cfg: &Av1CodecConfig) -> Result<()> {
423    if cfg.seq_profile > 2 {
424        return Err(Error::invalid(format!(
425            "av1C: seq_profile={} > 2 (AV1 §A.4)",
426            cfg.seq_profile
427        )));
428    }
429    // AV1 §A.3: reserved values are 24..=30. 31 is the "level not specified" sentinel.
430    if (24..=30).contains(&cfg.seq_level_idx_0) {
431        return Err(Error::invalid(format!(
432            "av1C: seq_level_idx_0={} is reserved (AV1 §A.3)",
433            cfg.seq_level_idx_0
434        )));
435    }
436    let sub_x = cfg.chroma_subsampling_x;
437    let sub_y = cfg.chroma_subsampling_y;
438    if cfg.monochrome && !(sub_x && sub_y) {
439        return Err(Error::invalid(format!(
440            "av1C: monochrome=1 requires chroma_subsampling_x=chroma_subsampling_y=1, \
441             got x={sub_x} y={sub_y} (AV1 §5.5.2)"
442        )));
443    }
444    // 4:2:2 = (1, 0) is only legal in profile 2.
445    if !cfg.monochrome && sub_x && !sub_y && cfg.seq_profile != 2 {
446        return Err(Error::invalid(format!(
447            "av1C: 4:2:2 subsampling requires seq_profile=2, got profile={} (AV1 §5.5.2)",
448            cfg.seq_profile
449        )));
450    }
451    // 4:4:4 = (0, 0) is only legal in profiles 1 or 2.
452    if !cfg.monochrome && !sub_x && !sub_y && cfg.seq_profile == 0 {
453        return Err(Error::invalid(
454            "av1C: 4:4:4 subsampling requires seq_profile in {1, 2}, got profile=0 (AV1 §5.5.2)",
455        ));
456    }
457    Ok(())
458}
459
460/// Soft upper bound on the AV1 OBU payload size we are willing to forward
461/// from an AVIF item. Files in the wild rarely exceed a few MB per item;
462/// rejecting absurdly large payloads here avoids handing the AV1 decoder
463/// a buffer that would dominate the fuzz wall-clock budget.
464///
465/// `32 MiB` is generous — even a 8K HDR still tops out around 5 MiB in
466/// practice — and keeps the limit well above all real-world fixtures
467/// shipped under `tests/fixtures/`.
468pub(crate) const MAX_AV1_ITEM_BYTES: usize = 32 * 1024 * 1024;
469
470/// Decode a single av01 item's OBU bitstream into a `VideoFrame` plus
471/// its inferred `(format, width, height)` triple. The slim
472/// [`VideoFrame`] no longer carries those fields, so we recover them
473/// from plane geometry.
474/// Which reconstructed image of a (possibly layered) AV1 Image Item to
475/// surface — av1-avif §2.3: the `a1op` operating point to process and
476/// the `lsel` spatial layer to render.
477#[derive(Clone, Copy, Debug, Default)]
478struct LayerSelect {
479    /// `a1op` `op_index`; `None` = operating point 0.
480    operating_point: Option<u8>,
481    /// `lsel` `layer_id`; `None` or `0xFFFF` = the top (last decoded)
482    /// layer.
483    layer_id: Option<u16>,
484}
485
486fn decode_av01_item(
487    obu_bytes: &[u8],
488    av1c: &[u8],
489    ispe: Option<(u32, u32)>,
490    select: LayerSelect,
491    threads: usize,
492) -> Result<(VideoFrame, PixelFormat, u32, u32)> {
493    if obu_bytes.len() > MAX_AV1_ITEM_BYTES {
494        return Err(Error::invalid(format!(
495            "avif: av01 item payload {} bytes exceeds soft cap {} bytes",
496            obu_bytes.len(),
497            MAX_AV1_ITEM_BYTES
498        )));
499    }
500    let cfg = parse_av1c(av1c)?; // eagerly validate
501    validate_av1_config(&cfg)?;
502    check_av1c_matches_stream(obu_bytes, &cfg)?;
503    // Every shown frame of the temporal unit, in decode order — a
504    // layered item (av1-avif §2.3.1) shows one frame per spatial
505    // layer, increasing `spatial_id`.
506    let frames: Vec<VideoFrame> = match select.operating_point {
507        Some(op) if op > 0 => decode_at_operating_point(obu_bytes, ispe, op)?,
508        _ => {
509            let mut params = CodecParameters::video(CodecId::new("av1"));
510            if let Some((w, h)) = ispe {
511                params.width = Some(w);
512                params.height = Some(h);
513            }
514            params.extradata = av1c.to_vec();
515            let mut av1 = oxideav_av1::registry::make_decoder(&params)?;
516            if threads > 1 {
517                // The budget is advisory and the output is
518                // byte-identical at any value (oxideav-av1 0.1.21).
519                av1.set_execution_context(&ExecutionContext::with_threads(threads));
520            }
521            let pkt = Packet::new(0, TimeBase::new(1, 90_000), obu_bytes.to_vec());
522            av1.send_packet(&pkt)?;
523            let mut out = Vec::new();
524            loop {
525                match av1.receive_frame() {
526                    Ok(Frame::Video(v)) => out.push(v),
527                    Ok(other) => {
528                        return Err(Error::unsupported(format!(
529                            "avif: AV1 decoder returned non-video frame: {other:?}"
530                        )))
531                    }
532                    Err(Error::NeedMore) => break,
533                    Err(e) => {
534                        if out.is_empty() {
535                            return Err(e);
536                        }
537                        break;
538                    }
539                }
540            }
541            out
542        }
543    };
544    if frames.is_empty() {
545        return Err(Error::invalid("avif: AV1 item decoded to no shown frame"));
546    }
547    let frame = match select.layer_id {
548        Some(id) if id != 0xFFFF => {
549            let idx = usize::from(id);
550            if idx >= frames.len() {
551                return Err(Error::invalid(format!(
552                    "avif: lsel layer_id {id} but the item decodes {} layer frame(s) \
553                     (av1-avif §2.3.2.2: the layer shall be present and produce an output frame)",
554                    frames.len()
555                )));
556            }
557            frames.into_iter().nth(idx).expect("index checked")
558        }
559        _ => frames.into_iter().last().expect("non-empty"),
560    };
561    let (format, width, height) = infer_av1_pixmap(&frame, &cfg)?;
562    Ok((frame, format, width, height))
563}
564
565/// av1-avif §2.2.1: "The values of the fields in the
566/// AV1CodecConfigurationBox shall match those of the Sequence Header
567/// OBU in the AV1 Image Item Data." The layout fields decide how the
568/// decoded planes are read (storage width, plane count, chroma
569/// extents), so a record that disagrees with the stream on bit depth,
570/// monochrome or sub-sampling would label the picture wrongly — the
571/// item is refused instead. Walks the item's OBUs up to the first
572/// Sequence Header; an item without one is left to the AV1 decoder.
573fn check_av1c_matches_stream(obu_bytes: &[u8], cfg: &Av1CodecConfig) -> Result<()> {
574    use oxideav_av1::{parse_obu, ObuType};
575    let mut off = 0usize;
576    while off < obu_bytes.len() {
577        let Ok((desc, consumed)) = parse_obu(&obu_bytes[off..]) else {
578            return Ok(());
579        };
580        if desc.obu_type == ObuType::SequenceHeader {
581            let Ok(seq) = oxideav_av1::sequence_header::parse_sequence_header(desc.payload) else {
582                return Ok(());
583            };
584            let cc = &seq.color_config;
585            let stream = (
586                cc.bit_depth,
587                cc.mono_chrome,
588                cc.subsampling_x,
589                cc.subsampling_y,
590            );
591            let record = (
592                cfg.bit_depth(),
593                cfg.monochrome,
594                cfg.chroma_subsampling_x,
595                cfg.chroma_subsampling_y,
596            );
597            if stream != record {
598                return Err(Error::invalid(format!(
599                    "avif: av1C (bit depth {}, monochrome {}, subsampling {}/{}) disagrees with \
600                     the Sequence Header OBU (bit depth {}, monochrome {}, subsampling {}/{}) — \
601                     av1-avif §2.2.1",
602                    record.0,
603                    record.1,
604                    u8::from(record.2),
605                    u8::from(record.3),
606                    stream.0,
607                    stream.1,
608                    u8::from(stream.2),
609                    u8::from(stream.3)
610                )));
611            }
612            return Ok(());
613        }
614        if consumed == 0 {
615            return Ok(());
616        }
617        off += consumed;
618    }
619    Ok(())
620}
621
622/// Decode an item's temporal unit at a non-default operating point
623/// (av1-avif §2.3.2.1 `a1op`): the registry decoder has no
624/// operating-point control, so the unit is wrapped in a one-frame IVF
625/// stream for the AV1 crate's operating-point-aware file decoder and
626/// its frames are lifted into `VideoFrame`s.
627fn decode_at_operating_point(
628    obu_bytes: &[u8],
629    ispe: Option<(u32, u32)>,
630    op: u8,
631) -> Result<Vec<VideoFrame>> {
632    use oxideav_av1::encoder::ivf::{build_file_header, build_frame_header, FOURCC_AV01};
633    let (w, h) = ispe.unwrap_or((0, 0));
634    let mut ivf = Vec::with_capacity(44 + obu_bytes.len());
635    ivf.extend_from_slice(&build_file_header(
636        FOURCC_AV01,
637        w.min(u32::from(u16::MAX)) as u16,
638        h.min(u32::from(u16::MAX)) as u16,
639        30,
640        1,
641        1,
642    ));
643    ivf.extend_from_slice(&build_frame_header(obu_bytes.len() as u32, 0));
644    ivf.extend_from_slice(obu_bytes);
645    let frames = oxideav_av1::decode_av1_at_operating_point(&ivf, op).map_err(|e| {
646        Error::invalid(format!(
647            "avif: AV1 decode at operating point {op} failed: {e}"
648        ))
649    })?;
650    frames
651        .into_iter()
652        .map(|f| match f {
653            oxideav_av1::decoder::Frame::Spec(sf) => {
654                let bps = if sf.bit_depth > 8 { 2 } else { 1 };
655                Ok(VideoFrame {
656                    pts: None,
657                    planes: sf
658                        .planes
659                        .iter()
660                        .zip(&sf.plane_dims)
661                        .map(|(data, &(pw, _))| VideoPlane {
662                            stride: pw as usize * bps,
663                            data: data.clone(),
664                        })
665                        .collect(),
666                })
667            }
668            #[allow(unreachable_patterns)]
669            other => Err(Error::unsupported(format!(
670                "avif: AV1 decoder returned an unexpected frame kind: {other:?}"
671            ))),
672        })
673        .collect()
674}
675
676/// Upper bound on a grid canvas (`output_width × output_height`) in
677/// pixels — 16384 × 16384, the largest coded extent an AV1
678/// Professional-profile image reaches. Rejected before any tile is
679/// decoded, so a hostile descriptor cannot drive a multi-gigabyte
680/// allocation.
681pub const MAX_GRID_CANVAS_PIXELS: u64 = 1 << 28;
682
683/// Upper bound on the number of item decodes one file may trigger.
684/// A derivation graph is a DAG in which an item may be referenced by
685/// many derived items (diamonds are legal); outputs are memoised per
686/// item, so this bounds the distinct items decoded, not the fan-out —
687/// a hostile graph of nested grids referencing each other cannot go
688/// exponential.
689pub const MAX_ITEM_DECODES: u32 = 4096;
690
691/// Per-file decode state: the `dimg` recursion stack (cycle guard),
692/// memoised item output images, the decode budget and the caller's
693/// [`crate::DecodeOptions`] that steer the walk.
694pub(crate) struct DecodeCtx {
695    stack: Vec<u32>,
696    cache: std::collections::HashMap<(u32, bool), ItemImage>,
697    decodes: u32,
698    /// Apply the gain map of a `tmap` primary (else its base).
699    tone_mapped: bool,
700    /// HDR reference white for PQ-coded reconstructions.
701    reference_white_nits: f64,
702    /// Cap on any derived canvas (grid / overlay) on the way to the
703    /// output, in pixels — the caller's bound (`u64::MAX` when
704    /// unlimited), applied alongside this crate's own
705    /// ([`MAX_GRID_CANVAS_PIXELS`] /
706    /// [`crate::overlay::MAX_OVERLAY_CANVAS_PIXELS`]).
707    max_pixels: u64,
708    /// Spatial layer to render for the top-level coded item instead
709    /// of its `lsel`.
710    layer: Option<u16>,
711    /// Worker budget handed to every AV1 item decode (`≥ 1`).
712    threads: usize,
713}
714
715impl DecodeCtx {
716    pub(crate) fn new(opts: &crate::api::DecodeOptions) -> Self {
717        Self {
718            stack: Vec::new(),
719            cache: std::collections::HashMap::new(),
720            decodes: 0,
721            tone_mapped: opts.tone_mapped,
722            reference_white_nits: opts.reference_white_nits,
723            max_pixels: opts.max_pixels.unwrap_or(u64::MAX),
724            layer: opts.layer,
725            threads: opts.worker_count(),
726        }
727    }
728}
729
730/// An image item's output image (HEIF §6.3) — the container crate's
731/// planar frame, which its composition layer works on directly. The
732/// crate-local layout (packed `Ya8` / `Ya16Le`, the framework
733/// `PixelFormat`) is produced once, for the primary's final output.
734pub(crate) type ItemImage = HeifFrame;
735
736fn heif_err(e: oxideav_heif::HeifError) -> Error {
737    core_err(crate::error::AvifError::from(e))
738}
739
740/// Decode the output image of any image item — coded or derived —
741/// recursively over its `dimg` inputs (HEIF §6.3 / §6.6.1):
742///
743/// 1. the **reconstructed image**: the AV1 decode of an `av01` item,
744///    the tile stitch of a `grid`, the canvas composition of an
745///    `iovl`, or the single input of an `iden`;
746/// 2. the alpha auxiliary (`auxl` + alpha `auxC`) attached to the
747///    frame when `with_alpha` is set — the caller's rendering context
748///    decides: the primary and overlay inputs carry their alpha, grid
749///    tiles and auxiliaries themselves do not;
750/// 3. the item's transformative properties (`clap` / `irot` /
751///    `imir`) applied in `ipma` order (§6.3) by the container's
752///    composition layer — an odd clean aperture on subsampled chroma
753///    promotes the picture to 4:4:4 (MIAF §7.3.6.7).
754///
755/// `depth` + the context's stack guard the walk against hostile
756/// graphs: chains deeper than [`MAX_DERIVATION_DEPTH`] and `dimg`
757/// cycles are rejected before any decode; outputs are memoised per
758/// `(item, with_alpha)` so shared inputs decode once, and the total
759/// number of item decodes is capped by [`MAX_ITEM_DECODES`].
760pub(crate) fn decode_item_output(
761    hdr: &AvifHeader<'_>,
762    item_id: u32,
763    depth: u32,
764    ctx: &mut DecodeCtx,
765    with_alpha: bool,
766) -> Result<ItemImage> {
767    if depth > MAX_DERIVATION_DEPTH {
768        return Err(Error::invalid(format!(
769            "avif: derivation chain deeper than {MAX_DERIVATION_DEPTH} at item {item_id}"
770        )));
771    }
772    if ctx.stack.contains(&item_id) {
773        return Err(Error::invalid(format!(
774            "avif: derivation cycle through item {item_id}"
775        )));
776    }
777    if let Some(cached) = ctx.cache.get(&(item_id, with_alpha)) {
778        return Ok(cached.clone());
779    }
780    if ctx.decodes >= MAX_ITEM_DECODES {
781        return Err(Error::invalid(format!(
782            "avif: derivation graph needs more than {MAX_ITEM_DECODES} item decodes"
783        )));
784    }
785    ctx.decodes += 1;
786    ctx.stack.push(item_id);
787    let result = decode_item_output_inner(hdr, item_id, depth, ctx, with_alpha);
788    ctx.stack.pop();
789    if let Ok(image) = &result {
790        ctx.cache.insert((item_id, with_alpha), image.clone());
791    }
792    result
793}
794
795fn decode_item_output_inner(
796    hdr: &AvifHeader<'_>,
797    item_id: u32,
798    depth: u32,
799    ctx: &mut DecodeCtx,
800    with_alpha: bool,
801) -> Result<ItemImage> {
802    let info = hdr
803        .meta
804        .item_by_id(item_id)
805        .ok_or_else(|| Error::invalid(format!("avif: item {item_id} unknown")))?;
806    let item_type = info.item_type;
807    let mut image = if item_type == ITEM_TYPE_AV01 {
808        let layer = if depth == 0 { ctx.layer } else { None };
809        decode_coded_item(hdr, item_id, layer, ctx.threads)?
810    } else if item_type == ITEM_TYPE_TMAP {
811        let (base, _gain) = tone_map_inputs(hdr, item_id)?;
812        if depth == 0 && !ctx.tone_mapped {
813            // Not applied: the base image is what a reader without
814            // tone-map support shows (its own output image, its own
815            // `colr` — the `tmap` item's transforms are the
816            // alternative's, not the base's).
817            return decode_item_output(hdr, base, depth + 1, ctx, with_alpha);
818        }
819        decode_tone_map_item(hdr, item_id, depth, ctx, with_alpha)?
820    } else if item_type == ITEM_TYPE_GRID {
821        decode_grid_item(hdr, item_id, depth, ctx)?
822    } else if item_type == ITEM_TYPE_IOVL {
823        decode_overlay_item(hdr, item_id, depth, ctx)?
824    } else if item_type == ITEM_TYPE_IDEN {
825        let inputs = hdr.meta.iref_targets(&DIMG, item_id);
826        if inputs.len() != 1 {
827            return Err(Error::invalid(format!(
828                "avif: iden item {item_id} has {} dimg inputs, expected exactly 1 (HEIF §6.6.2.1)",
829                inputs.len()
830            )));
831        }
832        decode_item_output(hdr, inputs[0], depth + 1, ctx, with_alpha)?
833    } else {
834        return Err(Error::unsupported(format!(
835            "avif: item {item_id} type '{}' cannot be decoded to pixels",
836            String::from_utf8_lossy(&item_type)
837        )));
838    };
839    if with_alpha {
840        if let Some(alpha_id) = find_alpha_item_id(&hdr.meta, item_id) {
841            if image.format.has_alpha {
842                return Err(Error::unsupported(format!(
843                    "avif: item {item_id} already carries alpha and has an alpha auxiliary {alpha_id}"
844                )));
845            }
846            let alpha = decode_item_output(hdr, alpha_id, depth + 1, ctx, false)?;
847            let alpha = alpha_as_gray(&alpha, image.width, image.height)?;
848            if alpha.format.bit_depth != image.format.bit_depth {
849                return Err(Error::invalid(format!(
850                    "avif alpha: alpha item {alpha_id} is {}-bit but master item {item_id} is \
851                     {}-bit — the auxiliary shall be encoded at the master's bit depth \
852                     (av1-avif §4.1)",
853                    alpha.format.bit_depth, image.format.bit_depth
854                )));
855            }
856            image = compose::attach_alpha(&image, &alpha).map_err(heif_err)?;
857        }
858    }
859    for prop in transforms_for(&hdr.meta, item_id) {
860        image = match prop {
861            Property::Clap(clap) => {
862                // A degenerate / non-fitting clean aperture is treated
863                // as absent, as this crate always has.
864                if clap_extent(clap, image.width, image.height).is_none() {
865                    continue;
866                }
867                compose::apply_clap(&image, &hprops::Clap::from(clap))
868            }
869            Property::Irot(irot) => {
870                let turns = irot.angle & 0x03;
871                if turns != 0 {
872                    let (sx, sy) = image.format.chroma.shift();
873                    rotation_keeps_layout((sx as u8, sy as u8), turns).map_err(core_err)?;
874                }
875                compose::apply_irot(&image, &hprops::Irot::new(turns))
876            }
877            Property::Imir(imir) => {
878                compose::apply_imir(&image, &hprops::Imir::new(imir.axis & 0x01))
879            }
880            _ => continue,
881        }
882        .map_err(heif_err)?;
883    }
884    Ok(image)
885}
886
887/// Decode one coded `av01` item: AV1 decode + the `ispe` clamp against
888/// a padded coded frame, re-laid out as the container frame. `layer`
889/// overrides the item's `lsel` choice of spatial layer; `threads` is
890/// the AV1 decoder's worker budget.
891fn decode_coded_item(
892    hdr: &AvifHeader<'_>,
893    item_id: u32,
894    layer: Option<u16>,
895    threads: usize,
896) -> Result<ItemImage> {
897    let bytes = hdr.item_data(item_id).map_err(core_err)?;
898    let av1c = match hdr.meta.property_for(item_id, &AV1C) {
899        Some(Property::Av1C(b)) => b.clone(),
900        _ => {
901            return Err(Error::invalid(format!(
902                "avif: item {item_id} missing av1C property"
903            )))
904        }
905    };
906    let ispe = match hdr.meta.property_for(item_id, &ISPE) {
907        Some(Property::Ispe(e)) => Some((e.width, e.height)),
908        _ => None,
909    };
910    let select = LayerSelect {
911        operating_point: match hdr.meta.property_for(item_id, b"a1op") {
912            Some(Property::A1op(a)) => Some(a.op_index),
913            _ => None,
914        },
915        layer_id: layer.or(match hdr.meta.property_for(item_id, b"lsel") {
916            Some(Property::Lsel(l)) => Some(l.layer_id),
917            _ => None,
918        }),
919    };
920    let (core_frame, fmt_core, mut w, mut h) =
921        decode_av01_item(&bytes, &av1c, ispe, select, threads)?;
922    let bit_depth = parse_av1c(&av1c)?.bit_depth();
923    let mut frame = core_to_avif_frame(core_frame);
924    let format = from_core_pix(fmt_core)?;
925    if let Some((iw, ih)) = ispe {
926        if iw > 0 && ih > 0 && iw <= w && ih <= h && (iw != w || ih != h) {
927            frame = crop_top_left(&frame, format, w, h, iw, ih).map_err(core_err)?;
928            w = iw;
929            h = ih;
930        }
931    }
932    to_heif(&frame, format, bit_depth, w, h).map_err(core_err)
933}
934
935/// Decode a `grid` derived item: decode each `dimg` input (its output
936/// image, alpha-less — av1-avif §7 forbids transforms in a grid's
937/// input chain, and a grid's alpha is an auxiliary of the grid item
938/// itself), then stitch into the declared canvas (HEIF §6.6.2.3).
939fn decode_grid_item(
940    hdr: &AvifHeader<'_>,
941    grid_id: u32,
942    depth: u32,
943    ctx: &mut DecodeCtx,
944) -> Result<ItemImage> {
945    if hdr.meta.location_by_id(grid_id).is_none() {
946        return Err(Error::invalid("avif: grid item missing in iloc"));
947    }
948    let grid_bytes = hdr.item_data(grid_id).map_err(core_err)?;
949    let grid = ImageGrid::parse(&grid_bytes).map_err(core_err)?;
950    let tile_ids = hdr.meta.iref_targets(&DIMG, grid_id);
951    if tile_ids.is_empty() {
952        return Err(Error::invalid("avif: grid item has no dimg iref"));
953    }
954    if tile_ids.len() != grid.expected_tile_count() {
955        return Err(Error::invalid(format!(
956            "avif: grid declares {} tiles but dimg lists {}",
957            grid.expected_tile_count(),
958            tile_ids.len()
959        )));
960    }
961    if grid.output_width == 0 || grid.output_height == 0 {
962        return Err(Error::invalid("avif: grid output dimensions zero"));
963    }
964    let canvas = u64::from(grid.output_width) * u64::from(grid.output_height);
965    if canvas > MAX_GRID_CANVAS_PIXELS {
966        return Err(Error::invalid(format!(
967            "avif: grid canvas {}x{} exceeds {MAX_GRID_CANVAS_PIXELS} pixels",
968            grid.output_width, grid.output_height
969        )));
970    }
971    if canvas > ctx.max_pixels {
972        return Err(Error::ResourceExhausted(format!(
973            "avif: grid canvas {}x{} exceeds the {} pixels allowed",
974            grid.output_width, grid.output_height, ctx.max_pixels
975        )));
976    }
977    let mut tiles: Vec<HeifFrame> = Vec::with_capacity(tile_ids.len());
978    if ctx.threads > 1 && tile_ids.len() > 1 {
979        decode_tiles_parallel(hdr, &tile_ids, depth + 1, ctx)?;
980    }
981    for (i, tid) in tile_ids.iter().enumerate() {
982        // Serial, or the memoised output of the parallel walk above.
983        let tile = decode_item_output(hdr, *tid, depth + 1, ctx, false)?;
984        if let Some(first) = tiles.first() {
985            if first.format != tile.format {
986                return Err(Error::invalid(format!(
987                    "avif: grid tile {i} format {:?} differs from tile 0 {:?}",
988                    tile.format, first.format
989                )));
990            }
991            if (first.width, first.height) != (tile.width, tile.height) {
992                return Err(Error::invalid(format!(
993                    "avif: grid tile {i} dims {}x{} differ from tile 0 {}x{}",
994                    tile.width, tile.height, first.width, first.height
995                )));
996            }
997        }
998        tiles.push(tile);
999    }
1000    crate::grid::composite_grid_frames(&grid, &tiles).map_err(core_err)
1001}
1002
1003/// Decode the distinct, not-yet-memoised tiles of a grid on up to
1004/// `ctx.threads` workers (round-robin over the tile order, each tile
1005/// on a child context with the AV1 budget split evenly — a 12-tile
1006/// grid on 8 workers codes each tile serially; the same output image
1007/// as the serial walk, item by item). Outputs land in `ctx.cache` and
1008/// the decode budget / cycle guard are kept: each child starts from
1009/// the parent's derivation stack, its decode count is added back
1010/// under [`MAX_ITEM_DECODES`], and the first failing tile in tile
1011/// order is reported.
1012fn decode_tiles_parallel(
1013    hdr: &AvifHeader<'_>,
1014    tile_ids: &[u32],
1015    depth: u32,
1016    ctx: &mut DecodeCtx,
1017) -> Result<()> {
1018    let mut todo: Vec<u32> = Vec::with_capacity(tile_ids.len());
1019    for &tid in tile_ids {
1020        if !todo.contains(&tid) && !ctx.cache.contains_key(&(tid, false)) {
1021            todo.push(tid);
1022        }
1023    }
1024    if todo.len() < 2 {
1025        return Ok(());
1026    }
1027    if ctx.decodes.saturating_add(todo.len() as u32) > MAX_ITEM_DECODES {
1028        return Err(Error::invalid(format!(
1029            "avif: derivation graph needs more than {MAX_ITEM_DECODES} item decodes"
1030        )));
1031    }
1032    let workers = ExecutionContext::with_threads(ctx.threads).effective_workers(todo.len());
1033    let per_tile = (ctx.threads / workers).max(1);
1034    let seed = DecodeCtx {
1035        stack: ctx.stack.clone(),
1036        cache: std::collections::HashMap::new(),
1037        decodes: 0,
1038        tone_mapped: ctx.tone_mapped,
1039        reference_white_nits: ctx.reference_white_nits,
1040        max_pixels: ctx.max_pixels,
1041        layer: None,
1042        threads: per_tile,
1043    };
1044    // (tile index, tile id, output, decodes spent)
1045    type TileOut = (usize, u32, Result<ItemImage>, u32);
1046    let outs: Vec<TileOut> = std::thread::scope(|scope| {
1047        let handles: Vec<_> = (0..workers)
1048            .map(|w| {
1049                let todo = &todo;
1050                let seed = &seed;
1051                scope.spawn(move || {
1052                    let mut out: Vec<TileOut> = Vec::new();
1053                    for (i, &tid) in todo.iter().enumerate().skip(w).step_by(workers) {
1054                        let mut child = DecodeCtx {
1055                            stack: seed.stack.clone(),
1056                            cache: std::collections::HashMap::new(),
1057                            ..*seed
1058                        };
1059                        let r = decode_item_output(hdr, tid, depth, &mut child, false);
1060                        out.push((i, tid, r, child.decodes));
1061                    }
1062                    out
1063                })
1064            })
1065            .collect();
1066        let mut all = Vec::with_capacity(todo.len());
1067        for h in handles {
1068            match h.join() {
1069                Ok(v) => all.extend(v),
1070                Err(_) => all.push((
1071                    usize::MAX,
1072                    0,
1073                    Err(Error::invalid("avif: a grid tile decode worker panicked")),
1074                    0,
1075                )),
1076            }
1077        }
1078        all
1079    });
1080    let mut outs = outs;
1081    outs.sort_by_key(|o| o.0);
1082    let mut spent = 0u32;
1083    for (_, tid, r, n) in outs {
1084        spent = spent.saturating_add(n);
1085        let image = r?;
1086        ctx.cache.insert((tid, false), image);
1087    }
1088    ctx.decodes = ctx.decodes.saturating_add(spent);
1089    if ctx.decodes > MAX_ITEM_DECODES {
1090        return Err(Error::invalid(format!(
1091            "avif: derivation graph needs more than {MAX_ITEM_DECODES} item decodes"
1092        )));
1093    }
1094    Ok(())
1095}
1096
1097const COLR: BoxType = b(b"colr");
1098const PREM: BoxType = b(b"prem");
1099const PIXI: BoxType = b(b"pixi");
1100const ALTR: BoxType = b(b"altr");
1101
1102/// The `tmap` item that is `primary_id`'s alternative: the primary
1103/// itself when it is a `tmap`, else a `tmap` whose base input (first
1104/// `dimg` target, HEIF Amd 1 §6.6.2.4.1) is the primary — preferring
1105/// one that shares an `altr` entity group with it (MIAF Amd 1
1106/// §7.3.11.5, av1-avif §4.2.2). `None` when the file carries no such
1107/// item.
1108pub(crate) fn tone_map_of(hdr: &AvifHeader<'_>, primary_id: u32) -> Option<u32> {
1109    let meta = &hdr.meta;
1110    if meta.item_by_id(primary_id)?.item_type == ITEM_TYPE_TMAP {
1111        return Some(primary_id);
1112    }
1113    let candidates: Vec<u32> = meta
1114        .items
1115        .iter()
1116        .filter(|i| i.item_type == ITEM_TYPE_TMAP)
1117        .filter(|i| meta.iref_targets(&DIMG, i.id).first() == Some(&primary_id))
1118        .map(|i| i.id)
1119        .collect();
1120    if candidates.is_empty() {
1121        return None;
1122    }
1123    let groups = meta.groups().unwrap_or_default();
1124    candidates
1125        .iter()
1126        .copied()
1127        .find(|&t| {
1128            groups.iter().any(|g| {
1129                g.grouping_type == ALTR
1130                    && g.entity_ids.contains(&t)
1131                    && g.entity_ids.contains(&primary_id)
1132            })
1133        })
1134        .or(candidates.first().copied())
1135}
1136
1137/// The `(base, gain map)` inputs of a `tmap` item: exactly two `dimg`
1138/// targets, base first (HEIF Amd 1 §6.6.2.4.1 `reference_count` shall
1139/// be 2).
1140fn tone_map_inputs(hdr: &AvifHeader<'_>, tmap_id: u32) -> Result<(u32, u32)> {
1141    let inputs = hdr.meta.iref_targets(&DIMG, tmap_id);
1142    match inputs.as_slice() {
1143        [base, gain] => Ok((*base, *gain)),
1144        other => Err(Error::invalid(format!(
1145            "avif: tmap item {tmap_id} has {} dimg inputs, expected exactly 2 (base, gain map) \
1146             — HEIF Amd 1 §6.6.2.4.1",
1147            other.len()
1148        ))),
1149    }
1150}
1151
1152/// The `colr` of an item as the container's type, when it has one.
1153fn container_colr(hdr: &AvifHeader<'_>, item_id: u32) -> Option<hprops::Colr> {
1154    match hdr.meta.property_for(item_id, &COLR) {
1155        Some(Property::Colr(c)) => Some(hprops::Colr::from(c)),
1156        _ => None,
1157    }
1158}
1159
1160/// Decode a `tmap` derived item with its gain map applied (HEIF Amd 1
1161/// §6.6.2.4.1 "Reconstruction is done by applying the gain map to the
1162/// base image according to the algorithm described in ISO 21496-1";
1163/// the item's `colr` describes "the reconstructed image if the gain
1164/// map input item is fully applied"): the base's output image (alpha
1165/// included when the caller renders alpha) and the gain map's output
1166/// image go through the container's normative reconstruction — the
1167/// `ToneMapImage` body (version 0 + ISO 21496-1 C.2 metadata), the
1168/// base's and the gain map's `colr`, the `tmap` item's `colr` as the
1169/// alternate colorimetry, its `pixi` depth (else the base's), the
1170/// configured HDR reference white for a PQ alternate.
1171fn decode_tone_map_item(
1172    hdr: &AvifHeader<'_>,
1173    tmap_id: u32,
1174    depth: u32,
1175    ctx: &mut DecodeCtx,
1176    with_alpha: bool,
1177) -> Result<ItemImage> {
1178    let (base_id, gain_id) = tone_map_inputs(hdr, tmap_id)?;
1179    if hdr.meta.location_by_id(tmap_id).is_none() {
1180        return Err(Error::invalid("avif: tmap item missing in iloc"));
1181    }
1182    let body = hdr.item_data(tmap_id).map_err(core_err)?;
1183    let metadata = GainMapMetadata::parse_tmap_body(&body).map_err(heif_err)?;
1184    let base = decode_item_output(hdr, base_id, depth + 1, ctx, with_alpha)?;
1185    let gain = decode_item_output(hdr, gain_id, depth + 1, ctx, false)?;
1186    let base_colr = container_colr(hdr, base_id);
1187    let gain_colr = container_colr(hdr, gain_id);
1188    let alternate_colr = container_colr(hdr, tmap_id);
1189    let bit_depth = match hdr.meta.property_for(tmap_id, &PIXI) {
1190        Some(Property::Pixi(p)) if p.max_bit_depth() > 0 => p.max_bit_depth(),
1191        _ => base.format.bit_depth,
1192    };
1193    let reference_white = if ctx.reference_white_nits > 0.0 {
1194        ctx.reference_white_nits
1195    } else {
1196        DEFAULT_HDR_REFERENCE_WHITE_NITS
1197    };
1198    reconstruct_tone_map(
1199        &base,
1200        base_colr.as_ref(),
1201        &gain,
1202        gain_colr.as_ref(),
1203        alternate_colr.as_ref(),
1204        &metadata,
1205        bit_depth,
1206        reference_white,
1207    )
1208    .map_err(heif_err)
1209}
1210
1211/// True when `item_id`'s colour samples are signalled pre-multiplied
1212/// by its alpha auxiliary — a `prem` item reference in either
1213/// direction (HEIF 2017 §6.10.1.1 alpha → master; HEIF 2025 §6.9.1
1214/// master → auxiliary).
1215fn is_premultiplied(hdr: &AvifHeader<'_>, item_id: u32) -> bool {
1216    hdr.meta
1217        .irefs
1218        .iter()
1219        .any(|e| e.reference_type == PREM && (e.from_id == item_id || e.to_ids.contains(&item_id)))
1220}
1221
1222/// Decode an `iovl` derived item: parse the descriptor, decode every
1223/// input's output image (alpha included), and composite onto the
1224/// canvas (HEIF §6.6.2.2 + §6.9.1) — the fill converted with the
1225/// output's colour information (MIAF §7.3.6.4 default when absent).
1226fn decode_overlay_item(
1227    hdr: &AvifHeader<'_>,
1228    iovl_id: u32,
1229    depth: u32,
1230    ctx: &mut DecodeCtx,
1231) -> Result<ItemImage> {
1232    if hdr.meta.location_by_id(iovl_id).is_none() {
1233        return Err(Error::invalid("avif: iovl item missing in iloc"));
1234    }
1235    let bytes = hdr.item_data(iovl_id).map_err(core_err)?;
1236    let sources = hdr.meta.iref_targets(&DIMG, iovl_id);
1237    if sources.is_empty() {
1238        return Err(Error::invalid("avif: iovl item has no dimg iref"));
1239    }
1240    let desc = ImageOverlay::parse(&bytes, sources.len()).map_err(core_err)?;
1241    let area = u64::from(desc.output_width) * u64::from(desc.output_height);
1242    if desc.output_width == 0 || desc.output_height == 0 {
1243        return Err(Error::invalid("avif: iovl canvas dimensions zero"));
1244    }
1245    if area > crate::overlay::MAX_OVERLAY_CANVAS_PIXELS {
1246        return Err(Error::invalid(format!(
1247            "avif: iovl canvas {}x{} exceeds {} pixels",
1248            desc.output_width,
1249            desc.output_height,
1250            crate::overlay::MAX_OVERLAY_CANVAS_PIXELS
1251        )));
1252    }
1253    if area > ctx.max_pixels {
1254        return Err(Error::ResourceExhausted(format!(
1255            "avif: iovl canvas {}x{} exceeds the {} pixels allowed",
1256            desc.output_width, desc.output_height, ctx.max_pixels
1257        )));
1258    }
1259    let mut inputs: Vec<(HeifFrame, bool)> = Vec::with_capacity(sources.len());
1260    for (i, &src) in sources.iter().enumerate() {
1261        let image = decode_item_output(hdr, src, depth + 1, ctx, true)?;
1262        if let Some((first, _)) = inputs.first() {
1263            if (first.format.chroma, first.format.bit_depth)
1264                != (image.format.chroma, image.format.bit_depth)
1265            {
1266                return Err(Error::unsupported(format!(
1267                    "avif overlay: input {i} layout ({}-bit {:?}) differs from input 0 \
1268                     ({}-bit {:?}) — mixed layouts are not composited",
1269                    image.format.bit_depth,
1270                    image.format.chroma,
1271                    first.format.bit_depth,
1272                    first.format.chroma
1273                )));
1274            }
1275        }
1276        inputs.push((image, is_premultiplied(hdr, src)));
1277    }
1278    let colr = match hdr.meta.property_for(iovl_id, &COLR) {
1279        Some(Property::Colr(c)) => Some(c.clone()),
1280        _ => match hdr.meta.property_for(sources[0], &COLR) {
1281            Some(Property::Colr(c)) => Some(c.clone()),
1282            _ => None,
1283        },
1284    };
1285    let colr = effective_cicp(colr.as_ref()).to_colr();
1286    let overlay_inputs: Vec<compose::OverlayInput<'_>> = inputs
1287        .iter()
1288        .map(|(frame, premultiplied)| compose::OverlayInput::new(frame, *premultiplied))
1289        .collect();
1290    crate::overlay::composite_overlay_frames(&desc, &overlay_inputs, &colr).map_err(core_err)
1291}
1292
1293/// Reduce a decoded alpha auxiliary to the single-plane gray frame the
1294/// attachment consumes: only the luma plane of a colour-coded alpha is
1295/// relevant (HEIF §6.9.1), and an alpha plane whose extents differ from
1296/// the master's is resized to them (§6.9.1 — the rescaling is
1297/// unspecified; this crate's reading is nearest-neighbour, kept here
1298/// so the result is independent of the container's resampler).
1299fn alpha_as_gray(alpha: &ItemImage, master_w: u32, master_h: u32) -> Result<ItemImage> {
1300    let depth = alpha.format.bit_depth;
1301    let bps = alpha.format.bytes_per_sample();
1302    let (aw, ah) = (alpha.width, alpha.height);
1303    let luma = &alpha.planes[0];
1304    let mut data = Vec::with_capacity(master_w as usize * master_h as usize * bps);
1305    if (aw, ah) == (master_w, master_h) {
1306        for r in 0..ah as usize {
1307            let s = r * luma.stride;
1308            data.extend_from_slice(&luma.data[s..s + aw as usize * bps]);
1309        }
1310    } else {
1311        for r in 0..master_h {
1312            let sr = (u64::from(r) * u64::from(ah) / u64::from(master_h)) as usize;
1313            let row = sr * luma.stride;
1314            for c in 0..master_w {
1315                let sc = (u64::from(c) * u64::from(aw) / u64::from(master_w)) as usize;
1316                data.extend_from_slice(&luma.data[row + sc * bps..row + (sc + 1) * bps]);
1317            }
1318        }
1319    }
1320    let format = HeifPixelFormat::new(Chroma::Mono, depth, false).map_err(heif_err)?;
1321    let out = HeifFrame {
1322        width: master_w,
1323        height: master_h,
1324        format,
1325        planes: vec![Plane {
1326            stride: master_w as usize * bps,
1327            data,
1328        }],
1329    };
1330    out.validate().map_err(heif_err)?;
1331    Ok(out)
1332}
1333
1334impl Decoder for AvifDecoder {
1335    fn codec_id(&self) -> &CodecId {
1336        &self.codec_id
1337    }
1338
1339    fn send_packet(&mut self, packet: &Packet) -> Result<()> {
1340        // Every AVIF packet is a complete file. We dispatch to the
1341        // sequence (`avis`) path when the brand classification flags
1342        // it; otherwise fall through to the still-image path. The
1343        // still-image path is also used when the file claims `avis`
1344        // but lacks a `moov` (rare malformed-encoder output) — that
1345        // way we don't lose access to a valid `meta`-only image just
1346        // because the brand label was wrong.
1347        //
1348        // Classification reads only the `ftyp` box: a pure
1349        // image-sequence file legally carries just `ftyp` + `moov` +
1350        // `mdat` with no `meta` (the MetaBox belongs to image *items*),
1351        // so the still-image header walk must not gate the sequence
1352        // route.
1353        let (ftyp_payload, _) = crate::box_parser::find_box(&packet.data, &b(b"ftyp"))
1354            .map_err(core_err)?
1355            .ok_or_else(|| Error::invalid("avif: missing ftyp"))?;
1356        let (major_brand, _minor, compatible_brands) =
1357            crate::parser::parse_ftyp(ftyp_payload).map_err(core_err)?;
1358        let brands = classify_brands(&major_brand, &compatible_brands).map_err(core_err)?;
1359        if brands.is_sequence || brands.has_msf1 {
1360            // Probe for moov; fall back to still-image path when the
1361            // sequence claim is bogus.
1362            if crate::box_parser::find_box(&packet.data, &b(b"moov"))
1363                .ok()
1364                .flatten()
1365                .is_some()
1366            {
1367                self.decode_avis_file(&packet.data).map(|_| ())
1368            } else {
1369                self.decode_file(&packet.data).map(|_| ())
1370            }
1371        } else {
1372            self.decode_file(&packet.data).map(|_| ())
1373        }
1374    }
1375
1376    fn receive_frame(&mut self) -> Result<Frame> {
1377        if self.pending.is_empty() {
1378            return Err(Error::NeedMore);
1379        }
1380        Ok(self.pending.remove(0))
1381    }
1382
1383    fn flush(&mut self) -> Result<()> {
1384        Ok(())
1385    }
1386
1387    fn reset(&mut self) -> Result<()> {
1388        self.pending.clear();
1389        self.info = None;
1390        self.color_signal = None;
1391        self.output_format = None;
1392        Ok(())
1393    }
1394
1395    /// The worker budget for the AV1 decode of every item / sample
1396    /// (see [`DecodeOptions::threads`]); an explicit `threads` option
1397    /// wins over it. Byte-identical output at any budget.
1398    fn set_execution_context(&mut self, ctx: &ExecutionContext) {
1399        self.exec_threads = ctx.threads.max(1);
1400    }
1401
1402    /// The label of the frames of the last decoded file
1403    /// ([`AvifDecoder::output_format`]); `None` before the first
1404    /// decode.
1405    fn output_pixel_format(&self) -> Option<PixelFormat> {
1406        self.output_format
1407    }
1408}
1409
1410/// The registry decoder's option schema (see
1411/// [`AvifDecoder::with_params`]), parsed strictly through
1412/// [`oxideav_core::parse_options`].
1413#[non_exhaustive]
1414#[derive(Clone, Copy, Debug, PartialEq)]
1415pub struct AvifDecoderOptions {
1416    /// `gain_map=apply` (`tone_mapped`): the `tmap` alternative of the
1417    /// primary with its gain map applied; default `false` = the base.
1418    pub tone_mapped: bool,
1419    /// `reference_white` in cd/m² (default 203).
1420    pub reference_white: f64,
1421    /// `threads`: the AV1 worker budget; `0` (default) follows the
1422    /// execution context.
1423    pub threads: u32,
1424    /// `max_width` (`0` = unlimited; default 16384).
1425    pub max_width: u32,
1426    /// `max_height` (`0` = unlimited; default 16384).
1427    pub max_height: u32,
1428    /// `max_pixels` (`0` = unlimited; default `1 << 28`).
1429    pub max_pixels: u32,
1430    /// `max_bytes` (`0` = unlimited; default `1 << 30`).
1431    pub max_bytes: u32,
1432    /// `strict` conformance (default `false`).
1433    pub strict: bool,
1434    /// `layer`: the spatial layer to render (`-1` = the file's `lsel`).
1435    pub layer: i32,
1436}
1437
1438impl Default for AvifDecoderOptions {
1439    fn default() -> Self {
1440        let d = DecodeOptions::default();
1441        Self {
1442            tone_mapped: d.tone_mapped,
1443            reference_white: d.reference_white_nits,
1444            threads: 0,
1445            max_width: d.max_width.unwrap_or(0),
1446            max_height: d.max_height.unwrap_or(0),
1447            max_pixels: d
1448                .max_pixels
1449                .map(|p| p.min(u64::from(u32::MAX)) as u32)
1450                .unwrap_or(0),
1451            max_bytes: d
1452                .max_bytes
1453                .map(|p| p.min(u64::from(u32::MAX)) as u32)
1454                .unwrap_or(0),
1455            strict: d.strict,
1456            layer: -1,
1457        }
1458    }
1459}
1460
1461impl AvifDecoderOptions {
1462    /// The defaults (the [`DecodeOptions`] defaults, serial, the
1463    /// file's `lsel`).
1464    pub fn new() -> Self {
1465        Self::default()
1466    }
1467
1468    /// Fold these options into a [`DecodeOptions`] (every field set).
1469    pub fn apply_to(&self, mut o: DecodeOptions) -> DecodeOptions {
1470        let nz32 = |v: u32| (v > 0).then_some(v);
1471        o.tone_mapped = self.tone_mapped;
1472        o.reference_white_nits = self.reference_white;
1473        o.threads = (self.threads > 0).then_some(self.threads as usize);
1474        o.max_width = nz32(self.max_width);
1475        o.max_height = nz32(self.max_height);
1476        o.max_pixels = nz32(self.max_pixels).map(u64::from);
1477        o.max_bytes = nz32(self.max_bytes).map(u64::from);
1478        o.strict = self.strict;
1479        o.layer = u16::try_from(self.layer).ok();
1480        o
1481    }
1482}
1483
1484impl oxideav_core::CodecOptionsStruct for AvifDecoderOptions {
1485    const SCHEMA: &'static [oxideav_core::OptionField] = &[
1486        oxideav_core::OptionField {
1487            name: "gain_map",
1488            kind: oxideav_core::OptionKind::Enum(&["base", "apply"]),
1489            default: oxideav_core::OptionValue::String(String::new()),
1490            help: "base (default): the primary as readers without tone-map support show it; apply: the tmap alternative with its gain map applied",
1491        },
1492        oxideav_core::OptionField {
1493            name: "reference_white",
1494            kind: oxideav_core::OptionKind::F32,
1495            default: oxideav_core::OptionValue::F32(203.0),
1496            help: "HDR reference white in cd/m2 for a PQ-coded tone-mapped reconstruction (default 203)",
1497        },
1498        oxideav_core::OptionField {
1499            name: "threads",
1500            kind: oxideav_core::OptionKind::U32,
1501            default: oxideav_core::OptionValue::U32(0),
1502            help: "AV1 decode worker budget (tile / band / wave / stripe parallel, byte-identical); 0 = the execution context's budget (serial unless granted)",
1503        },
1504        oxideav_core::OptionField {
1505            name: "max_width",
1506            kind: oxideav_core::OptionKind::U32,
1507            default: oxideav_core::OptionValue::U32(16_384),
1508            help: "Refuse a picture wider than this (0 = unlimited)",
1509        },
1510        oxideav_core::OptionField {
1511            name: "max_height",
1512            kind: oxideav_core::OptionKind::U32,
1513            default: oxideav_core::OptionValue::U32(16_384),
1514            help: "Refuse a picture taller than this (0 = unlimited)",
1515        },
1516        oxideav_core::OptionField {
1517            name: "max_pixels",
1518            kind: oxideav_core::OptionKind::U32,
1519            default: oxideav_core::OptionValue::U32(1 << 28),
1520            help: "Refuse a picture or derived canvas with more pixels than this (0 = unlimited)",
1521        },
1522        oxideav_core::OptionField {
1523            name: "max_bytes",
1524            kind: oxideav_core::OptionKind::U32,
1525            default: oxideav_core::OptionValue::U32(1 << 30),
1526            help: "Refuse an input longer than this many bytes (0 = unlimited)",
1527        },
1528        oxideav_core::OptionField {
1529            name: "strict",
1530            kind: oxideav_core::OptionKind::Bool,
1531            default: oxideav_core::OptionValue::Bool(false),
1532            help: "Strict conformance: an AVIF brand is required and a claimed mif1 must carry its mandatory boxes",
1533        },
1534        oxideav_core::OptionField {
1535            name: "layer",
1536            kind: oxideav_core::OptionKind::I32,
1537            default: oxideav_core::OptionValue::I32(-1),
1538            help: "Spatial layer of a layered primary to render (-1 = the file's lsel)",
1539        },
1540    ];
1541
1542    fn apply(&mut self, key: &str, value: &oxideav_core::OptionValue) -> Result<()> {
1543        match key {
1544            "gain_map" => self.tone_mapped = value.as_str()? == "apply",
1545            "reference_white" => self.reference_white = f64::from(value.as_f32()?),
1546            "threads" => self.threads = value.as_u32()?,
1547            "max_width" => self.max_width = value.as_u32()?,
1548            "max_height" => self.max_height = value.as_u32()?,
1549            "max_pixels" => self.max_pixels = value.as_u32()?,
1550            "max_bytes" => self.max_bytes = value.as_u32()?,
1551            "strict" => self.strict = value.as_bool()?,
1552            "layer" => {
1553                let l = value.as_i32()?;
1554                if l < -1 || l > i32::from(u16::MAX) {
1555                    return Err(Error::invalid(format!(
1556                        "avif: layer {l} is outside -1..=65535"
1557                    )));
1558                }
1559                self.layer = l;
1560            }
1561            other => return Err(Error::invalid(format!("unknown option '{other}'"))),
1562        }
1563        Ok(())
1564    }
1565}
1566
1567pub fn make_decoder(params: &CodecParameters) -> Result<Box<dyn Decoder>> {
1568    Ok(Box::new(AvifDecoder::with_params(params)?))
1569}
1570
1571#[cfg(test)]
1572mod tests {
1573    use super::*;
1574
1575    const FIXTURE: &[u8] = include_bytes!("../tests/fixtures/monochrome.avif");
1576
1577    /// `validate_av1_config` rejects `seq_profile > 2` (AV1 §A.4 reserves
1578    /// profiles 3..=7).
1579    #[test]
1580    fn validate_av1_config_rejects_high_profile() {
1581        let mut cfg = parse_av1c(&[0x81, 0x00, 0x0c, 0x00]).unwrap();
1582        cfg.seq_profile = 3;
1583        let err = validate_av1_config(&cfg).unwrap_err();
1584        match err {
1585            Error::InvalidData(s) => assert!(s.contains("seq_profile"), "got: {s}"),
1586            other => panic!("expected InvalidData, got {other:?}"),
1587        }
1588    }
1589
1590    /// `validate_av1_config` rejects reserved `seq_level_idx_0` values
1591    /// (24..=30 are reserved per AV1 §A.3).
1592    #[test]
1593    fn validate_av1_config_rejects_reserved_level() {
1594        let mut cfg = parse_av1c(&[0x81, 0x00, 0x0c, 0x00]).unwrap();
1595        cfg.seq_level_idx_0 = 27;
1596        let err = validate_av1_config(&cfg).unwrap_err();
1597        match err {
1598            Error::InvalidData(s) => assert!(s.contains("seq_level_idx_0"), "got: {s}"),
1599            other => panic!("expected InvalidData, got {other:?}"),
1600        }
1601    }
1602
1603    /// `validate_av1_config` rejects a monochrome record that doesn't
1604    /// also set chroma_subsampling_x=chroma_subsampling_y=1 — AV1 §5.5.2
1605    /// requires both bits for 4:0:0.
1606    #[test]
1607    fn validate_av1_config_rejects_monochrome_without_subsampling() {
1608        let mut cfg = parse_av1c(&[0x81, 0x00, 0x0c, 0x00]).unwrap();
1609        cfg.monochrome = true;
1610        cfg.chroma_subsampling_x = false;
1611        cfg.chroma_subsampling_y = true;
1612        let err = validate_av1_config(&cfg).unwrap_err();
1613        match err {
1614            Error::InvalidData(s) => assert!(s.contains("monochrome"), "got: {s}"),
1615            other => panic!("expected InvalidData, got {other:?}"),
1616        }
1617    }
1618
1619    /// `validate_av1_config` rejects 4:2:2 declared on `seq_profile=0` —
1620    /// AV1 §5.5.2 only allows 4:2:2 chroma in profile 2.
1621    #[test]
1622    fn validate_av1_config_rejects_422_outside_profile_2() {
1623        let mut cfg = parse_av1c(&[0x81, 0x00, 0x0c, 0x00]).unwrap();
1624        cfg.seq_profile = 0;
1625        cfg.chroma_subsampling_x = true;
1626        cfg.chroma_subsampling_y = false;
1627        let err = validate_av1_config(&cfg).unwrap_err();
1628        match err {
1629            Error::InvalidData(s) => assert!(s.contains("4:2:2"), "got: {s}"),
1630            other => panic!("expected InvalidData, got {other:?}"),
1631        }
1632    }
1633
1634    /// `validate_av1_config` rejects 4:4:4 on `seq_profile=0` — AV1
1635    /// §5.5.2 confines 4:4:4 to profiles 1 and 2.
1636    #[test]
1637    fn validate_av1_config_rejects_444_in_profile_0() {
1638        let mut cfg = parse_av1c(&[0x81, 0x00, 0x0c, 0x00]).unwrap();
1639        cfg.seq_profile = 0;
1640        cfg.chroma_subsampling_x = false;
1641        cfg.chroma_subsampling_y = false;
1642        let err = validate_av1_config(&cfg).unwrap_err();
1643        match err {
1644            Error::InvalidData(s) => assert!(s.contains("4:4:4"), "got: {s}"),
1645            other => panic!("expected InvalidData, got {other:?}"),
1646        }
1647    }
1648
1649    /// `validate_av1_config` accepts the canonical 4:2:0 / profile-0 /
1650    /// level 1 layout used by every still-image AVIF fixture in this
1651    /// crate's `tests/fixtures/`.
1652    #[test]
1653    fn validate_av1_config_accepts_canonical_420_profile0() {
1654        // 0x81 = marker=1 version=1; 0x00 = seq_profile=0 level=0;
1655        // 0x0c = chroma_subsampling_x=chroma_subsampling_y=1 (4:2:0).
1656        let cfg = parse_av1c(&[0x81, 0x00, 0x0c, 0x00]).unwrap();
1657        validate_av1_config(&cfg).expect("canonical 4:2:0 / profile 0 must validate");
1658    }
1659
1660    #[test]
1661    fn decoder_decodes_monochrome_fixture_end_to_end() {
1662        // The AV1 hand-off is the conformance-grade `oxideav_av1`
1663        // spec-driver decoder, so this real-world 1280×720 monochrome
1664        // still must decode to pixels — a decode error here is a
1665        // regression in the container hand-off or the AV1 crate.
1666        let mut d = AvifDecoder::new(CodecId::new(crate::CODEC_ID_STR));
1667        let pkt = Packet::new(0, TimeBase::new(1, 1), FIXTURE.to_vec());
1668        d.send_packet(&pkt).expect("send_packet");
1669        let frame = d.receive_frame().expect("receive_frame");
1670        let vf = match frame {
1671            Frame::Video(v) => v,
1672            other => panic!("expected VideoFrame, got {other:?}"),
1673        };
1674        // Monochrome primary, no alpha auxiliary → exactly one plane
1675        // at the fixture's 1280×720 extent.
1676        assert_eq!(vf.image_plane_count(), 1);
1677        let y = &vf.planes[0];
1678        assert_eq!(y.stride, 1280);
1679        assert_eq!(y.data.len() / y.stride, 720);
1680    }
1681}