Skip to main content

oxideav_avif/
derived.rs

1//! Derived-image descriptors and entity grouping — HEIF §6.6.2 + §9.4,
2//! plus AV1-AVIF v1.2.0 §4.2.3 sample-transform derivations.
3//!
4//! AVIF restricts derived-image carriage to the `grid` form (av1-avif
5//! §4.2), but a reader that walks AVIF / HEIF files in the wild will
6//! encounter HEIF features layered on top of an AVIF brand:
7//!
8//! * **`iovl` image-overlay derivations** (HEIF §6.6.2.2): one or more
9//!   source images placed at signed `(x, y)` offsets on a fixed canvas
10//!   with an RGBA fill colour.
11//! * **`iden` identity derivations** (HEIF §6.6.2.1): the source image
12//!   passed through unchanged, useful when transformative properties
13//!   on the derivation differ from those on the source.
14//! * **`sato` sample-transform derivations** (av1-avif v1.2.0 §4.2.3):
15//!   a postfix-notation expression of integer operators and operands
16//!   evaluated per sample to combine pixels from one or more input
17//!   image items. See [`SampleTransform`].
18//! * **Entity grouping** (HEIF §9.4): `grpl` containing one or more
19//!   `EntityToGroupBox` per grouping type. The common groupings are
20//!   `altr` (alternates), `ster` (stereo pair), `eqiv` (timeline
21//!   equivalence to a track sample).
22//!
23//! All parsers here operate on raw box payload bytes — they're
24//! independent of the [`crate::parser`] file walker and the
25//! [`crate::meta`] item-property pipeline, so a caller can apply them
26//! to any byte range that follows the documented layout. The
27//! [`crate::parser::AvifHeader`] walker now exposes a `grpl` slice
28//! through [`crate::meta::Meta::groups`] for callers that need to
29//! enumerate AVIF/HEIF alternates without rebuilding the container
30//! traversal.
31
32use crate::box_parser::{b, iter_boxes, parse_full_box, read_u16, read_u32, type_str, BoxType};
33use crate::error::{AvifError as Error, Result};
34
35/// One placed image inside an `iovl` overlay descriptor (HEIF §6.6.2.2).
36/// `horizontal_offset` + `vertical_offset` are signed pixel offsets from
37/// the top-left corner of the canvas; per spec, source pixels with a
38/// negative coordinate (or coordinates `>= output_width / output_height`)
39/// are clipped out of the reconstructed image.
40///
41/// The actual source image item id isn't stored here — `iovl` payload
42/// only carries the offsets; the source ids come from the parallel
43/// `dimg` iref's `to_ids` list (in the same order).
44#[doc(hidden)]
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub struct OverlayEntry {
47    pub horizontal_offset: i32,
48    pub vertical_offset: i32,
49}
50
51impl OverlayEntry {
52    /// Every field as a positional argument, in declaration order
53    /// (the record is `#[non_exhaustive]`: build it here, or from
54    /// `Default` where one exists, then read / assign the public
55    /// fields).
56    #[allow(clippy::too_many_arguments)]
57    pub fn new(horizontal_offset: i32, vertical_offset: i32) -> Self {
58        Self {
59            horizontal_offset,
60            vertical_offset,
61        }
62    }
63    /// Setter: replace `horizontal_offset`.
64    pub fn with_horizontal_offset(mut self, horizontal_offset: i32) -> Self {
65        self.horizontal_offset = horizontal_offset;
66        self
67    }
68    /// Setter: replace `vertical_offset`.
69    pub fn with_vertical_offset(mut self, vertical_offset: i32) -> Self {
70        self.vertical_offset = vertical_offset;
71        self
72    }
73}
74
75/// Parsed `iovl` ImageOverlay descriptor (HEIF §6.6.2.2). Bottom-most
76/// input image is `entries[0]`; the top-most is `entries[entries.len()-1]`.
77///
78/// `canvas_fill_value` is RGBA in sRGB (R, G, B, A) per spec; the A
79/// channel runs 0 (transparent) to 65535 (opaque) linearly. RGB values
80/// are also 16-bit, padded with zeros if the writer thought in 8-bit.
81#[doc(hidden)]
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct ImageOverlay {
84    pub canvas_fill_value: [u16; 4],
85    pub output_width: u32,
86    pub output_height: u32,
87    pub entries: Vec<OverlayEntry>,
88}
89
90impl ImageOverlay {
91    /// Every field as a positional argument, in declaration order
92    /// (the record is `#[non_exhaustive]`: build it here, or from
93    /// `Default` where one exists, then read / assign the public
94    /// fields).
95    #[allow(clippy::too_many_arguments)]
96    pub fn new(
97        canvas_fill_value: [u16; 4],
98        output_width: u32,
99        output_height: u32,
100        entries: Vec<OverlayEntry>,
101    ) -> Self {
102        Self {
103            canvas_fill_value,
104            output_width,
105            output_height,
106            entries,
107        }
108    }
109    /// Setter: replace `canvas_fill_value`.
110    pub fn with_canvas_fill_value(mut self, canvas_fill_value: [u16; 4]) -> Self {
111        self.canvas_fill_value = canvas_fill_value;
112        self
113    }
114    /// Setter: replace `output_width`.
115    pub fn with_output_width(mut self, output_width: u32) -> Self {
116        self.output_width = output_width;
117        self
118    }
119    /// Setter: replace `output_height`.
120    pub fn with_output_height(mut self, output_height: u32) -> Self {
121        self.output_height = output_height;
122        self
123    }
124    /// Setter: replace `entries`.
125    pub fn with_entries(mut self, entries: Vec<OverlayEntry>) -> Self {
126        self.entries = entries;
127        self
128    }
129}
130
131impl ImageOverlay {
132    /// Parse an `iovl` item body; `reference_count` is the number of
133    /// `dimg` inputs the item declares (HEIF §6.6.2.2.2).
134    pub fn parse(payload: &[u8], reference_count: usize) -> Result<Self> {
135        match payload.first() {
136            None => return Err(Error::invalid("avif: iovl header too short")),
137            Some(0) => {}
138            Some(v) => return Err(Error::invalid(format!("avif: iovl version {v} != 0"))),
139        }
140        let desc = oxideav_heif::derived::OverlayDescriptor::parse(payload, reference_count)?;
141        Ok(ImageOverlay {
142            canvas_fill_value: desc.canvas_fill,
143            output_width: desc.output_width,
144            output_height: desc.output_height,
145            entries: desc
146                .offsets
147                .iter()
148                .map(|&(h, v)| OverlayEntry {
149                    horizontal_offset: h,
150                    vertical_offset: v,
151                })
152                .collect(),
153        })
154    }
155
156    /// The container crate's view of the same descriptor.
157    pub(crate) fn descriptor(&self) -> oxideav_heif::derived::OverlayDescriptor {
158        oxideav_heif::derived::OverlayDescriptor::new(
159            self.canvas_fill_value,
160            self.output_width,
161            self.output_height,
162            self.entries
163                .iter()
164                .map(|e| (e.horizontal_offset, e.vertical_offset))
165                .collect(),
166        )
167    }
168}
169
170/// One `EntityToGroupBox` entry (HEIF / ISOBMFF §8.15.3 / 23008-12 §9.4.3).
171/// `grouping_type` is a four-CC declaring the relationship between the
172/// listed entity ids: `altr` (alternates), `ster` (stereo pair), `eqiv`
173/// (equivalence to a track sample), and others.
174///
175/// `entity_ids` are conventionally `item_ID` values from the same `meta`
176/// (file-level `grpl` references file-level items; per §9.4.1). When a
177/// grouping mixes items and tracks, the resolver chooses based on which
178/// id matches — that's a caller-side concern.
179#[non_exhaustive]
180#[derive(Clone, Debug, PartialEq, Eq)]
181pub struct EntityGroup {
182    pub grouping_type: BoxType,
183    pub group_id: u32,
184    pub entity_ids: Vec<u32>,
185    /// The 24-bit `flags` field of the `EntityToGroupBox` FullBox header
186    /// (ISO/IEC 14496-12 §8.15.3.2). Most grouping types leave it `0`,
187    /// but some carry semantics here — e.g. the `'iaug'` audio-to-image
188    /// group (HEIF §6.8.4) uses the least-significant flag bit to signal
189    /// that the audio track should repeat for the whole viewing
190    /// duration.
191    pub flags: u32,
192}
193
194impl EntityGroup {
195    /// Every field as a positional argument, in declaration order
196    /// (the record is `#[non_exhaustive]`: build it here, or from
197    /// `Default` where one exists, then read / assign the public
198    /// fields).
199    #[allow(clippy::too_many_arguments)]
200    pub fn new(grouping_type: BoxType, group_id: u32, entity_ids: Vec<u32>, flags: u32) -> Self {
201        Self {
202            grouping_type,
203            group_id,
204            entity_ids,
205            flags,
206        }
207    }
208}
209
210impl EntityGroup {
211    /// True when the grouping type signals stereo pair (HEIF §9.4.3.1).
212    /// Reader convention: `entity_ids[0]` is the left view,
213    /// `entity_ids[1]` is the right view.
214    pub fn is_stereo_pair(&self) -> bool {
215        &self.grouping_type == b"ster"
216    }
217
218    /// True when the grouping type signals an alternate set
219    /// (HEIF §9.4.3.1) — the reader picks one of `entity_ids` and
220    /// discards the others.
221    pub fn is_alternates(&self) -> bool {
222        &self.grouping_type == b"altr"
223    }
224
225    /// True when the grouping type signals timeline equivalence to a
226    /// track sample (HEIF §6.8.1).
227    pub fn is_equivalence(&self) -> bool {
228        &self.grouping_type == b"eqiv"
229    }
230
231    /// True when the grouping type signals a set of images captured in
232    /// order to create a panorama (HEIF §6.8.8.1), listed in
233    /// increasing panorama order. The §6.5.27 `pano` item property
234    /// describing the panorama direction `should` only be associated
235    /// with an entity group of this type (§6.5.27.1).
236    pub fn is_panorama(&self) -> bool {
237        &self.grouping_type == b"pano"
238    }
239
240    /// True when the grouping type signals a progressive rendering set
241    /// (`'prgr'`, HEIF §6.8.10): the listed image items are similar images
242    /// at different quality levels, stored — and listed in the group — in
243    /// **increasing picture quality order**, so a renderer can show a
244    /// first low-quality image quickly and refine it. A `'prgr'` group
245    /// contains only image items, never tracks (§6.8.10).
246    pub fn is_progressive(&self) -> bool {
247        &self.grouping_type == b"prgr"
248    }
249
250    /// True when the grouping type signals a burst image set (`'brst'`,
251    /// HEIF §6.8.9): a series of rapid-succession images whose `entity_id`
252    /// values are listed in temporally increasing order.
253    pub fn is_burst(&self) -> bool {
254        &self.grouping_type == b"brst"
255    }
256
257    /// True when the grouping type signals a multi-source grouping
258    /// (`'msrc'`, HEIF §9.4): tracks/items that originate from the same
259    /// source (e.g. the visual tracks for the same captured content,
260    /// alongside their associated audio).
261    pub fn is_multi_source(&self) -> bool {
262        &self.grouping_type == b"msrc"
263    }
264
265    /// Check the §6.8 cardinality `shall`s that can be verified from the
266    /// group alone (without resolving whether each `entity_id` is an
267    /// image item, a track, or another group). Returns `true` when the
268    /// group's `entity_ids` count satisfies its grouping-type constraint,
269    /// and `true` (vacuously) for grouping types with no fixed-count
270    /// `shall`:
271    ///
272    /// * `'ster'` (§6.8.5) — exactly two image items.
273    /// * `'iaug'` (§6.8.4) — exactly two entities (one image item/group,
274    ///   one audio track).
275    ///
276    /// This is a necessary — not sufficient — compliance check: the
277    /// per-entity type constraints (image vs audio) require track/item
278    /// resolution the caller must perform separately.
279    pub fn cardinality_ok(&self) -> bool {
280        match &self.grouping_type {
281            b"ster" | b"iaug" => self.entity_ids.len() == 2,
282            _ => true,
283        }
284    }
285
286    /// True when the grouping type signals a time-synchronized capture
287    /// set (`'tsyn'`, HEIF §6.8.3): the listed entities were
288    /// synchronously captured spanning the same time. A single `'tsyn'`
289    /// group `shall` list `entity_id` values that all resolve to image
290    /// items or all to image-sequence tracks — never a mixture — and
291    /// any tracks in the group `shall` have the same duration.
292    pub fn is_time_synchronized(&self) -> bool {
293        &self.grouping_type == b"tsyn"
294    }
295
296    /// True when the grouping type signals an audio-to-image association
297    /// (`'iaug'`, HEIF §6.8.4): exactly two entities, one an image item
298    /// (or an entity group) and the other an audio track, so a reader
299    /// can play the audio while the image is displayed.
300    pub fn is_audio_to_image(&self) -> bool {
301        &self.grouping_type == b"iaug"
302    }
303
304    /// For an `'iaug'` audio-to-image group (HEIF §6.8.4): `true` when
305    /// the least-significant `flags` bit is set, requesting that the
306    /// audio track repeat for the whole viewing duration when the audio
307    /// is shorter than the display and its edit list does not already
308    /// set the repeat flag. Meaningless (always `false`-checked by the
309    /// caller) for other grouping types.
310    pub fn audio_repeats(&self) -> bool {
311        self.is_audio_to_image() && (self.flags & 0x1) != 0
312    }
313
314    /// True when the grouping type signals a slideshow set (`'slid'`,
315    /// HEIF §6.8.9): the listed image items form a slideshow, listed in
316    /// increasing display order, with per-item transition-effect
317    /// properties and optional `stpe`/`ssld` timing.
318    pub fn is_slideshow(&self) -> bool {
319        &self.grouping_type == b"slid"
320    }
321
322    /// True when the grouping type signals an album collection
323    /// (`'albc'`, HEIF §6.8.7.1): the listed entities form an album; an
324    /// optional `udes` user-description property may describe it.
325    pub fn is_album_collection(&self) -> bool {
326        &self.grouping_type == b"albc"
327    }
328
329    /// True when the grouping type signals a favorites collection
330    /// (`'favc'`, HEIF §6.8.7.2): the listed entities form a collection
331    /// of favorite images; an optional `udes` property may describe it.
332    pub fn is_favorites_collection(&self) -> bool {
333        &self.grouping_type == b"favc"
334    }
335
336    /// True when the grouping type is one of the HEIF §6.8.7
337    /// user-defined image collections (`'albc'` album / `'favc'`
338    /// favorites).
339    pub fn is_user_collection(&self) -> bool {
340        self.is_album_collection() || self.is_favorites_collection()
341    }
342
343    /// True when the grouping type is one of the HEIF §6.8.6 bracketed
344    /// capture-time sets (`'aebr'` auto-exposure, `'wbbr'` white-balance,
345    /// `'fobr'` focus, `'afbr'` flash-exposure, `'dobr'` depth-of-field).
346    /// The listed image items were captured with a single varying
347    /// parameter; the per-item value is carried by the item property of
348    /// the same four-CC (§6.8.6.1).
349    pub fn is_bracketed_set(&self) -> bool {
350        self.bracketing_kind().is_some()
351    }
352
353    /// Classify a §6.8.6 bracketed-set grouping type, or `None` for any
354    /// other grouping type.
355    pub fn bracketing_kind(&self) -> Option<BracketingKind> {
356        BracketingKind::from_four_cc(&self.grouping_type)
357    }
358}
359
360/// The five HEIF §6.8.6 bracketed-set capture-time grouping types. Each
361/// FourCC serves simultaneously as an `EntityToGroupBox` `grouping_type`
362/// (grouping the image items of a bracket) and as the box type of the
363/// per-item item property that carries the item-specific parameter, and
364/// — for image sequences — as a sample-group `grouping_type` whose
365/// `VisualSampleGroupEntry` subclass carries the per-sample parameter
366/// (see [`BracketingEntry`]).
367#[non_exhaustive]
368#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
369pub enum BracketingKind {
370    /// `'aebr'` — auto-exposure bracketing (§6.8.6.2).
371    AutoExposure,
372    /// `'wbbr'` — white-balance bracketing (§6.8.6.3).
373    WhiteBalance,
374    /// `'fobr'` — focus bracketing (§6.8.6.4).
375    Focus,
376    /// `'afbr'` — flash-exposure bracketing (§6.8.6.5).
377    FlashExposure,
378    /// `'dobr'` — depth-of-field bracketing (§6.8.6.6).
379    DepthOfField,
380}
381
382impl BracketingKind {
383    /// Classify a four-CC as a §6.8.6 bracketed-set kind, or `None`.
384    pub fn from_four_cc(fourcc: &[u8; 4]) -> Option<BracketingKind> {
385        match fourcc {
386            b"aebr" => Some(BracketingKind::AutoExposure),
387            b"wbbr" => Some(BracketingKind::WhiteBalance),
388            b"fobr" => Some(BracketingKind::Focus),
389            b"afbr" => Some(BracketingKind::FlashExposure),
390            b"dobr" => Some(BracketingKind::DepthOfField),
391            _ => None,
392        }
393    }
394
395    /// The four-CC that identifies this bracketing kind.
396    pub fn four_cc(&self) -> &'static [u8; 4] {
397        match self {
398            BracketingKind::AutoExposure => b"aebr",
399            BracketingKind::WhiteBalance => b"wbbr",
400            BracketingKind::Focus => b"fobr",
401            BracketingKind::FlashExposure => b"afbr",
402            BracketingKind::DepthOfField => b"dobr",
403        }
404    }
405}
406
407/// Parse a `GroupsListBox` (`grpl`) payload into its set of entity
408/// groups. Spec: ISO/IEC 23008-12 §9.4.2 (file-level grouping).
409///
410/// `grpl` itself is a plain Box containing one or more `EntityToGroupBox`
411/// children, each a FullBox keyed by `grouping_type` four-CC.
412pub fn parse_grpl(payload: &[u8]) -> Result<Vec<EntityGroup>> {
413    let mut out = Vec::new();
414    for hdr in iter_boxes(payload) {
415        let hdr = hdr?;
416        let child = &payload[hdr.payload_start..hdr.end()];
417        let (_version, flags, body) = parse_full_box(child)?;
418        if body.len() < 8 {
419            return Err(Error::invalid(format!(
420                "avif: EntityToGroupBox '{}' body too short ({} < 8)",
421                type_str(&hdr.box_type),
422                body.len()
423            )));
424        }
425        let group_id = read_u32(body, 0)?;
426        let num_entities = read_u32(body, 4)? as usize;
427        let need = 8 + num_entities * 4;
428        if body.len() < need {
429            return Err(Error::invalid(format!(
430                "avif: EntityToGroupBox '{}' truncated entity list ({} < {need})",
431                type_str(&hdr.box_type),
432                body.len()
433            )));
434        }
435        let mut entity_ids = Vec::with_capacity(num_entities);
436        for i in 0..num_entities {
437            entity_ids.push(read_u32(body, 8 + i * 4)?);
438        }
439        out.push(EntityGroup {
440            grouping_type: hdr.box_type,
441            group_id,
442            entity_ids,
443            flags,
444        });
445    }
446    Ok(out)
447}
448
449/// Result of a `mif1` brand compliance audit (HEIF §10.2.1.1).
450///
451/// A `mif1` file must contain a top-level `ftyp` + `meta` and its `meta`
452/// must contain `hdlr`, `pitm`, `iinf` + `infe` entries, `iloc`, and
453/// `iprp`. The audit is informational: AVIF files in the wild ship
454/// `mif1` as a compatible brand without strict compliance (e.g. ones
455/// emitted by ImageMagick), and our reader still accepts them. The
456/// validator exists so callers that want to enforce strict-mif1 mode
457/// can.
458#[non_exhaustive]
459#[derive(Clone, Debug, PartialEq, Eq, Default)]
460pub struct Mif1Compliance {
461    pub has_hdlr: bool,
462    pub has_pitm: bool,
463    pub has_iinf: bool,
464    pub has_iloc: bool,
465    pub has_iprp: bool,
466    /// Number of `infe` entries inside `iinf`. mif1 requires at least
467    /// one image item (the primary).
468    pub infe_count: usize,
469    /// Brand carries `mif1` in major_brand or compatible_brands.
470    pub claims_mif1: bool,
471}
472
473impl Mif1Compliance {
474    /// Every field as a positional argument, in declaration order
475    /// (the record is `#[non_exhaustive]`: build it here, or from
476    /// `Default` where one exists, then read / assign the public
477    /// fields).
478    #[allow(clippy::too_many_arguments)]
479    pub fn new(
480        has_hdlr: bool,
481        has_pitm: bool,
482        has_iinf: bool,
483        has_iloc: bool,
484        has_iprp: bool,
485        infe_count: usize,
486        claims_mif1: bool,
487    ) -> Self {
488        Self {
489            has_hdlr,
490            has_pitm,
491            has_iinf,
492            has_iloc,
493            has_iprp,
494            infe_count,
495            claims_mif1,
496        }
497    }
498}
499
500impl Mif1Compliance {
501    /// True when every mandatory mif1 reader-side box is present.
502    /// Strict spec interpretation per §10.2.1.1 table — does not include
503    /// the optional `iref` / `idat` / `iprp` of §10.2.1.2 entries that
504    /// are reader-side suggestions only.
505    pub fn is_compliant(&self) -> bool {
506        self.has_hdlr
507            && self.has_pitm
508            && self.has_iinf
509            && self.has_iloc
510            && self.has_iprp
511            && self.infe_count > 0
512    }
513
514    /// A human-friendly list of missing required boxes, useful for
515    /// diagnostics. Returns an empty list when [`Self::is_compliant`].
516    pub fn missing(&self) -> Vec<&'static str> {
517        let mut out = Vec::new();
518        if !self.has_hdlr {
519            out.push("hdlr");
520        }
521        if !self.has_pitm {
522            out.push("pitm");
523        }
524        if !self.has_iinf {
525            out.push("iinf");
526        }
527        if self.infe_count == 0 {
528            out.push("infe");
529        }
530        if !self.has_iloc {
531            out.push("iloc");
532        }
533        if !self.has_iprp {
534            out.push("iprp");
535        }
536        out
537    }
538}
539
540// ---------------------------------------------------------------------------
541// AV1-AVIF v1.2.0 §4.2.3 — Sample Transform Derived Image Item (`sato`)
542// ---------------------------------------------------------------------------
543
544/// A single token in a [`SampleTransform`] expression. The encoding is
545/// the raw 8-bit token value from the bitstream (av1-avif §4.2.3.2,
546/// Table 2). The variant carries any decoded payload (`Constant` holds
547/// the signed literal extracted from the stream; every other variant is
548/// a no-payload tag).
549///
550/// Token-value ranges (spec Table 2):
551///
552/// * `0` — `Constant(value)`. The constant is a signed integer of
553///   `2^(bit_depth+3)` bits read from the stream (so 8, 16, 32 or
554///   64 bits keyed by [`SampleTransform::bit_depth`]). The decoded value
555///   is sign-extended to `i64` for in-memory storage; it is pushed to
556///   the stack at the [`SampleTransform::num_bits`] intermediate
557///   precision when evaluated.
558/// * `1..=32` — `Sample(token)`. A 1-based index into the parallel
559///   `dimg` iref's `to_ids` list. The actual sample value comes from
560///   the named input image item at the same spatial coordinates and
561///   channel as the output sample being evaluated.
562/// * `33..=63` — reserved. Readers shall reject (av1-avif §4.2.3.3:
563///   "Readers shall ignore a Sample Transform Derived Image Item with
564///   a reserved token value"). Surfaced as [`Token::Reserved`] from
565///   [`SampleTransform::parse_relaxed`] for diagnostics; the strict
566///   [`SampleTransform::parse`] errors out instead.
567/// * `64..=67` — `Unary(op)`. One stack pop, one stack push.
568///   * `64` — negation (`-L`)
569///   * `65` — absolute value (`|L|`)
570///   * `66` — bitwise not (`¬L`)
571///   * `67` — `bsr` (0-based index of the MSB of `L` when `L > 0`,
572///     else `0`)
573/// * `68..=127` — reserved.
574/// * `128..=137` — `Binary(op)`. Two stack pops (right first, then
575///   left), one push.
576///   * `128` — sum (`L + R`)
577///   * `129` — difference (`L - R`)
578///   * `130` — product (`L * R`)
579///   * `131` — quotient (`L / R` truncated toward zero; `L` if
580///     `R == 0`)
581///   * `132` — bitwise and (`L ∧ R`)
582///   * `133` — bitwise or (`L ∨ R`)
583///   * `134` — bitwise xor (`L ⊕ R`)
584///   * `135` — pow (`L^R` truncated; `0` if `L == 0`)
585///   * `136` — min
586///   * `137` — max
587/// * `138..=255` — reserved.
588#[non_exhaustive]
589#[derive(Clone, Copy, Debug, PartialEq, Eq)]
590pub enum Token {
591    /// A literal signed constant pushed to the stack.
592    Constant(i64),
593    /// Push the sample from the `n`th input image item (1-based, so
594    /// `1..=32`).
595    Sample(u8),
596    /// Unary operator (token value `64..=67`). Carries the raw token
597    /// byte for round-trip and error reporting.
598    Unary(u8),
599    /// Binary operator (token value `128..=137`). Carries the raw token
600    /// byte.
601    Binary(u8),
602    /// Reserved token value (`33..=63`, `68..=127`, `138..=255`).
603    /// Returned only by [`SampleTransform::parse_relaxed`]; the strict
604    /// parser refuses these per av1-avif §4.2.3.3.
605    Reserved(u8),
606}
607
608impl Token {
609    /// True for [`Self::Constant`] and [`Self::Sample`] — tokens that
610    /// produce an operand without consuming any.
611    pub fn is_operand(&self) -> bool {
612        matches!(self, Token::Constant(_) | Token::Sample(_))
613    }
614
615    /// True for [`Self::Unary`] — one input pop, one push.
616    pub fn is_unary(&self) -> bool {
617        matches!(self, Token::Unary(_))
618    }
619
620    /// True for [`Self::Binary`] — two input pops, one push.
621    pub fn is_binary(&self) -> bool {
622        matches!(self, Token::Binary(_))
623    }
624}
625
626/// Parsed `sato` Sample Transform Derived Image Item descriptor
627/// (av1-avif v1.2.0 §4.2.3). The wire format is one header byte
628/// (`version:2 | reserved:4 | bit_depth:2`), then `token_count: u8`,
629/// then `token_count` tokens (each one byte plus an optional
630/// constant-value payload).
631///
632/// The reconstructed image's samples come from evaluating this
633/// expression once per channel per `(x, y)` coordinate, drawing input
634/// samples from the input image items named in the parallel `dimg`
635/// iref's `to_ids` list (`reference_count` items in declaration order).
636/// The result is the single value left on the stack after the last
637/// token; it is clamped to fit the reconstructed item's
638/// `PixelInformationProperty` bit depth.
639///
640/// Use [`Self::parse`] to decode the descriptor with strict
641/// spec-conformance — reserved token values, version mismatches, and
642/// stack-discipline violations all error. The descriptor stores the
643/// fully decoded token list so callers can either evaluate the
644/// expression themselves (via [`Self::evaluate`] on a per-sample input
645/// vector) or hand the parsed structure to a future composition layer
646/// when an AV1 decoder is available to produce the input items.
647///
648/// Caveat: composition is not implemented in oxideav yet — the
649/// `oxideav-av1` decoder is the bottleneck (see crate README). This
650/// parser unblocks structural inspection, validation, and any future
651/// composition work that lands on top.
652#[non_exhaustive]
653#[derive(Clone, Debug, PartialEq, Eq)]
654pub struct SampleTransform {
655    /// `version` field. Spec mandates `0`; readers shall ignore items
656    /// with an unrecognised version. [`Self::parse`] errors on any
657    /// non-zero version (callers that prefer "ignore" semantics can
658    /// match on the error and skip the item).
659    pub version: u8,
660    /// `bit_depth` field — selector for the intermediate precision.
661    /// `0..=3` map to 8 / 16 / 32 / 64 bits via [`Self::num_bits`].
662    /// [`Self::parse`] errors on any value outside that range (the
663    /// wire format reserves only 2 bits so a strict reader can never
664    /// observe an out-of-range value, but a relaxed parser fed garbled
665    /// bytes might).
666    pub bit_depth: u8,
667    /// Decoded token list. The first token's evaluation happens first.
668    pub tokens: Vec<Token>,
669}
670
671impl SampleTransform {
672    /// Every field as a positional argument, in declaration order
673    /// (the record is `#[non_exhaustive]`: build it here, or from
674    /// `Default` where one exists, then read / assign the public
675    /// fields).
676    #[allow(clippy::too_many_arguments)]
677    pub fn new(version: u8, bit_depth: u8, tokens: Vec<Token>) -> Self {
678        Self {
679            version,
680            bit_depth,
681            tokens,
682        }
683    }
684}
685
686impl SampleTransform {
687    /// Intermediate bit depth keyed by [`Self::bit_depth`] (av1-avif
688    /// Table 1). `0 → 8`, `1 → 16`, `2 → 32`, `3 → 64`.
689    pub fn num_bits(&self) -> u32 {
690        bit_depth_to_num_bits(self.bit_depth)
691    }
692
693    /// Minimum value representable at [`Self::num_bits`] precision
694    /// (`-2^(num_bits-1)`). Computed underflows clamp to this value
695    /// per spec.
696    pub fn min_value(&self) -> i64 {
697        let n = self.num_bits();
698        if n >= 64 {
699            i64::MIN
700        } else {
701            -(1i64 << (n - 1))
702        }
703    }
704
705    /// Maximum value representable at [`Self::num_bits`] precision
706    /// (`2^(num_bits-1) - 1`). Computed overflows clamp to this value
707    /// per spec.
708    pub fn max_value(&self) -> i64 {
709        let n = self.num_bits();
710        if n >= 64 {
711            i64::MAX
712        } else {
713            (1i64 << (n - 1)) - 1
714        }
715    }
716
717    /// Parse a `sato` payload strictly per av1-avif v1.2.0 §4.2.3.2 /
718    /// §4.2.3.4:
719    ///
720    /// * `version` must be `0`.
721    /// * `token_count` must be `>= 1`.
722    /// * Each sample-operand token (`1..=token_count_max`) must be
723    ///   `<= reference_count` per §4.2.3.4. Pass the `dimg` iref's
724    ///   reference count for the item.
725    /// * Reserved token values cause an error.
726    /// * The expression must evaluate without stack underflow and leave
727    ///   exactly one element on the stack.
728    pub fn parse(payload: &[u8], reference_count: u32) -> Result<Self> {
729        let st = Self::parse_relaxed(payload)?;
730        if st.version != 0 {
731            return Err(Error::invalid(format!(
732                "avif: sato version {} != 0",
733                st.version
734            )));
735        }
736        // bit_depth is 2 bits on the wire so always 0..=3, but
737        // parse_relaxed accepts whatever it was decoded to. Re-check.
738        if st.bit_depth > 3 {
739            return Err(Error::invalid(format!(
740                "avif: sato bit_depth {} out of 0..=3",
741                st.bit_depth
742            )));
743        }
744        // Reject reserved tokens.
745        for (i, t) in st.tokens.iter().enumerate() {
746            if let Token::Reserved(raw) = *t {
747                return Err(Error::invalid(format!(
748                    "avif: sato token[{i}] = {raw} is reserved"
749                )));
750            }
751        }
752        // Validate every sample reference fits inside reference_count.
753        // Spec §4.2.3.4: `token shall be at most reference_count` when
754        // 1 <= token <= 32.
755        for (i, t) in st.tokens.iter().enumerate() {
756            if let Token::Sample(n) = *t {
757                if u32::from(n) > reference_count {
758                    return Err(Error::invalid(format!(
759                        "avif: sato token[{i}] sample index {n} > reference_count {reference_count}"
760                    )));
761                }
762            }
763        }
764        // Validate stack discipline without evaluating actual values.
765        let mut depth: i64 = 0;
766        for (i, t) in st.tokens.iter().enumerate() {
767            match t {
768                Token::Constant(_) | Token::Sample(_) => depth += 1,
769                Token::Unary(_) => {
770                    if depth < 1 {
771                        return Err(Error::invalid(format!(
772                            "avif: sato token[{i}] unary op underflows stack"
773                        )));
774                    }
775                    // Pop one, push one: net 0.
776                }
777                Token::Binary(_) => {
778                    if depth < 2 {
779                        return Err(Error::invalid(format!(
780                            "avif: sato token[{i}] binary op underflows stack"
781                        )));
782                    }
783                    depth -= 1; // Two pops, one push: net -1.
784                }
785                Token::Reserved(_) => unreachable!(),
786            }
787        }
788        if depth != 1 {
789            return Err(Error::invalid(format!(
790                "avif: sato expression leaves {depth} elements on stack, expected 1"
791            )));
792        }
793        Ok(st)
794    }
795
796    /// Parse a `sato` payload structurally without rejecting reserved
797    /// tokens or unrecognised versions. Useful for diagnostic dumps of
798    /// experimental files. Stack discipline is still enforced (a
799    /// malformed expression can't be round-tripped).
800    pub fn parse_relaxed(payload: &[u8]) -> Result<Self> {
801        if payload.len() < 2 {
802            return Err(Error::invalid("avif: sato header too short"));
803        }
804        let header = payload[0];
805        let version = (header >> 6) & 0x03;
806        // bits 5..=2 are reserved (spec says ignored)
807        let bit_depth = header & 0x03;
808        let token_count = payload[1] as usize;
809        if token_count == 0 {
810            return Err(Error::invalid("avif: sato token_count = 0"));
811        }
812        let num_bits = bit_depth_to_num_bits(bit_depth);
813        let const_bytes = (num_bits / 8) as usize; // 1, 2, 4, or 8
814        let mut tokens = Vec::with_capacity(token_count);
815        let mut cursor = 2usize;
816        for i in 0..token_count {
817            if cursor >= payload.len() {
818                return Err(Error::invalid(format!(
819                    "avif: sato truncated before token {i} (cursor {cursor} >= {})",
820                    payload.len()
821                )));
822            }
823            let raw = payload[cursor];
824            cursor += 1;
825            let token = if raw == 0 {
826                let end = cursor
827                    .checked_add(const_bytes)
828                    .ok_or_else(|| Error::invalid("avif: sato constant offset overflow"))?;
829                if end > payload.len() {
830                    return Err(Error::invalid(format!(
831                        "avif: sato truncated constant payload for token {i} (need {const_bytes} bytes at {cursor})"
832                    )));
833                }
834                let value = read_sint_be(&payload[cursor..end])?;
835                cursor = end;
836                Token::Constant(value)
837            } else if raw <= 32 {
838                Token::Sample(raw)
839            } else if (64..=67).contains(&raw) {
840                Token::Unary(raw)
841            } else if (128..=137).contains(&raw) {
842                Token::Binary(raw)
843            } else {
844                Token::Reserved(raw)
845            };
846            tokens.push(token);
847        }
848        Ok(SampleTransform {
849            version,
850            bit_depth,
851            tokens,
852        })
853    }
854
855    /// Evaluate this expression for one output sample, given the input
856    /// sample values from the parallel `dimg` iref's `to_ids` list (in
857    /// the same order). Returns the single value left on the stack,
858    /// already clamped to the intermediate precision via the
859    /// underflow / overflow rules of av1-avif §4.2.3.3.
860    ///
861    /// `inputs[i]` is the sample from input image item `i + 1` (the
862    /// spec's `token` values for sample operands are 1-based; this
863    /// helper translates them to 0-based vector indices).
864    ///
865    /// The caller is responsible for the final clamp into the
866    /// reconstructed item's `PixelInformationProperty` bit depth, since
867    /// that depth lives outside the `sato` descriptor itself.
868    ///
869    /// Returns an error if the expression dereferences an out-of-range
870    /// sample or trips a stack underflow that wasn't caught by
871    /// [`Self::parse`]'s validation (the only path is a caller passing
872    /// `inputs.len() < max_sample_index_used` or a `parse_relaxed`
873    /// expression that bypassed validation).
874    pub fn evaluate(&self, inputs: &[i64]) -> Result<i64> {
875        let min = self.min_value();
876        let max = self.max_value();
877        let clamp = |v: i64| v.clamp(min, max);
878        let mut stack: Vec<i64> = Vec::with_capacity(self.tokens.len());
879        for (i, t) in self.tokens.iter().enumerate() {
880            match t {
881                Token::Constant(value) => stack.push(clamp(*value)),
882                Token::Sample(n) => {
883                    let idx = usize::from(*n).saturating_sub(1);
884                    let v = *inputs.get(idx).ok_or_else(|| {
885                        Error::invalid(format!(
886                            "avif: sato token[{i}] sample {n} out of range (inputs.len()={})",
887                            inputs.len()
888                        ))
889                    })?;
890                    stack.push(clamp(v));
891                }
892                Token::Unary(raw) => {
893                    let l = stack
894                        .pop()
895                        .ok_or_else(|| Error::invalid("avif: sato unary stack underflow"))?;
896                    let r = match *raw {
897                        64 => l.checked_neg().unwrap_or(i64::MAX), // negation
898                        65 => l.checked_abs().unwrap_or(i64::MAX), // abs
899                        66 => !l,                                  // bitwise not
900                        67 => {
901                            // bsr: 0-based index of MSB if l > 0, else 0.
902                            if l > 0 {
903                                (63 - (l as u64).leading_zeros()) as i64
904                            } else {
905                                0
906                            }
907                        }
908                        _ => {
909                            return Err(Error::invalid(format!(
910                                "avif: sato unary token {raw} not implemented",
911                            )));
912                        }
913                    };
914                    stack.push(clamp(r));
915                }
916                Token::Binary(raw) => {
917                    let r = stack
918                        .pop()
919                        .ok_or_else(|| Error::invalid("avif: sato binary right underflow"))?;
920                    let l = stack
921                        .pop()
922                        .ok_or_else(|| Error::invalid("avif: sato binary left underflow"))?;
923                    let out = match *raw {
924                        // Spec §4.2.3.3: results that underflow / overflow
925                        // the intermediate bit depth are replaced by
926                        // -2^(num_bits-1) / 2^(num_bits-1)-1. We compute
927                        // at i64 saturation and rely on the final
928                        // `clamp` call below to narrow into num_bits.
929                        128 => l.saturating_add(r),
930                        129 => l.saturating_sub(r),
931                        130 => l.saturating_mul(r),
932                        131 => {
933                            if r == 0 {
934                                l
935                            } else {
936                                // Truncate toward zero, which is Rust's
937                                // default for `/` on signed integers.
938                                // `i64::MIN / -1` overflows; saturate.
939                                l.checked_div(r).unwrap_or(i64::MAX)
940                            }
941                        }
942                        132 => l & r,
943                        133 => l | r,
944                        134 => l ^ r,
945                        135 => {
946                            if l == 0 {
947                                0
948                            } else {
949                                pow_truncated(l, r)
950                            }
951                        }
952                        136 => l.min(r),
953                        137 => l.max(r),
954                        _ => {
955                            return Err(Error::invalid(format!(
956                                "avif: sato binary token {raw} not implemented",
957                            )));
958                        }
959                    };
960                    stack.push(clamp(out));
961                }
962                Token::Reserved(raw) => {
963                    return Err(Error::invalid(format!(
964                        "avif: sato token[{i}] = {raw} reserved (cannot evaluate)",
965                    )));
966                }
967            }
968        }
969        if stack.len() != 1 {
970            return Err(Error::invalid(format!(
971                "avif: sato expression leaves {} elements on stack, expected 1",
972                stack.len()
973            )));
974        }
975        Ok(stack[0])
976    }
977}
978
979// `sato` four-CC lives in [`crate::meta::ITEM_TYPE_SATO`] (alongside
980// `iovl`, `iden`, `tmap`, etc.) to keep all item-type constants on the
981// `Meta` surface.
982
983/// Map the 2-bit `bit_depth` selector to its intermediate bit depth
984/// (av1-avif Table 1).
985fn bit_depth_to_num_bits(bit_depth: u8) -> u32 {
986    match bit_depth & 0x03 {
987        0 => 8,
988        1 => 16,
989        2 => 32,
990        3 => 64,
991        _ => unreachable!(),
992    }
993}
994
995/// Read a signed big-endian integer of `bytes.len()` bytes (must be
996/// 1, 2, 4 or 8) and sign-extend to `i64`.
997fn read_sint_be(bytes: &[u8]) -> Result<i64> {
998    Ok(match bytes.len() {
999        1 => bytes[0] as i8 as i64,
1000        2 => i16::from_be_bytes([bytes[0], bytes[1]]) as i64,
1001        4 => i32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as i64,
1002        8 => i64::from_be_bytes([
1003            bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
1004        ]),
1005        n => {
1006            return Err(Error::invalid(format!(
1007                "avif: sato constant width {n} not in {{1,2,4,8}}"
1008            )))
1009        }
1010    })
1011}
1012
1013/// Truncated integer exponentiation per av1-avif Table 2 row 135.
1014/// `L^R` with the result truncated; the spec defines `0` for the
1015/// `L == 0` case via the row's own piecewise rule (handled by the
1016/// caller). Negative `R` truncates to `0` for `|L| > 1` (1/L^|R| < 1)
1017/// and to `L` itself for `L == ±1` (since `1^anything = 1` and
1018/// `(-1)^anything = ±1`). The result is saturated to `i64`'s range
1019/// when intermediate computations would overflow.
1020fn pow_truncated(base: i64, exp: i64) -> i64 {
1021    if exp < 0 {
1022        return match base.abs() {
1023            1 => {
1024                // 1^anything = 1; (-1)^anything alternates so just
1025                // compute by parity.
1026                if base == 1 || exp % 2 == 0 {
1027                    1
1028                } else {
1029                    -1
1030                }
1031            }
1032            _ => 0,
1033        };
1034    }
1035    if exp == 0 {
1036        return 1;
1037    }
1038    let mut result: i64 = 1;
1039    let mut base_acc = base;
1040    let mut e = exp as u64;
1041    while e > 0 {
1042        if e & 1 == 1 {
1043            result = match result.checked_mul(base_acc) {
1044                Some(v) => v,
1045                None => {
1046                    return if (result >= 0) == (base_acc >= 0) {
1047                        i64::MAX
1048                    } else {
1049                        i64::MIN
1050                    };
1051                }
1052            };
1053        }
1054        e >>= 1;
1055        if e > 0 {
1056            base_acc = match base_acc.checked_mul(base_acc) {
1057                Some(v) => v,
1058                None => {
1059                    return if base_acc >= 0 || (exp & 1 == 0) {
1060                        i64::MAX
1061                    } else {
1062                        i64::MIN
1063                    };
1064                }
1065            };
1066        }
1067    }
1068    result
1069}
1070
1071// ---------------------------------------------------------------------------
1072// AV1-AVIF v1.2.0 §4.2.2 — Tone Map Derived Image Item (`tmap`) compliance
1073// ---------------------------------------------------------------------------
1074
1075/// Result of an av1-avif §4.2.2 audit on a single `'tmap'` Tone Map
1076/// Derived Image Item carried by the file.
1077///
1078/// The `tmap` item type itself is registered by the HEIF / MIAF family;
1079/// the descriptor *body* the item points at via its `iloc` is the ISO
1080/// 21496-1 gain map metadata payload, parsed by
1081/// [`GainMapMetadata::parse`]. This audit covers the two file-shape
1082/// `should` constraints av1-avif §4.2.2 imposes *independently* of that
1083/// body:
1084///
1085/// 1. **`altr` grouping.** The base image item (i.e. the input the
1086///    tmap item references via `'dimg'`) and the `tmap` item should be
1087///    grouped together by an `'altr'` entity group, so legacy readers
1088///    that don't understand `tmap` still pick a valid alternate.
1089/// 2. **Hidden gain map.** When the tmap derivation references a "gain
1090///    map" input image item (the additional image input layered onto
1091///    the base via tone mapping), that input should be a HEIF
1092///    [hidden image item](crate::meta::ItemInfo::is_hidden) so a
1093///    legacy reader never surfaces it as a primary picture.
1094///
1095/// Both are `should`, not `shall`, so a fail does not invalidate the
1096/// file — it is purely informational for callers that want strict
1097/// av1-avif §4.2.2 mode. [`Self::is_compliant`] reports the AND of
1098/// both signals; [`Self::missing`] lists which checks failed.
1099///
1100/// `tmap` items carry their inputs in `'dimg'` iref entries whose
1101/// `from_item_ID` is the `tmap` item id (per av1-avif §4.2.3.1
1102/// SingleItemTypeReferenceBox conventions, the same shape `'sato'`
1103/// uses). Convention in HEIF gain-map layouts is `to_ids[0]` =
1104/// base image item, `to_ids[1..]` = gain map(s); we treat
1105/// `to_ids[0]` as the base and every subsequent entry as a gain map.
1106#[non_exhaustive]
1107#[derive(Clone, Debug, PartialEq, Eq, Default)]
1108pub struct ToneMapCompliance {
1109    /// The `'tmap'` item id this audit describes.
1110    pub tmap_item_id: u32,
1111    /// The base image item id (input `0` of the tmap's `dimg`), or
1112    /// `None` when the tmap has no `dimg` iref / no inputs at all.
1113    pub base_item_id: Option<u32>,
1114    /// Every additional input image item id (`to_ids[1..]`) — these
1115    /// are the gain map inputs the spec wants hidden. Empty when the
1116    /// tmap has exactly one input (base only).
1117    pub gain_map_item_ids: Vec<u32>,
1118    /// True when *some* `'altr'` entity group pairs `tmap_item_id`
1119    /// with `base_item_id` (av1-avif §4.2.2 first `should`).
1120    /// False when no `grpl` is present, when no `altr` group lists
1121    /// both ids, or when `base_item_id` is `None`.
1122    pub paired_in_altr: bool,
1123    /// True when every id in `gain_map_item_ids` is marked hidden
1124    /// (`infe` flags low bit set; HEIF §6.4.2). Trivially true when
1125    /// `gain_map_item_ids` is empty.
1126    pub gain_maps_hidden: bool,
1127}
1128
1129impl ToneMapCompliance {
1130    /// Every field as a positional argument, in declaration order
1131    /// (the record is `#[non_exhaustive]`: build it here, or from
1132    /// `Default` where one exists, then read / assign the public
1133    /// fields).
1134    #[allow(clippy::too_many_arguments)]
1135    pub fn new(
1136        tmap_item_id: u32,
1137        base_item_id: Option<u32>,
1138        gain_map_item_ids: Vec<u32>,
1139        paired_in_altr: bool,
1140        gain_maps_hidden: bool,
1141    ) -> Self {
1142        Self {
1143            tmap_item_id,
1144            base_item_id,
1145            gain_map_item_ids,
1146            paired_in_altr,
1147            gain_maps_hidden,
1148        }
1149    }
1150}
1151
1152impl ToneMapCompliance {
1153    /// True when both `should`s pass — there exists an `altr` group
1154    /// pairing the tmap with its base item, and every gain-map input
1155    /// is hidden.
1156    pub fn is_compliant(&self) -> bool {
1157        self.paired_in_altr && self.gain_maps_hidden
1158    }
1159
1160    /// Human-readable list of failed checks. Returns an empty vector
1161    /// when [`Self::is_compliant`].
1162    pub fn missing(&self) -> Vec<&'static str> {
1163        let mut out = Vec::new();
1164        if !self.paired_in_altr {
1165            out.push("altr-pairs-base-and-tmap");
1166        }
1167        if !self.gain_maps_hidden {
1168            out.push("gain-map-hidden");
1169        }
1170        out
1171    }
1172}
1173
1174/// Audit every `'tmap'` item carried in `meta` against the two av1-avif
1175/// §4.2.2 `should` constraints. Returns one [`ToneMapCompliance`]
1176/// record per tmap item, in `iinf` declaration order. The returned
1177/// vector is empty when the file ships no tmap items.
1178///
1179/// Spec: av1-avif v1.2.0 §4.2.2 (Tone Map Derived Image Item) — the
1180/// av1-avif file-shape clauses only. The `tmap` descriptor body itself
1181/// (the ISO 21496-1 gain map metadata payload pointed at by the item's
1182/// `iloc`) is parsed separately by [`GainMapMetadata::parse`].
1183pub fn audit_tone_map(meta: &crate::meta::Meta) -> Vec<ToneMapCompliance> {
1184    let tmap_type = crate::meta::ITEM_TYPE_TMAP;
1185    let dimg = b(b"dimg");
1186    let groups = meta.groups().unwrap_or_default();
1187    meta.item_ids_of_type(&tmap_type)
1188        .into_iter()
1189        .map(|tmap_id| audit_one_tone_map(meta, &groups, &dimg, tmap_id))
1190        .collect()
1191}
1192
1193fn audit_one_tone_map(
1194    meta: &crate::meta::Meta,
1195    groups: &[EntityGroup],
1196    dimg: &BoxType,
1197    tmap_id: u32,
1198) -> ToneMapCompliance {
1199    let inputs = meta.iref_targets(dimg, tmap_id);
1200    let base_item_id = inputs.first().copied();
1201    let gain_map_item_ids: Vec<u32> = inputs.iter().skip(1).copied().collect();
1202
1203    // §4.2.2 first `should`: an `altr` group contains both the tmap and
1204    // its base image item.
1205    let paired_in_altr = match base_item_id {
1206        Some(base) => groups.iter().any(|g| {
1207            g.is_alternates() && g.entity_ids.contains(&tmap_id) && g.entity_ids.contains(&base)
1208        }),
1209        None => false,
1210    };
1211
1212    // §4.2.2 second `should`: every gain-map input image item is
1213    // hidden. Items missing from `iinf` (malformed iref) count as
1214    // not-hidden — they fail the audit rather than silently passing.
1215    let gain_maps_hidden = gain_map_item_ids
1216        .iter()
1217        .all(|id| meta.item_by_id(*id).is_some_and(|info| info.is_hidden()));
1218
1219    ToneMapCompliance {
1220        tmap_item_id: tmap_id,
1221        base_item_id,
1222        gain_map_item_ids,
1223        paired_in_altr,
1224        gain_maps_hidden,
1225    }
1226}
1227
1228// ---------------------------------------------------------------------------
1229// ISO 21496-1:2025 Annex C.2 — Gain map metadata binary payload
1230// ---------------------------------------------------------------------------
1231
1232/// A signed rational (`numerator / denominator`) read from the gain map
1233/// metadata payload. The numerator is stored as a signed 32-bit integer
1234/// and the denominator as an unsigned 32-bit integer per ISO 21496-1
1235/// Annex C.2.2; the denominator of every rational field "shall not be 0".
1236///
1237/// Stored as raw integer components rather than a pre-divided float so
1238/// the parse stays lossless — callers that want the value compute
1239/// `numerator as f64 / denominator as f64` themselves.
1240#[doc(hidden)]
1241#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1242pub struct GainMapRational {
1243    /// Signed numerator (the `int(32)` component).
1244    pub numerator: i32,
1245    /// Unsigned denominator (the `unsigned int(32)` component). The spec
1246    /// forbids `0`; [`GainMapMetadata::parse`] rejects any payload that
1247    /// carries a zero denominator.
1248    pub denominator: u32,
1249}
1250
1251impl GainMapRational {
1252    /// Every field as a positional argument, in declaration order
1253    /// (the record is `#[non_exhaustive]`: build it here, or from
1254    /// `Default` where one exists, then read / assign the public
1255    /// fields).
1256    #[allow(clippy::too_many_arguments)]
1257    pub fn new(numerator: i32, denominator: u32) -> Self {
1258        Self {
1259            numerator,
1260            denominator,
1261        }
1262    }
1263}
1264
1265impl GainMapRational {
1266    /// The rational evaluated as an `f64`. The denominator is guaranteed
1267    /// non-zero by [`GainMapMetadata::parse`], so this never divides by
1268    /// zero on a value obtained through the parser.
1269    pub fn as_f64(&self) -> f64 {
1270        self.numerator as f64 / self.denominator as f64
1271    }
1272}
1273
1274/// One per-channel gain map metadata record (ISO 21496-1 `GainMapChannel`,
1275/// Annex C.2.2). Each field is a signed/unsigned rational pair; the
1276/// per-component values are computed as `numerator / denominator`.
1277///
1278/// `gamma_numerator` and every `*_denominator` "shall not be 0" per Annex
1279/// C.2.3; [`GainMapMetadata::parse`] enforces those constraints.
1280#[doc(hidden)]
1281#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1282pub struct GainMapChannel {
1283    /// Per-component gain map min value (signed rational; 5.2.5.2).
1284    pub gain_map_min: GainMapRational,
1285    /// Per-component gain map max value (signed rational; 5.2.5.3).
1286    pub gain_map_max: GainMapRational,
1287    /// Per-component gamma value (unsigned rational; 5.2.5.6). The
1288    /// numerator is constrained non-zero in addition to the denominator.
1289    pub gamma: GainMapRational,
1290    /// Per-component baseline offset constant (signed rational; 5.2.5.4).
1291    pub base_offset: GainMapRational,
1292    /// Per-component alternate offset constant (signed rational; 5.2.5.5).
1293    pub alternate_offset: GainMapRational,
1294}
1295
1296impl GainMapChannel {
1297    /// Every field as a positional argument, in declaration order
1298    /// (the record is `#[non_exhaustive]`: build it here, or from
1299    /// `Default` where one exists, then read / assign the public
1300    /// fields).
1301    #[allow(clippy::too_many_arguments)]
1302    pub fn new(
1303        gain_map_min: GainMapRational,
1304        gain_map_max: GainMapRational,
1305        gamma: GainMapRational,
1306        base_offset: GainMapRational,
1307        alternate_offset: GainMapRational,
1308    ) -> Self {
1309        Self {
1310            gain_map_min,
1311            gain_map_max,
1312            gamma,
1313            base_offset,
1314            alternate_offset,
1315        }
1316    }
1317}
1318
1319impl GainMapChannel {
1320    /// Unnormalize a stored gain-map sample for this component into the
1321    /// log2 gain value `G`, applying ISO 21496-1 §6.2.1 Formula (1):
1322    ///
1323    /// ```text
1324    /// G = [max(G) − min(G)] × (Gnormalized ^ (1/γ)) + min(G)
1325    /// ```
1326    ///
1327    /// `normalized` is the stored, gamma-encoded sample in `[0, 1]`
1328    /// (`Gnormalized_γ` in the spec's notation — the value as carried in
1329    /// the gain-map image after the §A.3.4 gamma pre-compression). The
1330    /// `(·)^(1/γ)` term inverts that gamma, then the `[min, max]` range
1331    /// scaling inverts the §A.3.3 normalization, yielding `G` in log2
1332    /// (stops) space ready for [`GainMapMetadata::apply_component`].
1333    ///
1334    /// `min(G)`, `max(G)` and `γ` are this channel's [`Self::gain_map_min`],
1335    /// [`Self::gain_map_max`] and [`Self::gamma`]. The parser guarantees
1336    /// `γ > 0` (non-zero numerator and denominator) so `1/γ` is finite,
1337    /// and `max(G) ≥ min(G)` so the span is non-negative.
1338    ///
1339    /// The stored sample is clamped into `[0, 1]` before the gamma inverse
1340    /// because `x^(1/γ)` is not real for negative `x` when `1/γ` is
1341    /// non-integral; §A.3.3 defines `Gnormalized` on `[0, 1]` so an
1342    /// out-of-range input is a writer error and is saturated rather than
1343    /// allowed to produce a NaN.
1344    pub fn unnormalize_log2_gain(&self, normalized: f64) -> f64 {
1345        let min = self.gain_map_min.as_f64();
1346        let max = self.gain_map_max.as_f64();
1347        let gamma = self.gamma.as_f64();
1348        let g_norm = normalized.clamp(0.0, 1.0);
1349        // Invert the §A.3.4 gamma: Gnormalized = (Gnormalized_γ)^(1/γ).
1350        let degammad = g_norm.powf(1.0 / gamma);
1351        (max - min) * degammad + min
1352    }
1353}
1354
1355/// Parsed ISO 21496-1:2025 gain map metadata payload (Annex C.2) — the
1356/// binary descriptor body carried by the AVIF / HEIF `'tmap'` (tone map)
1357/// derived image item.
1358///
1359/// av1-avif §4.2.2 (and HEIF) register the `'tmap'` item type and the
1360/// `altr`/hidden file-shape constraints audited by [`audit_tone_map`];
1361/// the *body* the item points at via its `iloc` is this structure,
1362/// specified by ISO 21496-1. Resolve the item bytes with
1363/// [`crate::inspect::item_payload_bytes`] (or
1364/// [`crate::parser::item_bytes_owned`]) and hand them to
1365/// [`GainMapMetadata::parse`].
1366///
1367/// The byte order is big-endian regardless of the container, and the
1368/// 1-bit `is_multichannel` / `use_base_colour_space` flags are read from
1369/// the most-significant bits of a single byte (Annex C.2.1). Per Annex
1370/// C.2.1 the structure may carry trailing padding or future optional
1371/// metadata after the recognised fields; the parser stops after the last
1372/// recognised field and ignores the remainder, so a longer payload is
1373/// not an error.
1374#[doc(hidden)]
1375#[derive(Clone, Debug, PartialEq, Eq, Default)]
1376pub struct GainMapMetadata {
1377    /// `minimum_version` — the minimum version a parser must understand to
1378    /// apply the gain map. Annex C.2.3: this "shall be 0 in this version
1379    /// of the specification". A reader encountering an unrecognised value
1380    /// must ignore the payload and display the base image.
1381    pub minimum_version: u16,
1382    /// `writer_version` — the version the writer used; "shall be greater
1383    /// than or equal to `minimum_version`".
1384    pub writer_version: u16,
1385    /// `is_multichannel` — when true the per-channel record count is 3
1386    /// (channels in R, G, B order); when false it is 1.
1387    pub is_multichannel: bool,
1388    /// `use_base_colour_space` — true selects the baseline image colour
1389    /// primaries for the gain map application space; false selects the
1390    /// alternate image primaries (5.3.4).
1391    pub use_base_colour_space: bool,
1392    /// Baseline HDR headroom (unsigned rational; 5.2.6). Stored with its
1393    /// `int(32)` numerator widened to `i32` for a uniform rational type —
1394    /// the value is logically unsigned and the denominator is non-zero.
1395    pub base_hdr_headroom: GainMapRational,
1396    /// Alternate HDR headroom (unsigned rational; 5.2.7).
1397    pub alternate_hdr_headroom: GainMapRational,
1398    /// Per-channel metadata. Length is 3 when [`Self::is_multichannel`],
1399    /// else 1.
1400    pub channels: Vec<GainMapChannel>,
1401}
1402
1403impl GainMapMetadata {
1404    /// Every field as a positional argument, in declaration order
1405    /// (the record is `#[non_exhaustive]`: build it here, or from
1406    /// `Default` where one exists, then read / assign the public
1407    /// fields).
1408    #[allow(clippy::too_many_arguments)]
1409    pub fn new(
1410        minimum_version: u16,
1411        writer_version: u16,
1412        is_multichannel: bool,
1413        use_base_colour_space: bool,
1414        base_hdr_headroom: GainMapRational,
1415        alternate_hdr_headroom: GainMapRational,
1416        channels: Vec<GainMapChannel>,
1417    ) -> Self {
1418        Self {
1419            minimum_version,
1420            writer_version,
1421            is_multichannel,
1422            use_base_colour_space,
1423            base_hdr_headroom,
1424            alternate_hdr_headroom,
1425            channels,
1426        }
1427    }
1428}
1429
1430impl GainMapMetadata {
1431    /// Parse the body of a `'tmap'` item — the `ToneMapImage` of
1432    /// HEIF Amd 1:2025 §6.6.2.4.2: one `version` byte that shall be 0
1433    /// followed by the ISO 21496-1 C.2 `GainMapMetadata` to the end
1434    /// of the item. Any other version is `Unsupported` (§6.6.2.4.3:
1435    /// readers shall not process it; the base image is what to show).
1436    pub fn parse_tone_map_image(body: &[u8]) -> Result<Self> {
1437        match body.split_first() {
1438            Some((0, metadata)) => Self::parse(metadata),
1439            Some((version, _)) => Err(Error::unsupported(format!(
1440                "avif: ToneMapImage version {version} — readers shall not process a version other \
1441                 than 0 (HEIF Amd 1:2025 §6.6.2.4.3); display the base image"
1442            ))),
1443            None => Err(Error::invalid("avif: empty tmap item body")),
1444        }
1445    }
1446
1447    /// Parse a bare ISO 21496-1 Annex C.2.2 `GainMapMetadata` payload
1448    /// (the bytes after a `tmap` body's `ToneMapImage` version byte —
1449    /// see [`Self::parse_tone_map_image`]).
1450    ///
1451    /// Behaviour:
1452    ///
1453    /// * Returns [`AvifError::Unsupported`](crate::error::AvifError::Unsupported)
1454    ///   when `minimum_version != 0` — Annex C.2.3 requires such a reader
1455    ///   to ignore the payload, so the caller should fall back to the
1456    ///   base image rather than treat the bytes as malformed.
1457    /// * Returns [`AvifError::InvalidData`](crate::error::AvifError::InvalidData)
1458    ///   when the payload is truncated, when `writer_version <
1459    ///   minimum_version`, when any rational denominator (or the gamma
1460    ///   numerator) is `0`, when any channel's `gain_map_max <
1461    ///   gain_map_min` (§5.2.5.3 "shall be greater than or equal to"),
1462    ///   or when `alternate_hdr_headroom == base_hdr_headroom` (§5.2.7
1463    ///   "shall not be equal to") — all `shall`-level constraints from
1464    ///   §5.2 / Annex C.2.3.
1465    /// * Trailing bytes after the last recognised field are ignored
1466    ///   (Annex C.2.1 padding / future-optional-metadata rule).
1467    pub fn parse(payload: &[u8]) -> Result<Self> {
1468        // GainMapVersion: minimum_version(16) + writer_version(16).
1469        let minimum_version = read_u16(payload, 0)?;
1470        let writer_version = read_u16(payload, 2)?;
1471
1472        if minimum_version != 0 {
1473            return Err(Error::unsupported(format!(
1474                "avif: gain map metadata minimum_version {minimum_version} not understood \
1475                 (ISO 21496-1 C.2.3 requires 0); caller should display the base image"
1476            )));
1477        }
1478        // C.2.3: writer_version shall be >= minimum_version.
1479        if writer_version < minimum_version {
1480            return Err(Error::invalid(format!(
1481                "avif: gain map metadata writer_version {writer_version} < minimum_version \
1482                 {minimum_version} (ISO 21496-1 C.2.3)"
1483            )));
1484        }
1485
1486        // One flags byte: is_multichannel(1) | use_base_colour_space(1) |
1487        // reserved(6), MSB-first (C.2.1).
1488        let flags = *payload
1489            .get(4)
1490            .ok_or_else(|| Error::invalid("avif: gain map metadata truncated at flags byte"))?;
1491        let is_multichannel = flags & 0x80 != 0;
1492        let use_base_colour_space = flags & 0x40 != 0;
1493        let channel_count = if is_multichannel { 3 } else { 1 };
1494
1495        // base/alternate HDR headroom: 4 × u32 starting at offset 5.
1496        let base_hdr_headroom = read_unsigned_rational(payload, 5)?;
1497        let alternate_hdr_headroom = read_unsigned_rational(payload, 13)?;
1498        // §5.2.7: "H_alternate shall not be equal to H_baseline". The
1499        // headroom values are unsigned rationals; compare via the
1500        // cross-multiplied i64 product so a different
1501        // (numerator, denominator) pair that reduces to the same value
1502        // is still flagged (e.g. 1/1 == 2/2). Denominators have already
1503        // been rejected when zero by `read_unsigned_rational`.
1504        if !rationals_differ(&base_hdr_headroom, &alternate_hdr_headroom) {
1505            return Err(Error::invalid(
1506                "avif: gain map metadata alternate_hdr_headroom equals base_hdr_headroom \
1507                 (ISO 21496-1 §5.2.7)",
1508            ));
1509        }
1510
1511        // GainMapChannel[channel_count] follow, each 10 × 32-bit fields
1512        // (40 bytes): min, max, gamma, base_offset, alternate_offset.
1513        let mut channels = Vec::with_capacity(channel_count);
1514        let mut at = 21usize;
1515        for _ in 0..channel_count {
1516            let gain_map_min = read_signed_rational(payload, at)?;
1517            let gain_map_max = read_signed_rational(payload, at + 8)?;
1518            // §5.2.5.3: "For each component, max(G) shall be greater
1519            // than or equal to the min(G) value". Compare via the
1520            // cross-multiplied i64 product so the predicate is exact
1521            // for any (numerator, denominator) pair; denominators are
1522            // already non-zero (and positive, being unsigned), so the
1523            // product's sign is the sign of the difference.
1524            if !rational_ge(&gain_map_max, &gain_map_min) {
1525                return Err(Error::invalid(
1526                    "avif: gain map metadata gain_map_max < gain_map_min \
1527                     (ISO 21496-1 §5.2.5.3)",
1528                ));
1529            }
1530            let gamma = read_unsigned_rational(payload, at + 16)?;
1531            // C.2.3: gamma_numerator shall not be 0.
1532            if gamma.numerator == 0 {
1533                return Err(Error::invalid(
1534                    "avif: gain map metadata gamma_numerator is 0 (ISO 21496-1 C.2.3)",
1535                ));
1536            }
1537            let base_offset = read_signed_rational(payload, at + 24)?;
1538            let alternate_offset = read_signed_rational(payload, at + 32)?;
1539            channels.push(GainMapChannel {
1540                gain_map_min,
1541                gain_map_max,
1542                gamma,
1543                base_offset,
1544                alternate_offset,
1545            });
1546            at += 40;
1547        }
1548
1549        Ok(GainMapMetadata {
1550            minimum_version,
1551            writer_version,
1552            is_multichannel,
1553            use_base_colour_space,
1554            base_hdr_headroom,
1555            alternate_hdr_headroom,
1556            channels,
1557        })
1558    }
1559
1560    /// The per-channel record count implied by [`Self::is_multichannel`]
1561    /// (3 when multichannel, else 1). Always equal to `channels.len()`
1562    /// for a value obtained from [`Self::parse`].
1563    pub fn channel_count(&self) -> usize {
1564        self.channels.len()
1565    }
1566
1567    /// The weighting factor `W` for a target HDR headroom, per ISO 21496-1
1568    /// §6.3 Formula (3):
1569    ///
1570    /// ```text
1571    /// W = sign(H_alternate − H_baseline) × clamp((H_target − H_baseline) /
1572    ///                                            (H_alternate − H_baseline), 0, 1)
1573    /// ```
1574    ///
1575    /// `W` scales how much of the gain map is applied (§6.3 NOTE 4): at
1576    /// `H_target == H_baseline` the gain map is not applied (`W == 0`),
1577    /// and at `H_target == H_alternate` it is fully applied (`W == ±1`,
1578    /// the sign following `sign(H_alternate − H_baseline)`). Targets
1579    /// outside `[H_baseline, H_alternate]` are clamped so `W` stays in
1580    /// `[−1, 1]`.
1581    ///
1582    /// `H_baseline` and `H_alternate` are [`Self::base_hdr_headroom`] and
1583    /// [`Self::alternate_hdr_headroom`]; the parser guarantees they are
1584    /// not equal (§5.2.7), so the denominator is non-zero.
1585    pub fn weight_factor(&self, target_headroom: f64) -> f64 {
1586        let h_base = self.base_hdr_headroom.as_f64();
1587        let h_alt = self.alternate_hdr_headroom.as_f64();
1588        let span = h_alt - h_base;
1589        // span != 0 is guaranteed by the §5.2.7 parse check.
1590        let frac = ((target_headroom - h_base) / span).clamp(0.0, 1.0);
1591        span.signum() * frac
1592    }
1593
1594    /// The per-component metadata record that applies to RGB component
1595    /// `component` (`0 = R`, `1 = G`, `2 = B`), honouring the §5.2.5.1
1596    /// broadcast rule: a single-channel metadata record applies to all
1597    /// three colour components, while a three-channel record uses one
1598    /// value per component. Returns `None` only when `channels` is empty
1599    /// (never the case for a value from [`Self::parse`]) or `component`
1600    /// exceeds 2.
1601    fn channel_for(&self, component: usize) -> Option<&GainMapChannel> {
1602        if component > 2 {
1603            return None;
1604        }
1605        match self.channels.len() {
1606            // Achromatic / single per-component metadata: broadcast.
1607            1 => self.channels.first(),
1608            // Per-component metadata: index directly.
1609            _ => self.channels.get(component),
1610        }
1611    }
1612
1613    /// Apply the gain map to one linear baseline colour component and
1614    /// recover the corresponding linear alternate component, per
1615    /// ISO 21496-1 §6.3 Formula (2):
1616    ///
1617    /// ```text
1618    /// Alternate = (Baseline + k_baseline) × 2^(W × G) − k_alternate
1619    /// ```
1620    ///
1621    /// where `G` is the unnormalized log2 gain for this component
1622    /// ([`GainMapChannel::unnormalize_log2_gain`] applied to the stored
1623    /// `normalized` sample) and `W` is the weighting factor for
1624    /// `target_headroom` ([`Self::weight_factor`]). `k_baseline` and
1625    /// `k_alternate` are the channel's [`GainMapChannel::base_offset`] and
1626    /// [`GainMapChannel::alternate_offset`].
1627    ///
1628    /// `component` is the RGB component index (`0 = R`, `1 = G`,
1629    /// `2 = B`); the metadata channel is selected via the §5.2.5.1
1630    /// broadcast rule (single-channel metadata applies to all three).
1631    /// `baseline` is the linear baseline sample (scaled so HDR reference
1632    /// white is `1.0`, per §A.2 / Annex B.2) and `normalized` is the
1633    /// stored gain-map sample in `[0, 1]` for the gain-map component that
1634    /// applies to this colour component — for an achromatic gain map
1635    /// (§6.3 NOTE 2) the same sample is supplied for all three colour
1636    /// components.
1637    ///
1638    /// Returns `None` when `component > 2` or `channels` is empty.
1639    pub fn apply_component(
1640        &self,
1641        baseline: f64,
1642        normalized: f64,
1643        target_headroom: f64,
1644        component: usize,
1645    ) -> Option<f64> {
1646        let ch = self.channel_for(component)?;
1647        let g = ch.unnormalize_log2_gain(normalized);
1648        let w = self.weight_factor(target_headroom);
1649        let k_base = ch.base_offset.as_f64();
1650        let k_alt = ch.alternate_offset.as_f64();
1651        Some((baseline + k_base) * (w * g).exp2() - k_alt)
1652    }
1653
1654    /// Apply the gain map to a linear baseline RGB pixel and recover the
1655    /// linear alternate RGB pixel, per ISO 21496-1 §6.3 Formula (2)
1656    /// applied to each of the three colour components.
1657    ///
1658    /// `baseline` is the linear baseline `[R, G, B]` (HDR reference white
1659    /// `= 1.0`). `gain` is the stored gain-map sample(s): a 3-element
1660    /// array carries one sample per colour component, while broadcasting
1661    /// is the caller's job for an achromatic (single-component) gain-map
1662    /// image — pass the one decoded gain-map sample in all three slots
1663    /// (§6.3 NOTE 2). The per-component *metadata* broadcast (§5.2.5.1) is
1664    /// handled internally, so a single-channel-metadata file still applies
1665    /// correctly to all three colour components.
1666    ///
1667    /// Returns `None` when `channels` is empty (never the case for a value
1668    /// from [`Self::parse`]).
1669    pub fn apply_rgb(
1670        &self,
1671        baseline: [f64; 3],
1672        gain: [f64; 3],
1673        target_headroom: f64,
1674    ) -> Option<[f64; 3]> {
1675        Some([
1676            self.apply_component(baseline[0], gain[0], target_headroom, 0)?,
1677            self.apply_component(baseline[1], gain[1], target_headroom, 1)?,
1678            self.apply_component(baseline[2], gain[2], target_headroom, 2)?,
1679        ])
1680    }
1681
1682    /// Apply the gain map across a whole linear baseline RGB image plane,
1683    /// recovering the linear alternate RGB plane (ISO 21496-1 §6.3
1684    /// applied pixel-by-pixel).
1685    ///
1686    /// `baseline` is `width × height × 3` linear samples, interleaved as
1687    /// `[R, G, B, R, G, B, …]` in row-major order (HDR reference white
1688    /// `= 1.0`). `gain` is the decoded gain-map plane: either `width ×
1689    /// height` samples (a single-component / achromatic gain map — the
1690    /// same sample is applied to each colour component per §6.3 NOTE 2),
1691    /// or `width × height × 3` interleaved samples (an RGB gain map).
1692    /// Gain samples are the stored, normalized `[0, 1]` values
1693    /// (`Gnormalized_γ`); the §6.2.1 unnormalization is performed
1694    /// internally.
1695    ///
1696    /// The output is a freshly allocated `width × height × 3` interleaved
1697    /// linear alternate RGB plane.
1698    ///
1699    /// Returns [`AvifError::InvalidData`](crate::error::AvifError::InvalidData)
1700    /// when:
1701    ///
1702    /// * `width × height` overflows `usize`, or `baseline.len()` is not
1703    ///   exactly `width × height × 3`;
1704    /// * `gain.len()` is neither `width × height` (achromatic) nor
1705    ///   `width × height × 3` (RGB);
1706    /// * `channels` is empty (never the case for a [`Self::parse`] value).
1707    ///
1708    /// The §6.2.2 resampling step is the **caller's** responsibility: this
1709    /// method requires the gain plane to already match the baseline
1710    /// dimensions (§6.2.2 NOTE 3 — the alternate dimensions equal the
1711    /// baseline dimensions). A gain map stored at a different resolution
1712    /// must be resampled to `width × height` before being passed here.
1713    pub fn apply_plane_rgb(
1714        &self,
1715        baseline: &[f64],
1716        gain: &[f64],
1717        width: u32,
1718        height: u32,
1719        target_headroom: f64,
1720    ) -> Result<Vec<f64>> {
1721        if self.channels.is_empty() {
1722            return Err(Error::invalid(
1723                "avif: gain map metadata has no channel records to apply",
1724            ));
1725        }
1726        let px = (width as usize)
1727            .checked_mul(height as usize)
1728            .ok_or_else(|| Error::invalid("avif: gain map plane dimensions overflow usize"))?;
1729        let rgb_len = px
1730            .checked_mul(3)
1731            .ok_or_else(|| Error::invalid("avif: gain map plane dimensions overflow usize"))?;
1732        if baseline.len() != rgb_len {
1733            return Err(Error::invalid(format!(
1734                "avif: baseline plane length {} != width*height*3 = {rgb_len}",
1735                baseline.len()
1736            )));
1737        }
1738        // Achromatic (one sample/pixel, broadcast to RGB) or interleaved
1739        // RGB gain plane (§6.3 NOTE 2 vs an RGB gain map).
1740        let achromatic = match gain.len() {
1741            n if n == px => true,
1742            n if n == rgb_len => false,
1743            other => {
1744                return Err(Error::invalid(format!(
1745                    "avif: gain plane length {other} is neither width*height = {px} \
1746                     (achromatic) nor width*height*3 = {rgb_len} (RGB)"
1747                )));
1748            }
1749        };
1750
1751        // The weight factor depends only on the target headroom, so it is
1752        // constant across the whole plane — compute it once (§6.3
1753        // Formula 3).
1754        let w = self.weight_factor(target_headroom);
1755
1756        let mut out = vec![0.0f64; rgb_len];
1757        for i in 0..px {
1758            for c in 0..3 {
1759                let base = baseline[i * 3 + c];
1760                let g_sample = if achromatic { gain[i] } else { gain[i * 3 + c] };
1761                // channel_for is Some for c in 0..3 with a non-empty
1762                // channels vec (guaranteed above).
1763                let ch = self
1764                    .channel_for(c)
1765                    .expect("channel_for is Some for c<3 with non-empty channels");
1766                let g = ch.unnormalize_log2_gain(g_sample);
1767                let k_base = ch.base_offset.as_f64();
1768                let k_alt = ch.alternate_offset.as_f64();
1769                out[i * 3 + c] = (base + k_base) * (w * g).exp2() - k_alt;
1770            }
1771        }
1772        Ok(out)
1773    }
1774
1775    /// [`Self::apply_plane_rgb`] over a gain-map plane still in its
1776    /// **coded integer** form at `gain_bit_depth` bits — the shape the
1777    /// AV1 decode of a 8/10/12-bit `'tmap'` gain-map input item emits
1778    /// (av1-avif §4.2.2 codes the gain map as an ordinary AV1 image
1779    /// item). Each coded sample is first mapped to the normalized
1780    /// `[0, 1]` domain [`Self::apply_plane_rgb`] consumes via
1781    /// [`normalize_full_range_plane`] (full-range scaling at the coded
1782    /// depth), then the §6.3 application runs unchanged. `gain_coded`
1783    /// follows the same layout contract as `gain` there: `width ×
1784    /// height` samples for an achromatic gain map, `width × height × 3`
1785    /// interleaved for an RGB one.
1786    pub fn apply_plane_rgb_coded(
1787        &self,
1788        baseline: &[f64],
1789        gain_coded: &[u16],
1790        gain_bit_depth: u8,
1791        width: u32,
1792        height: u32,
1793        target_headroom: f64,
1794    ) -> Result<Vec<f64>> {
1795        let gain = normalize_full_range_plane(gain_coded, gain_bit_depth)?;
1796        self.apply_plane_rgb(baseline, &gain, width, height, target_headroom)
1797    }
1798}
1799
1800/// Map a plane of full-range coded samples at `bit_depth` bits into the
1801/// normalized `[0, 1]` domain: `x / (2^bit_depth − 1)`.
1802///
1803/// This is the AV1 full-range sample convention (§5.5.2
1804/// `color_range = 1`: code values span the whole `[0, 2^depth − 1]`
1805/// interval) applied in reverse — the shape a decoded 8/10/12-bit
1806/// gain-map or alpha plane arrives in before any normalized-domain
1807/// math. Accepts `bit_depth` ∈ {8, 10, 12, 16} and rejects samples
1808/// that exceed the declared depth's code range.
1809pub fn normalize_full_range_plane(samples: &[u16], bit_depth: u8) -> Result<Vec<f64>> {
1810    if !matches!(bit_depth, 8 | 10 | 12 | 16) {
1811        return Err(Error::invalid(format!(
1812            "avif: bit depth {bit_depth} is not one of 8/10/12/16"
1813        )));
1814    }
1815    let max = ((1u32 << bit_depth) - 1) as f64;
1816    let ceil = 1u32 << bit_depth;
1817    let mut out = Vec::with_capacity(samples.len());
1818    for (i, &s) in samples.iter().enumerate() {
1819        if (s as u32) >= ceil && bit_depth < 16 {
1820            return Err(Error::invalid(format!(
1821                "avif: sample {i} = {s} exceeds the {bit_depth}-bit code range"
1822            )));
1823        }
1824        out.push(s as f64 / max);
1825    }
1826    Ok(out)
1827}
1828
1829/// Read a `numerator(int32) / denominator(uint32)` rational and reject a
1830/// zero denominator (every denominator field "shall not be 0", C.2.3).
1831fn read_signed_rational(buf: &[u8], at: usize) -> Result<GainMapRational> {
1832    let numerator = read_u32(buf, at)? as i32;
1833    let denominator = read_u32(buf, at + 4)?;
1834    if denominator == 0 {
1835        return Err(Error::invalid(
1836            "avif: gain map metadata rational denominator is 0 (ISO 21496-1 C.2.3)",
1837        ));
1838    }
1839    Ok(GainMapRational {
1840        numerator,
1841        denominator,
1842    })
1843}
1844
1845/// Read a `numerator(uint32) / denominator(uint32)` rational. The HDR
1846/// headroom numerators are logically unsigned but stored in the same
1847/// `GainMapRational` (`i32` numerator) for a uniform type; values up to
1848/// `i32::MAX` round-trip exactly and the denominator is still rejected
1849/// when zero.
1850fn read_unsigned_rational(buf: &[u8], at: usize) -> Result<GainMapRational> {
1851    read_signed_rational(buf, at)
1852}
1853
1854/// True when the rational value of `a` is greater than or equal to
1855/// the rational value of `b`. Computes the comparison via the
1856/// cross-multiplied `i64` product so the predicate is exact across
1857/// any (numerator, denominator) pair without floating-point rounding.
1858///
1859/// Both denominators are required to be non-zero (and positive — the
1860/// underlying field is `unsigned int(32)` per Annex C.2.2 and the
1861/// reader rejects zero in `read_signed_rational`). With positive
1862/// denominators, `a/da >= b/db` iff `a*db >= b*da`. Numerators are
1863/// `i32` and denominators fit in `u32`, so the products always fit in
1864/// `i64` (max magnitude ~2^31 × 2^32 = 2^63, the i64 limit).
1865fn rational_ge(a: &GainMapRational, b: &GainMapRational) -> bool {
1866    let lhs = (a.numerator as i64) * (b.denominator as i64);
1867    let rhs = (b.numerator as i64) * (a.denominator as i64);
1868    lhs >= rhs
1869}
1870
1871/// True when the rational values of `a` and `b` are not equal. Uses
1872/// the same exact i64 cross-multiplication as [`rational_ge`] so
1873/// `1/1` and `2/2` correctly compare as equal (and therefore are not
1874/// reported as differing).
1875fn rationals_differ(a: &GainMapRational, b: &GainMapRational) -> bool {
1876    let lhs = (a.numerator as i64) * (b.denominator as i64);
1877    let rhs = (b.numerator as i64) * (a.denominator as i64);
1878    lhs != rhs
1879}
1880
1881/// Per-grid `shall`-level compliance against av1-avif §7's
1882/// transformative-property constraint:
1883///
1884/// > Transformative properties shall not be associated with items in a
1885/// > derivation chain (as defined in [MIAF]) that serves as an input to
1886/// > a grid derived image item. For example, if a file contains a grid
1887/// > item and its referenced coded image items, cropping, mirroring or
1888/// > rotation transformations are only permitted on the grid item itself.
1889///
1890/// This is a *file-shape* constraint: the spec lets the grid item itself
1891/// carry any of `clap` / `irot` / `imir`, but forbids any of its `dimg`
1892/// input tiles from doing so. A reader that processes a non-compliant
1893/// file would either silently get the wrong pixels (if it ignored the
1894/// per-tile transform) or render a torn canvas (if it honoured the
1895/// per-tile transform before compositing).
1896///
1897/// One [`GridDerivationAudit`] record is emitted per `'grid'` item in
1898/// `iinf` declaration order via [`audit_grid_derivations`]. Each record
1899/// lists the offending `(tile_item_id, transformative_kind)` pairs found
1900/// on any `dimg` input. An empty `offenders` vector is the compliant
1901/// case; [`Self::is_compliant`] is a one-call gate.
1902#[non_exhaustive]
1903#[derive(Clone, Debug, PartialEq, Eq, Default)]
1904pub struct GridDerivationAudit {
1905    /// The `'grid'` item id this audit describes.
1906    pub grid_item_id: u32,
1907    /// Tile items referenced by the grid's `dimg` iref, in declaration
1908    /// order. Empty when the grid has no `dimg` iref (already a malformed
1909    /// grid; the audit still emits the record).
1910    pub tile_item_ids: Vec<u32>,
1911    /// `(tile_item_id, property_kind)` pairs where a tile carries a
1912    /// transformative property (`'clap'` / `'irot'` / `'imir'` /
1913    /// `'iscl'`). One entry per (tile, kind) — a tile that carries
1914    /// all four lands as four entries. Empty when the grid is
1915    /// compliant.
1916    pub offenders: Vec<(u32, BoxType)>,
1917}
1918
1919impl GridDerivationAudit {
1920    /// Every field as a positional argument, in declaration order
1921    /// (the record is `#[non_exhaustive]`: build it here, or from
1922    /// `Default` where one exists, then read / assign the public
1923    /// fields).
1924    #[allow(clippy::too_many_arguments)]
1925    pub fn new(grid_item_id: u32, tile_item_ids: Vec<u32>, offenders: Vec<(u32, BoxType)>) -> Self {
1926        Self {
1927            grid_item_id,
1928            tile_item_ids,
1929            offenders,
1930        }
1931    }
1932}
1933
1934impl GridDerivationAudit {
1935    /// True when no tile in the derivation chain carries any
1936    /// transformative property — the spec-compliant shape.
1937    pub fn is_compliant(&self) -> bool {
1938        self.offenders.is_empty()
1939    }
1940
1941    /// Convenience: list the unique offending tile item ids (a tile that
1942    /// carries multiple transformative properties only appears once).
1943    pub fn offending_tile_ids(&self) -> Vec<u32> {
1944        let mut out: Vec<u32> = self.offenders.iter().map(|(id, _)| *id).collect();
1945        out.sort_unstable();
1946        out.dedup();
1947        out
1948    }
1949}
1950
1951/// Audit every `'grid'` item carried in `meta` against the av1-avif §7
1952/// transformative-property `shall`. Returns one [`GridDerivationAudit`]
1953/// record per grid item, in `iinf` declaration order. The returned
1954/// vector is empty when the file ships no grid items.
1955///
1956/// Spec: av1-avif v1.2.0 §7 General constraints — "Transformative
1957/// properties shall not be associated with items in a derivation chain
1958/// that serves as an input to a grid derived image item." The
1959/// transformative properties this crate recognises are `'clap'`
1960/// (cropping, HEIF §6.5.5), `'irot'` (rotation, HEIF §6.5.10),
1961/// `'imir'` (mirroring, HEIF §6.5.11), and `'iscl'` (image scaling,
1962/// HEIF §6.5.13). `'rref'` is descriptive (not transformative) and
1963/// is therefore not flagged; an explicit `Property::Other`
1964/// association on a tile is surfaced by the existing
1965/// [`crate::meta::Meta::unsupported_essential_properties`] path.
1966pub fn audit_grid_derivations(meta: &crate::meta::Meta) -> Vec<GridDerivationAudit> {
1967    let grid_type = crate::parser::ITEM_TYPE_GRID;
1968    let dimg = b(b"dimg");
1969    let irot = b(b"irot");
1970    let imir = b(b"imir");
1971    let clap = b(b"clap");
1972    let iscl = b(b"iscl");
1973    meta.item_ids_of_type(&grid_type)
1974        .into_iter()
1975        .map(|grid_id| {
1976            let tile_item_ids = meta.iref_targets(&dimg, grid_id);
1977            let mut offenders = Vec::new();
1978            for tile_id in &tile_item_ids {
1979                // For each tile we check the four transformative property
1980                // kinds explicitly so the output preserves a stable
1981                // (clap, irot, imir, iscl) ordering per offending tile —
1982                // easier for callers (and tests) to diff than association
1983                // order, which depends on `ipma` writer choice.
1984                for kind in [clap, irot, imir, iscl] {
1985                    if meta.property_for(*tile_id, &kind).is_some() {
1986                        offenders.push((*tile_id, kind));
1987                    }
1988                }
1989            }
1990            GridDerivationAudit {
1991                grid_item_id: grid_id,
1992                tile_item_ids,
1993                offenders,
1994            }
1995        })
1996        .collect()
1997}
1998
1999// ---------------------------------------------------------------------------
2000// HEIF §6.6.2.1 — Identity Derived Image Item (`iden`) `shall`-level compliance
2001// ---------------------------------------------------------------------------
2002
2003/// Per-iden `shall`-level compliance against ISO/IEC 23008-12 (HEIF) §6.6.2.1
2004/// (Identity derivation):
2005///
2006/// > A derived image item of the `item_type` value `'iden'` (identity
2007/// > transformation) may be used when it is desired to use transformative
2008/// > properties to derive an image item. The derived image item **shall**
2009/// > have no item body (i.e. no extents), and `reference_count` for the
2010/// > `'dimg'` item reference of a `'iden'` derived image item **shall** be
2011/// > equal to 1.
2012///
2013/// In addition to those two clause-local `shall`s, HEIF §6.6.1 imposes a
2014/// crosscutting `shall` that applies to every derived image item (`iden`
2015/// included):
2016///
2017/// > The number of `SingleItemTypeReferenceBoxes` with the box type `'dimg'`
2018/// > and with the same value of `from_item_ID` **shall** not be greater
2019/// > than 1.
2020///
2021/// Together, the constraints check whether a file's `'iden'` items obey
2022/// the standalone identity-derivation `shall`s without depending on any
2023/// pixel-side decode (the AV1 OBU pipeline is irrelevant here — `iden`
2024/// items have no body to decode in the first place).
2025///
2026/// One [`IdenCompliance`] record is emitted per `'iden'` item in `iinf`
2027/// declaration order via [`audit_iden_derivations`]. Each record reports:
2028///
2029/// * `dimg_reference_count` — number of `'dimg'` `to_ids` listed for the
2030///   iden's `from_item_ID`. Compliant value is exactly `1`.
2031/// * `dimg_iref_count` — how many separate `'dimg'` iref entries share the
2032///   iden's `from_item_ID`. Compliant value is at most `1` (HEIF §6.6.1).
2033/// * `has_item_body` — whether the iden's `'iloc'` entry lists any
2034///   non-empty extent. Compliant value is `false` (no body).
2035/// * `source_item_id` — the single contributing source item id, when
2036///   `dimg_reference_count == 1`. Useful for callers that want to resolve
2037///   which coded image item the iden derives from without re-walking the
2038///   iref.
2039///
2040/// All three checks are `shall`-level, so a fail means the file is
2041/// non-conformant per HEIF §6.6.2.1 / §6.6.1. [`Self::is_compliant`]
2042/// reports the AND of every signal; [`Self::missing`] enumerates the
2043/// failing checks for diagnostics.
2044#[non_exhaustive]
2045#[derive(Clone, Debug, PartialEq, Eq, Default)]
2046pub struct IdenCompliance {
2047    /// The `'iden'` item id this audit describes.
2048    pub iden_item_id: u32,
2049    /// Number of `'dimg'` `to_ids` listed for the iden's `from_item_ID`,
2050    /// across the single matching iref entry (HEIF §6.6.1 forbids more
2051    /// than one such entry; when multiple are present this counts the
2052    /// first only, and `dimg_iref_count` flags the violation separately).
2053    /// Compliant value: exactly `1`.
2054    pub dimg_reference_count: usize,
2055    /// Number of distinct `'dimg'` `SingleItemTypeReferenceBox` entries
2056    /// whose `from_item_ID` equals the iden item id. Compliant value: at
2057    /// most `1` (HEIF §6.6.1).
2058    pub dimg_iref_count: usize,
2059    /// `true` when the iden item's `'iloc'` entry carries any extent —
2060    /// HEIF §6.6.2.1 mandates the iden item have no body. Compliant
2061    /// value: `false`. An iden with no `'iloc'` entry at all is also
2062    /// compliant (empty extent list).
2063    pub has_item_body: bool,
2064    /// The single source image item id contributing to the iden, when
2065    /// `dimg_reference_count == 1`. `None` when the iden has zero
2066    /// inputs or when the input count is non-conformant (in which case
2067    /// the audit doesn't pick "the" source — the file is malformed and
2068    /// the caller should inspect `dimg_reference_count` to disambiguate).
2069    pub source_item_id: Option<u32>,
2070}
2071
2072impl IdenCompliance {
2073    /// Every field as a positional argument, in declaration order
2074    /// (the record is `#[non_exhaustive]`: build it here, or from
2075    /// `Default` where one exists, then read / assign the public
2076    /// fields).
2077    #[allow(clippy::too_many_arguments)]
2078    pub fn new(
2079        iden_item_id: u32,
2080        dimg_reference_count: usize,
2081        dimg_iref_count: usize,
2082        has_item_body: bool,
2083        source_item_id: Option<u32>,
2084    ) -> Self {
2085        Self {
2086            iden_item_id,
2087            dimg_reference_count,
2088            dimg_iref_count,
2089            has_item_body,
2090            source_item_id,
2091        }
2092    }
2093}
2094
2095impl IdenCompliance {
2096    /// True when every HEIF §6.6.2.1 + §6.6.1 `shall` passes for this
2097    /// iden item:
2098    ///
2099    /// * exactly one `'dimg'` input (`dimg_reference_count == 1`),
2100    /// * exactly one `'dimg'` iref entry for the iden's `from_item_ID`
2101    ///   (`dimg_iref_count <= 1`; zero is a degenerate but still
2102    ///   non-conformant case),
2103    /// * no item body (`!has_item_body`).
2104    pub fn is_compliant(&self) -> bool {
2105        self.dimg_reference_count == 1 && self.dimg_iref_count == 1 && !self.has_item_body
2106    }
2107
2108    /// Human-readable list of failed `shall`s. Returns an empty vector
2109    /// when [`Self::is_compliant`].
2110    pub fn missing(&self) -> Vec<&'static str> {
2111        let mut out = Vec::new();
2112        if self.dimg_reference_count != 1 {
2113            out.push("dimg-reference-count-eq-1");
2114        }
2115        if self.dimg_iref_count != 1 {
2116            out.push("dimg-iref-count-eq-1");
2117        }
2118        if self.has_item_body {
2119            out.push("no-item-body");
2120        }
2121        out
2122    }
2123}
2124
2125/// Audit every `'iden'` item carried in `meta` against the HEIF §6.6.2.1
2126/// (Identity derivation) and §6.6.1 (derived-image cross-clause)
2127/// `shall`-level constraints. Returns one [`IdenCompliance`] record per
2128/// iden item, in `iinf` declaration order. The returned vector is empty
2129/// when the file ships no iden items.
2130///
2131/// Spec: ISO/IEC 23008-12 (HEIF) §6.6.2.1 — "The derived image item shall
2132/// have no item body (i.e. no extents), and `reference_count` for the
2133/// `'dimg'` item reference of a `'iden'` derived image item shall be equal
2134/// to 1." + §6.6.1 — "The number of `SingleItemTypeReferenceBoxes` with
2135/// the box type `'dimg'` and with the same value of `from_item_ID` shall
2136/// not be greater than 1."
2137pub fn audit_iden_derivations(meta: &crate::meta::Meta) -> Vec<IdenCompliance> {
2138    let iden_type = crate::meta::ITEM_TYPE_IDEN;
2139    let dimg = b(b"dimg");
2140    meta.item_ids_of_type(&iden_type)
2141        .into_iter()
2142        .map(|iden_id| audit_one_iden(meta, &dimg, iden_id))
2143        .collect()
2144}
2145
2146fn audit_one_iden(meta: &crate::meta::Meta, dimg: &BoxType, iden_id: u32) -> IdenCompliance {
2147    // Count how many distinct `'dimg'` iref entries share this iden as
2148    // their `from_item_ID` (HEIF §6.6.1: shall not be greater than 1).
2149    let dimg_iref_count = meta
2150        .irefs
2151        .iter()
2152        .filter(|e| &e.reference_type == dimg && e.from_id == iden_id)
2153        .count();
2154
2155    // `iref_targets` returns the to_ids of the first matching entry, so
2156    // when `dimg_iref_count > 1` the reference count reported here is from
2157    // entry 0 only — that's fine: the `dimg_iref_count` field is what
2158    // flags the §6.6.1 violation, while `dimg_reference_count` checks the
2159    // §6.6.2.1 single-input shall against the (possibly first-of-many)
2160    // observed input list.
2161    let inputs = meta.iref_targets(dimg, iden_id);
2162    let dimg_reference_count = inputs.len();
2163    let source_item_id = if dimg_reference_count == 1 {
2164        Some(inputs[0])
2165    } else {
2166        None
2167    };
2168
2169    // HEIF §6.6.2.1 mandates the iden item carry no item body. An item
2170    // with no `'iloc'` entry at all trivially has no body; an entry with
2171    // an empty extent list (or every extent length zero) is equivalent.
2172    // Any non-zero-length extent flags a violation.
2173    let has_item_body = meta
2174        .location_by_id(iden_id)
2175        .map(|loc| loc.extents.iter().any(|x| x.length > 0))
2176        .unwrap_or(false);
2177
2178    IdenCompliance {
2179        iden_item_id: iden_id,
2180        dimg_reference_count,
2181        dimg_iref_count,
2182        has_item_body,
2183        source_item_id,
2184    }
2185}
2186
2187// ---------------------------------------------------------------------------
2188// 'pred' brand file-constraint audit (HEIF §10.2.4)
2189// ---------------------------------------------------------------------------
2190
2191/// Result of a `'pred'` brand file-constraint audit (HEIF §10.2.4.2).
2192///
2193/// The `'pred'` brand declares a file may contain *predictively coded
2194/// image items* (image items with a decoding dependency on other coded
2195/// image items, expressed through `'pred'` item references, §6.4.9). When
2196/// the brand is claimed the file shall satisfy two `shall`s (§10.2.4.2):
2197///
2198/// 1. **Dependency closure** — for every predictively coded image item,
2199///    the file shall also contain every item that item depends on (each
2200///    `'pred'` target shall be an item present in `iinf`).
2201/// 2. **mif1 primary independence** — if `'mif1'` is *also* among the
2202///    compatible brands, the primary item shall be independently coded
2203///    (it shall itself have no `'pred'` reference).
2204///
2205/// All flags are reported even when the file makes no `'pred'` claim, so a
2206/// caller can distinguish "claims pred and fails" from "no pred claim".
2207#[non_exhaustive]
2208#[derive(Clone, Debug, Default, PartialEq, Eq)]
2209pub struct PredBrandCompliance {
2210    /// `'pred'` is among the file's major/compatible brands.
2211    pub claims_pred: bool,
2212    /// `'mif1'` is among the compatible brands (gates the §10.2.4.2
2213    /// primary-independence `shall`).
2214    pub claims_mif1: bool,
2215    /// Number of predictively coded image items found (items with at
2216    /// least one outgoing `'pred'` reference).
2217    pub predictive_item_count: usize,
2218    /// `'pred'` targets that name an item id absent from `iinf` — each is
2219    /// a §10.2.4.2 dependency-closure violation. Empty when closure holds.
2220    pub missing_dependency_ids: Vec<u32>,
2221    /// `true` when `'mif1'` is claimed yet the primary item carries a
2222    /// `'pred'` reference (i.e. it is not independently coded) — a
2223    /// §10.2.4.2 violation. `false` when not applicable or satisfied.
2224    pub primary_not_independent: bool,
2225}
2226
2227impl PredBrandCompliance {
2228    /// Every field as a positional argument, in declaration order
2229    /// (the record is `#[non_exhaustive]`: build it here, or from
2230    /// `Default` where one exists, then read / assign the public
2231    /// fields).
2232    #[allow(clippy::too_many_arguments)]
2233    pub fn new(
2234        claims_pred: bool,
2235        claims_mif1: bool,
2236        predictive_item_count: usize,
2237        missing_dependency_ids: Vec<u32>,
2238        primary_not_independent: bool,
2239    ) -> Self {
2240        Self {
2241            claims_pred,
2242            claims_mif1,
2243            predictive_item_count,
2244            missing_dependency_ids,
2245            primary_not_independent,
2246        }
2247    }
2248}
2249
2250impl PredBrandCompliance {
2251    /// `true` when every applicable §10.2.4.2 `shall` passes. Trivially
2252    /// `true` when the file makes no `'pred'` claim.
2253    pub fn is_compliant(&self) -> bool {
2254        if !self.claims_pred {
2255            return true;
2256        }
2257        self.missing_dependency_ids.is_empty() && !self.primary_not_independent
2258    }
2259
2260    /// Human-readable list of failed `shall`s; empty when
2261    /// [`Self::is_compliant`].
2262    pub fn missing(&self) -> Vec<&'static str> {
2263        let mut out = Vec::new();
2264        if !self.claims_pred {
2265            return out;
2266        }
2267        if !self.missing_dependency_ids.is_empty() {
2268            out.push("pred-dependency-closure");
2269        }
2270        if self.primary_not_independent {
2271            out.push("mif1-primary-independent");
2272        }
2273        out
2274    }
2275}
2276
2277/// Audit a file's `'pred'` brand file-level constraints (HEIF §10.2.4.2)
2278/// against `meta` and the file's `compatible_brands` (plus `major_brand`).
2279///
2280/// `brands` is the brand set to test against — pass the file's
2281/// `major_brand` followed by its `compatible_brands` (the audit treats the
2282/// whole set uniformly; §10.2.4.1 places `'pred'` in `compatible_brands`).
2283/// The audit walks every image item, collects those with an outgoing
2284/// `'pred'` reference, verifies each `'pred'` target is an item present in
2285/// `iinf` (dependency closure), and — when `'mif1'` is also claimed —
2286/// checks the primary item carries no `'pred'` reference.
2287pub fn audit_pred_brand(meta: &crate::meta::Meta, brands: &[BoxType]) -> PredBrandCompliance {
2288    let pred = b(b"pred");
2289    let mif1 = b(b"mif1");
2290    let pred_brand = b(b"pred");
2291
2292    let mut out = PredBrandCompliance {
2293        claims_pred: brands.contains(&pred_brand),
2294        claims_mif1: brands.contains(&mif1),
2295        ..PredBrandCompliance::default()
2296    };
2297
2298    // Set of item ids present in iinf — the dependency-closure check.
2299    let present: std::collections::BTreeSet<u32> = meta.items.iter().map(|i| i.id).collect();
2300
2301    let mut missing: Vec<u32> = Vec::new();
2302    for item in &meta.items {
2303        let deps = meta.iref_targets_of(&pred, item.id);
2304        if deps.is_empty() {
2305            continue;
2306        }
2307        out.predictive_item_count += 1;
2308        for dep in deps {
2309            if !present.contains(&dep) && !missing.contains(&dep) {
2310                missing.push(dep);
2311            }
2312        }
2313    }
2314    out.missing_dependency_ids = missing;
2315
2316    if out.claims_mif1 {
2317        if let Some(primary) = meta.primary_item_id {
2318            out.primary_not_independent = !meta.iref_targets_of(&pred, primary).is_empty();
2319        }
2320    }
2321
2322    out
2323}
2324
2325// ---------------------------------------------------------------------------
2326// AV1 Alpha Image Item bit-depth match audit (av1-avif v1.2.0 §4.1)
2327// ---------------------------------------------------------------------------
2328
2329/// Decode bit-depth from the first three bytes of an `av1C` (AV1
2330/// CodecConfigurationRecord) payload. Returns `None` when the slice is
2331/// too short to carry the `high_bitdepth` / `twelve_bit` flag byte.
2332///
2333/// Layout per av1-avif §2.2.1 (which references AV1 Bitstream &
2334/// Decoding Process §6.4): `av1C[2]` packs
2335/// `high_bitdepth (1) | twelve_bit (1) | monochrome (1) |
2336///  chroma_subsampling_x (1) | chroma_subsampling_y (1) |
2337///  chroma_sample_position (2) | reserved (1)`. Bit depth is `8`
2338/// (neither flag), `10` (high only), or `12` (both).
2339fn decode_av1c_bit_depth(av1c: &[u8]) -> Option<u8> {
2340    if av1c.len() < 3 {
2341        return None;
2342    }
2343    let b2 = av1c[2];
2344    let high_bitdepth = ((b2 >> 6) & 1) != 0;
2345    let twelve_bit = ((b2 >> 5) & 1) != 0;
2346    Some(if twelve_bit {
2347        12
2348    } else if high_bitdepth {
2349        10
2350    } else {
2351        8
2352    })
2353}
2354
2355/// Per-(alpha, master) `shall`-level compliance against av1-avif v1.2.0
2356/// §4.1 (Auxiliary Image Items and Sequences):
2357///
2358/// > An AV1 Alpha Image Item (respectively an AV1 Alpha Image Sequence)
2359/// > shall be encoded with the same bit depth as the associated master
2360/// > AV1 Image Item (respectively AV1 Image Sequence).
2361///
2362/// One [`AlphaBitDepthAudit`] record is emitted per `(alpha_item_id,
2363/// master_item_id)` pair an `auxl` iref's `to_ids` declares, in iref
2364/// declaration order. The audit is independent of any AV1 OBU decode —
2365/// the check operates entirely on the `av1C` configuration record
2366/// surfaced by the box walker.
2367///
2368/// Fields:
2369///
2370/// * `alpha_bit_depth` / `master_bit_depth` — bit depths extracted from
2371///   each item's `av1C` property (`8`, `10`, or `12`). `None` when the
2372///   corresponding item carries no `av1C` (malformed) or when the
2373///   `av1C` payload is too short to carry the flag byte.
2374/// * `master_missing_av1c` — `true` when the master item id appearing
2375///   in the `auxl` iref does not resolve to an item with an `av1C`
2376///   property. This is also a §2.1 violation (every AV1 Image Item
2377///   shall carry `av1C`) and surfaces alongside the bit-depth check
2378///   here for a single point of inspection.
2379/// * `alpha_missing_av1c` — same for the alpha item.
2380///
2381/// The check passes ([`AlphaBitDepthAudit::is_compliant`]) when both
2382/// items carry an `av1C` and both decoded bit depths agree. Either
2383/// item missing `av1C`, or any mismatch, fails the audit.
2384#[non_exhaustive]
2385#[derive(Clone, Debug, PartialEq, Eq, Default)]
2386pub struct AlphaBitDepthAudit {
2387    /// The AV1 Alpha Image Item id (the `auxl` iref's `from_id`).
2388    pub alpha_item_id: u32,
2389    /// The associated master AV1 Image Item id (one of the `auxl`
2390    /// iref's `to_ids`).
2391    pub master_item_id: u32,
2392    /// Bit depth decoded from the alpha item's `av1C` (`8`, `10`, or
2393    /// `12`), or `None` when `av1C` is absent or truncated.
2394    pub alpha_bit_depth: Option<u8>,
2395    /// Bit depth decoded from the master item's `av1C`, or `None` when
2396    /// `av1C` is absent or truncated.
2397    pub master_bit_depth: Option<u8>,
2398    /// `true` when the alpha item carries no `av1C` association at all.
2399    /// Distinct from a present-but-truncated `av1C` (which surfaces as
2400    /// `alpha_bit_depth = None` without setting this flag).
2401    pub alpha_missing_av1c: bool,
2402    /// `true` when the master item carries no `av1C` association at
2403    /// all.
2404    pub master_missing_av1c: bool,
2405}
2406
2407impl AlphaBitDepthAudit {
2408    /// Every field as a positional argument, in declaration order
2409    /// (the record is `#[non_exhaustive]`: build it here, or from
2410    /// `Default` where one exists, then read / assign the public
2411    /// fields).
2412    #[allow(clippy::too_many_arguments)]
2413    pub fn new(
2414        alpha_item_id: u32,
2415        master_item_id: u32,
2416        alpha_bit_depth: Option<u8>,
2417        master_bit_depth: Option<u8>,
2418        alpha_missing_av1c: bool,
2419        master_missing_av1c: bool,
2420    ) -> Self {
2421        Self {
2422            alpha_item_id,
2423            master_item_id,
2424            alpha_bit_depth,
2425            master_bit_depth,
2426            alpha_missing_av1c,
2427            master_missing_av1c,
2428        }
2429    }
2430}
2431
2432impl AlphaBitDepthAudit {
2433    /// True when both items carry an `av1C` whose decoded bit depth
2434    /// agrees — the spec-compliant shape per av1-avif §4.1.
2435    pub fn is_compliant(&self) -> bool {
2436        match (self.alpha_bit_depth, self.master_bit_depth) {
2437            (Some(a), Some(m)) => a == m,
2438            _ => false,
2439        }
2440    }
2441
2442    /// Human-readable list of failed `shall`s. Returns an empty vector
2443    /// when [`Self::is_compliant`].
2444    pub fn missing(&self) -> Vec<&'static str> {
2445        let mut out = Vec::new();
2446        if self.alpha_missing_av1c {
2447            out.push("alpha-item-missing-av1C");
2448        } else if self.alpha_bit_depth.is_none() {
2449            out.push("alpha-item-av1C-truncated");
2450        }
2451        if self.master_missing_av1c {
2452            out.push("master-item-missing-av1C");
2453        } else if self.master_bit_depth.is_none() {
2454            out.push("master-item-av1C-truncated");
2455        }
2456        if let (Some(a), Some(m)) = (self.alpha_bit_depth, self.master_bit_depth) {
2457            if a != m {
2458                out.push("alpha-master-bit-depth-mismatch");
2459            }
2460        }
2461        out
2462    }
2463}
2464
2465/// Audit every `(AV1 Alpha Image Item, associated master AV1 Image
2466/// Item)` pairing carried in `meta` against the av1-avif §4.1 `shall`
2467/// that they share bit depth. Returns one [`AlphaBitDepthAudit`] record
2468/// per `(alpha, master)` pair declared by an `auxl` iref. The returned
2469/// vector is empty when the file ships no AV1 Alpha Image Items.
2470///
2471/// An item qualifies as an "AV1 Alpha Image Item" when:
2472///
2473/// 1. It is the `from_id` of an `auxl` iref entry, and
2474/// 2. It carries an `auxC` property whose URN starts with the alpha
2475///    prefix (`urn:mpeg:mpegB:cicp:systems:auxiliary:alpha`), matching
2476///    [`crate::alpha::ALPHA_URN_PREFIX`].
2477///
2478/// Each `to_id` in the same iref entry is audited as a master. A single
2479/// alpha item declared against multiple masters (a `to_ids` list of
2480/// length > 1) emits one record per master, in `to_ids` order — the
2481/// `shall` applies per pairing. Iref entries are processed in the order
2482/// they appear inside the source `iref` box.
2483///
2484/// Spec: av1-avif v1.2.0 §4.1 — "An AV1 Alpha Image Item (respectively
2485/// an AV1 Alpha Image Sequence) shall be encoded with the same bit
2486/// depth as the associated master AV1 Image Item (respectively AV1
2487/// Image Sequence)."
2488///
2489/// Either side may be a **derived** item (HEIF §6.4.1: an auxiliary
2490/// image may be a `grid` of coded tiles, and a grid primary's alpha is
2491/// such a grid): its `av1C` is then resolved through the first `dimg`
2492/// input of the derivation chain — the coded leaf whose configuration
2493/// record governs the whole derived image (all tiles share one
2494/// depth, av1-avif §7 / HEIF §6.6.2.3).
2495pub fn audit_alpha_bit_depth(meta: &crate::meta::Meta) -> Vec<AlphaBitDepthAudit> {
2496    let auxl = b(b"auxl");
2497    let auxc = b(b"auxC");
2498    let mut out = Vec::new();
2499    for entry in &meta.irefs {
2500        if entry.reference_type != auxl {
2501            continue;
2502        }
2503        let alpha_id = entry.from_id;
2504        // Classify the candidate as an alpha auxiliary by URN prefix
2505        // match against the `auxC` property. Non-alpha auxiliaries
2506        // (depth maps, HDR gain maps) are bound by separate `shall`s
2507        // beyond §4.1's bit-depth constraint and don't surface here.
2508        let is_alpha = match meta.property_for(alpha_id, &auxc) {
2509            Some(crate::meta::Property::AuxC(aux)) => {
2510                aux.aux_type.starts_with(crate::alpha::ALPHA_URN_PREFIX)
2511            }
2512            _ => false,
2513        };
2514        if !is_alpha {
2515            continue;
2516        }
2517        let (alpha_bit_depth, alpha_missing_av1c) = match effective_av1c(meta, alpha_id, 0) {
2518            Some(bytes) => (decode_av1c_bit_depth(bytes), false),
2519            None => (None, true),
2520        };
2521        for &master_id in &entry.to_ids {
2522            let (master_bit_depth, master_missing_av1c) = match effective_av1c(meta, master_id, 0) {
2523                Some(bytes) => (decode_av1c_bit_depth(bytes), false),
2524                None => (None, true),
2525            };
2526            out.push(AlphaBitDepthAudit {
2527                alpha_item_id: alpha_id,
2528                master_item_id: master_id,
2529                alpha_bit_depth,
2530                master_bit_depth,
2531                alpha_missing_av1c,
2532                master_missing_av1c,
2533            });
2534        }
2535    }
2536    out
2537}
2538
2539/// The `av1C` governing `item_id`: its own property, or — for a derived
2540/// item without one — the record of its first `dimg` input, recursively
2541/// (depth-guarded by [`MAX_DERIVATION_DEPTH`]).
2542fn effective_av1c(meta: &crate::meta::Meta, item_id: u32, depth: u32) -> Option<&[u8]> {
2543    if depth > MAX_DERIVATION_DEPTH {
2544        return None;
2545    }
2546    if let Some(crate::meta::Property::Av1C(bytes)) = meta.property_for(item_id, &b(b"av1C")) {
2547        return Some(bytes);
2548    }
2549    let inputs = meta.iref_targets(&b(b"dimg"), item_id);
2550    let &first = inputs.first()?;
2551    if first == item_id {
2552        return None;
2553    }
2554    effective_av1c(meta, first, depth + 1)
2555}
2556
2557/// Outcome of walking a single AV1 Image Item's OBU stream to count
2558/// Sequence Header OBUs, per av1-avif v1.2.0 §2.1 — "The AV1 Image
2559/// Item Data shall have exactly one Sequence Header OBU."
2560///
2561/// One record per AV1 Image Item (`item_type == 'av01'`) in declaration
2562/// order. The audit is purely structural: it walks the framing defined
2563/// in AV1 §5.3.1 (header byte plus leb128 `obu_size`) and reads the
2564/// `obu_type` field from each OBU header (AV1 §5.3.2), incrementing
2565/// [`Self::sequence_header_count`] each time the type equals
2566/// `OBU_SEQUENCE_HEADER` (value `1`, per AV1 §6.2.1's `obu_type`
2567/// enumeration). The OBU payload bodies themselves are not decoded — the
2568/// walker only needs the framing.
2569///
2570/// Three structural failure modes are surfaced distinctly from a plain
2571/// `sequence_header_count != 1` mismatch:
2572///
2573/// * [`Self::missing_iloc`] — the `iinf` lists the item but no `iloc`
2574///   resolves its bytes. The OBU walk is not attempted.
2575/// * [`Self::truncated_obu`] — the OBU framing walker hit the end of
2576///   the stream mid-OBU (truncated leb128, or a declared `obu_size`
2577///   that runs past the item payload). Any OBUs successfully walked
2578///   before truncation are still counted; the flag tells the caller
2579///   the count may be an undercount of the well-formed file the
2580///   writer intended.
2581/// * [`Self::has_size_field_zero`] — at least one OBU in the stream
2582///   has `obu_has_size_field == 0`. AV1 §5.3.1 requires that OBUs
2583///   carried inside a container that does not separately frame each
2584///   OBU (which is the case for AVIF image items, see §2.1's
2585///   "identical to the content of an AV1 Sample marked as 'sync'"
2586///   constraint and [AV1-ISOBMFF] §2.3.2's requirement that the
2587///   has_size flag be set when chaining OBUs inside one sample) must
2588///   carry `obu_size`. We surface this as a separate signal because
2589///   the walker cannot frame an OBU without an explicit size when
2590///   chained — it stops at the first such OBU and the count from
2591///   that point on is undefined.
2592#[non_exhaustive]
2593#[derive(Clone, Debug, PartialEq, Eq, Default)]
2594pub struct SequenceHeaderObuAudit {
2595    /// AV1 Image Item id whose payload was walked.
2596    pub item_id: u32,
2597    /// Number of OBUs whose header byte decoded to
2598    /// `obu_type == OBU_SEQUENCE_HEADER` (value `1`).
2599    pub sequence_header_count: u32,
2600    /// Total number of OBUs walked (successfully framed) in the
2601    /// stream. Diagnostic; not a `shall` check on its own.
2602    pub total_obu_count: u32,
2603    /// `true` when no `iloc` entry resolved the item's bytes. The OBU
2604    /// walk was skipped; [`Self::sequence_header_count`] and
2605    /// [`Self::total_obu_count`] are both `0`.
2606    pub missing_iloc: bool,
2607    /// `true` when the framing walker hit end-of-stream mid-OBU
2608    /// (truncated leb128 byte sequence, or a declared `obu_size` that
2609    /// runs past the end of the item payload).
2610    pub truncated_obu: bool,
2611    /// `true` when the walker encountered an OBU whose header byte has
2612    /// `obu_has_size_field == 0`. The walker stops at that OBU because
2613    /// it has no in-band length to advance past it. Per AV1 §5.3.1 +
2614    /// av1-avif §2.1, AV1 Image Item Data chains OBUs into one item
2615    /// payload and the `has_size` bit is required.
2616    pub has_size_field_zero: bool,
2617}
2618
2619impl SequenceHeaderObuAudit {
2620    /// Every field as a positional argument, in declaration order
2621    /// (the record is `#[non_exhaustive]`: build it here, or from
2622    /// `Default` where one exists, then read / assign the public
2623    /// fields).
2624    #[allow(clippy::too_many_arguments)]
2625    pub fn new(
2626        item_id: u32,
2627        sequence_header_count: u32,
2628        total_obu_count: u32,
2629        missing_iloc: bool,
2630        truncated_obu: bool,
2631        has_size_field_zero: bool,
2632    ) -> Self {
2633        Self {
2634            item_id,
2635            sequence_header_count,
2636            total_obu_count,
2637            missing_iloc,
2638            truncated_obu,
2639            has_size_field_zero,
2640        }
2641    }
2642}
2643
2644impl SequenceHeaderObuAudit {
2645    /// True when the walk succeeded structurally (`!missing_iloc &&
2646    /// !truncated_obu && !has_size_field_zero`) AND the OBU stream
2647    /// contains exactly one Sequence Header OBU.
2648    ///
2649    /// Spec: av1-avif v1.2.0 §2.1.
2650    pub fn is_compliant(&self) -> bool {
2651        !self.missing_iloc
2652            && !self.truncated_obu
2653            && !self.has_size_field_zero
2654            && self.sequence_header_count == 1
2655    }
2656
2657    /// Human-readable list of `shall`-level failures. Empty when
2658    /// [`Self::is_compliant`].
2659    pub fn missing(&self) -> Vec<&'static str> {
2660        let mut out = Vec::new();
2661        if self.missing_iloc {
2662            out.push("av01-item-missing-iloc");
2663        }
2664        if self.has_size_field_zero {
2665            out.push("av01-item-obu-has-size-field-zero");
2666        }
2667        if self.truncated_obu {
2668            out.push("av01-item-obu-stream-truncated");
2669        }
2670        if !self.missing_iloc && !self.has_size_field_zero && !self.truncated_obu {
2671            match self.sequence_header_count {
2672                0 => out.push("av01-item-missing-sequence-header-obu"),
2673                1 => {} // compliant
2674                _ => out.push("av01-item-multiple-sequence-header-obus"),
2675            }
2676        }
2677        out
2678    }
2679}
2680
2681/// `obu_type` value for `OBU_SEQUENCE_HEADER` per AV1 §6.2.1.
2682const AV1_OBU_TYPE_SEQUENCE_HEADER: u8 = 1;
2683
2684/// Decode an AV1 leb128 `obu_size` value (AV1 §4.10.5) from `bytes`.
2685///
2686/// Returns the decoded value and the number of bytes consumed
2687/// (`Leb128Bytes`, 1..=8). Returns `Err` when the bitstream ends in
2688/// the middle of a leb128 byte sequence (continuation bit set on the
2689/// last available byte) or when the leb128 sequence exceeds 8 bytes
2690/// without terminating.
2691fn read_leb128(bytes: &[u8]) -> core::result::Result<(u32, usize), ()> {
2692    let mut value: u64 = 0;
2693    for i in 0..8 {
2694        if i >= bytes.len() {
2695            return Err(());
2696        }
2697        let byte = bytes[i];
2698        value |= u64::from(byte & 0x7f) << (i * 7);
2699        if byte & 0x80 == 0 {
2700            // §4.10.5 also requires value <= (1 << 32) - 1 for
2701            // bitstream conformance; we surface a u32 so any wider
2702            // claim trips the conversion.
2703            if value > u64::from(u32::MAX) {
2704                return Err(());
2705            }
2706            return Ok((value as u32, i + 1));
2707        }
2708    }
2709    Err(())
2710}
2711
2712/// Walk the OBU framing of one AV1 Image Item's payload and count
2713/// Sequence Header OBUs. `payload` is the raw item bytes resolved via
2714/// the iloc.
2715///
2716/// Per AV1 §5.3.1 / §5.3.2:
2717///
2718/// * OBU header byte: `obu_forbidden_bit(1) | obu_type(4) |
2719///   obu_extension_flag(1) | obu_has_size_field(1) | obu_reserved_1bit(1)`.
2720/// * If `obu_extension_flag == 1`, one extension header byte follows
2721///   (§5.3.3 — temporal_id + spatial_id + reserved, total 8 bits).
2722/// * If `obu_has_size_field == 1`, a `leb128()` `obu_size` follows;
2723///   that many bytes are the OBU payload.
2724/// * If `obu_has_size_field == 0` we cannot frame the next OBU — the
2725///   walker stops and reports [`SequenceHeaderObuAudit::has_size_field_zero`].
2726fn walk_obu_stream(item_id: u32, payload: &[u8]) -> SequenceHeaderObuAudit {
2727    let mut out = SequenceHeaderObuAudit {
2728        item_id,
2729        ..SequenceHeaderObuAudit::default()
2730    };
2731    let mut cursor = 0usize;
2732    while cursor < payload.len() {
2733        // 1. OBU header byte.
2734        let header = payload[cursor];
2735        cursor += 1;
2736        let obu_type = (header >> 3) & 0x0f;
2737        let obu_extension_flag = (header >> 2) & 0x01;
2738        let obu_has_size_field = (header >> 1) & 0x01;
2739        // 2. Optional extension header.
2740        if obu_extension_flag == 1 {
2741            if cursor >= payload.len() {
2742                out.truncated_obu = true;
2743                return out;
2744            }
2745            cursor += 1;
2746        }
2747        // 3. Size field. AV1 §5.3.1 — when chained in a container that
2748        // doesn't externally frame each OBU (which is exactly the
2749        // AVIF Image Item Data case per av1-avif §2.1), this MUST be
2750        // set or we cannot find the next OBU.
2751        if obu_has_size_field == 0 {
2752            out.has_size_field_zero = true;
2753            // Still count this OBU's type — the header byte was
2754            // successfully read.
2755            if obu_type == AV1_OBU_TYPE_SEQUENCE_HEADER {
2756                out.sequence_header_count = out.sequence_header_count.saturating_add(1);
2757            }
2758            out.total_obu_count = out.total_obu_count.saturating_add(1);
2759            return out;
2760        }
2761        let (obu_size, leb_len) = match read_leb128(&payload[cursor..]) {
2762            Ok(v) => v,
2763            Err(()) => {
2764                out.truncated_obu = true;
2765                return out;
2766            }
2767        };
2768        cursor += leb_len;
2769        let obu_size = obu_size as usize;
2770        if cursor
2771            .checked_add(obu_size)
2772            .map(|end| end > payload.len())
2773            .unwrap_or(true)
2774        {
2775            out.truncated_obu = true;
2776            // The header byte itself was readable, count it before
2777            // bailing — this lets a caller see whether the truncation
2778            // dropped a Sequence Header.
2779            if obu_type == AV1_OBU_TYPE_SEQUENCE_HEADER {
2780                out.sequence_header_count = out.sequence_header_count.saturating_add(1);
2781            }
2782            out.total_obu_count = out.total_obu_count.saturating_add(1);
2783            return out;
2784        }
2785        cursor += obu_size;
2786        if obu_type == AV1_OBU_TYPE_SEQUENCE_HEADER {
2787            out.sequence_header_count = out.sequence_header_count.saturating_add(1);
2788        }
2789        out.total_obu_count = out.total_obu_count.saturating_add(1);
2790    }
2791    out
2792}
2793
2794/// Audit every AV1 Image Item (`item_type == 'av01'`) in `meta`
2795/// against the av1-avif v1.2.0 §2.1 `shall` "The AV1 Image Item Data
2796/// shall have exactly one Sequence Header OBU."
2797///
2798/// The function walks each item's payload bytes (resolved via the
2799/// item's `iloc` entry against `file`) and counts OBUs whose header
2800/// byte decodes to `obu_type == OBU_SEQUENCE_HEADER` (value `1`, per
2801/// AV1 §6.2.1). Items are reported in the same order as
2802/// `Meta::item_ids_of_type(b"av01")`.
2803///
2804/// The returned vector is empty when the file ships no AV1 Image
2805/// Items (a degenerate case, since an AVIF primary is required to
2806/// have at least one).
2807///
2808/// Spec sources:
2809/// * av1-avif v1.2.0 §2.1 — the `shall` audited.
2810/// * AV1 (Bitstream & Decoding Process Specification v1.0.0-errata1)
2811///   §5.3.1 (general OBU framing), §5.3.2 (OBU header byte layout),
2812///   §4.10.5 (`leb128()` decoder), §6.2.1 (`obu_type` enumeration).
2813pub fn audit_sequence_header_obu(
2814    meta: &crate::meta::Meta,
2815    file: &[u8],
2816) -> Vec<SequenceHeaderObuAudit> {
2817    let av01 = b(b"av01");
2818    let mut out = Vec::new();
2819    for item_id in meta.item_ids_of_type(&av01) {
2820        let Some(loc) = meta.location_by_id(item_id) else {
2821            out.push(SequenceHeaderObuAudit {
2822                item_id,
2823                missing_iloc: true,
2824                ..SequenceHeaderObuAudit::default()
2825            });
2826            continue;
2827        };
2828        // Reuse the standard iloc resolver. A failure here (e.g.
2829        // construction_method we don't support, or extent offsets
2830        // outside `file`) is surfaced the same way as missing iloc —
2831        // the audit can't reach the bytes either way.
2832        let Ok(payload) = crate::parser::item_bytes(file, loc) else {
2833            out.push(SequenceHeaderObuAudit {
2834                item_id,
2835                missing_iloc: true,
2836                ..SequenceHeaderObuAudit::default()
2837            });
2838            continue;
2839        };
2840        out.push(walk_obu_stream(item_id, payload));
2841    }
2842    out
2843}
2844
2845// ===========================================================================
2846// AVIF Profile compliance — av1-avif v1.2.0 §8.2 / §8.3 (MA1B / MA1A)
2847// ===========================================================================
2848
2849/// AV1 `seq_level_idx_0` decoded from `av1C[1]` low 5 bits, or `None`
2850/// when `av1c` is too short to carry byte 1. av1-isobmff §2.3 packs
2851/// `seq_profile (3) | seq_level_idx_0 (5)` into byte 1.
2852///
2853/// Crate-visible so the AVIS audit ([`crate::avis::audit_avis_profile_compliance`])
2854/// can decode the per-track av1C surfaced via `stsd → av01 → av1C`.
2855pub(crate) fn decode_av1c_seq_level_idx_0(av1c: &[u8]) -> Option<u8> {
2856    if av1c.len() < 2 {
2857        return None;
2858    }
2859    Some(av1c[1] & 0x1F)
2860}
2861
2862/// AV1 `seq_profile` decoded from `av1C[1]` high 3 bits, or `None`
2863/// when `av1c` is too short. Per AV1 Annex A.2: `0` = Main,
2864/// `1` = High, `2` = Professional.
2865///
2866/// Crate-visible so the AVIS audit ([`crate::avis::audit_avis_profile_compliance`])
2867/// can decode the per-track av1C surfaced via `stsd → av01 → av1C`.
2868pub(crate) fn decode_av1c_seq_profile(av1c: &[u8]) -> Option<u8> {
2869    if av1c.len() < 2 {
2870        return None;
2871    }
2872    Some((av1c[1] >> 5) & 0x7)
2873}
2874
2875/// Which AVIF profile brand a record was audited against — see
2876/// [`AvifProfileCompliance::profile`].
2877#[non_exhaustive]
2878#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2879pub enum AvifProfile {
2880    /// `MA1B` — AVIF Baseline Profile (av1-avif v1.2.0 §8.2). Requires
2881    /// AV1 Main Profile (`seq_profile == 0`) at level 5.1 or lower
2882    /// (`seq_level_idx_0 <= 13`).
2883    Baseline,
2884    /// `MA1A` — AVIF Advanced Profile (av1-avif v1.2.0 §8.3). For AV1
2885    /// Image Items requires AV1 High Profile (`seq_profile <= 1`) at
2886    /// level 6.0 or lower (`seq_level_idx_0 <= 16`).
2887    Advanced,
2888}
2889
2890/// Per-AV1-Image-Item compliance against the av1-avif v1.2.0 §8.2 /
2891/// §8.3 profile `shall`-level constraints when the file's `ftyp`
2892/// declares the corresponding brand.
2893///
2894/// One record is emitted by [`audit_avif_profile_compliance`] per
2895/// `(AV1 Image Item, declared profile)` pairing — when a file
2896/// declares both `MA1B` and `MA1A` brands (unusual but legal per §8.1
2897/// since both are independent profile claims), each AV1 Image Item
2898/// emits one record per declared brand.
2899///
2900/// The fields:
2901///
2902/// * [`Self::profile`] — which AVIF profile this record is checking.
2903/// * [`Self::item_id`] — the AV1 Image Item's id.
2904/// * [`Self::seq_profile`] — the AV1 `seq_profile` value decoded from
2905///   the item's `av1C[1]` high 3 bits (`0` = Main, `1` = High,
2906///   `2` = Professional). `None` when `av1C` is absent or truncated.
2907/// * [`Self::seq_level_idx_0`] — the AV1 `seq_level_idx_0` value
2908///   decoded from `av1C[1]` low 5 bits (`13` = level 5.1,
2909///   `16` = level 6.0, `31` = unconstrained per AV1 §A.3). `None` when
2910///   `av1C` is absent or truncated.
2911/// * [`Self::missing_av1c`] — `true` when the item carries no `av1C`
2912///   association at all (also a §2.1 violation; surfaced here so a
2913///   single point of inspection covers both).
2914///
2915/// The check passes ([`Self::is_compliant`]) when the `av1C` is
2916/// present and the (seq_profile, seq_level_idx_0) pair satisfies the
2917/// declared profile's constraints. The seq_level_idx_0 = 31 "Maximum
2918/// parameters" value is treated as not-compliant for either profile:
2919/// the spec's profile clauses both bound the level (5.1 / 6.0), and
2920/// the level-31 carve-out signals unconstrained sizing, which is
2921/// outside either profile's reach.
2922#[non_exhaustive]
2923#[derive(Clone, Debug, PartialEq, Eq)]
2924pub struct AvifProfileCompliance {
2925    /// The AVIF profile this record is checking the item against.
2926    pub profile: AvifProfile,
2927    /// The AV1 Image Item id whose `av1C` was inspected.
2928    pub item_id: u32,
2929    /// `seq_profile` decoded from `av1C[1]` high 3 bits, or `None`
2930    /// when `av1C` is absent or truncated.
2931    pub seq_profile: Option<u8>,
2932    /// `seq_level_idx_0` decoded from `av1C[1]` low 5 bits, or `None`
2933    /// when `av1C` is absent or truncated.
2934    pub seq_level_idx_0: Option<u8>,
2935    /// `true` when the item has no `av1C` property association at all
2936    /// (distinct from a present-but-truncated `av1C`, which surfaces
2937    /// as both fields `None` without setting this flag).
2938    pub missing_av1c: bool,
2939}
2940
2941impl AvifProfileCompliance {
2942    /// Every field as a positional argument, in declaration order
2943    /// (the record is `#[non_exhaustive]`: build it here, or from
2944    /// `Default` where one exists, then read / assign the public
2945    /// fields).
2946    #[allow(clippy::too_many_arguments)]
2947    pub fn new(
2948        profile: AvifProfile,
2949        item_id: u32,
2950        seq_profile: Option<u8>,
2951        seq_level_idx_0: Option<u8>,
2952        missing_av1c: bool,
2953    ) -> Self {
2954        Self {
2955            profile,
2956            item_id,
2957            seq_profile,
2958            seq_level_idx_0,
2959            missing_av1c,
2960        }
2961    }
2962}
2963
2964impl AvifProfileCompliance {
2965    /// True when the AV1 Image Item satisfies the declared AVIF
2966    /// profile's `shall`-level constraints on AV1 profile + level.
2967    ///
2968    /// Baseline (`MA1B`, §8.2): `seq_profile == 0` (AV1 Main) AND
2969    /// `seq_level_idx_0 <= 13` (level ≤ 5.1).
2970    ///
2971    /// Advanced (`MA1A`, §8.3): `seq_profile <= 1` (AV1 Main or
2972    /// High) AND `seq_level_idx_0 <= 16` (level ≤ 6.0). The §8.3
2973    /// `shall` is "The AV1 profile shall be the High Profile" — the
2974    /// AV1 Annex A.2 definition of "High Profile decoders" includes
2975    /// streams with `seq_profile == 0` (Main is a subset of High),
2976    /// so a Main-Profile stream is also accepted under `MA1A`.
2977    ///
2978    /// Returns `false` when `av1C` is missing or truncated.
2979    pub fn is_compliant(&self) -> bool {
2980        match (self.seq_profile, self.seq_level_idx_0) {
2981            (Some(p), Some(l)) => match self.profile {
2982                AvifProfile::Baseline => p == 0 && l <= 13,
2983                AvifProfile::Advanced => p <= 1 && l <= 16,
2984            },
2985            _ => false,
2986        }
2987    }
2988
2989    /// Human-readable list of failed `shall`s. Returns an empty vector
2990    /// when [`Self::is_compliant`]. Tokens:
2991    /// `item-missing-av1C`, `item-av1C-truncated`,
2992    /// `seq-profile-out-of-range`, `seq-level-idx-out-of-range`.
2993    pub fn missing(&self) -> Vec<&'static str> {
2994        let mut out = Vec::new();
2995        if self.missing_av1c {
2996            out.push("item-missing-av1C");
2997            return out;
2998        }
2999        if self.seq_profile.is_none() || self.seq_level_idx_0.is_none() {
3000            out.push("item-av1C-truncated");
3001            return out;
3002        }
3003        let p = self.seq_profile.unwrap();
3004        let l = self.seq_level_idx_0.unwrap();
3005        let (max_p, max_l) = match self.profile {
3006            AvifProfile::Baseline => (0u8, 13u8),
3007            AvifProfile::Advanced => (1u8, 16u8),
3008        };
3009        if p > max_p {
3010            out.push("seq-profile-out-of-range");
3011        }
3012        if l > max_l {
3013            out.push("seq-level-idx-out-of-range");
3014        }
3015        out
3016    }
3017}
3018
3019/// Audit every AV1 Image Item in `meta` against the av1-avif v1.2.0
3020/// §8.2 / §8.3 profile `shall`-level constraints, but only when the
3021/// file's `ftyp` declares the corresponding brand. Items are reported
3022/// in `iinf` declaration order; for files declaring both `MA1B` and
3023/// `MA1A`, each item emits one record per declared brand (Baseline
3024/// before Advanced).
3025///
3026/// The audit is independent of any AV1 OBU decode — it operates
3027/// entirely on `av1C[1]` (which packs `seq_profile (3) |
3028/// seq_level_idx_0 (5)` per av1-isobmff §2.3). For grid primaries the
3029/// constraint is on the tile items, not the grid item itself — `'grid'`
3030/// items carry no `av1C` and are skipped here (the per-tile records
3031/// give the same coverage, since av1-avif §4.2 keeps the AV1 profile +
3032/// level uniform across grid tiles).
3033///
3034/// The returned vector is empty when (a) the file ships no AV1 Image
3035/// Items, or (b) the file declares neither `MA1B` nor `MA1A` in its
3036/// `ftyp` compatible-brands list. The second case is a deliberate
3037/// no-op: a file that doesn't claim a profile has nothing to fail.
3038///
3039/// Spec sources:
3040/// * av1-avif v1.2.0 §8.2 — `MA1B` Baseline Profile constraints.
3041/// * av1-avif v1.2.0 §8.3 — `MA1A` Advanced Profile constraints.
3042/// * AV1 §A.2 — Profiles (Main / High / Professional).
3043/// * AV1 §A.3 — Levels (seq_level_idx_0 ↔ X.Y mapping; 13 = 5.1,
3044///   16 = 6.0, 31 = unconstrained).
3045/// * av1-isobmff §2.3 — `av1C` byte layout.
3046pub fn audit_avif_profile_compliance(
3047    meta: &crate::meta::Meta,
3048    brands: &crate::parser::BrandClass,
3049) -> Vec<AvifProfileCompliance> {
3050    let av01 = b(b"av01");
3051    let av1c_kind = b(b"av1C");
3052    let mut out = Vec::new();
3053    if !brands.is_baseline_profile && !brands.is_advanced_profile {
3054        return out;
3055    }
3056    for item_id in meta.item_ids_of_type(&av01) {
3057        let (seq_profile, seq_level_idx_0, missing_av1c) =
3058            match meta.property_for(item_id, &av1c_kind) {
3059                Some(crate::meta::Property::Av1C(bytes)) => (
3060                    decode_av1c_seq_profile(bytes),
3061                    decode_av1c_seq_level_idx_0(bytes),
3062                    false,
3063                ),
3064                _ => (None, None, true),
3065            };
3066        if brands.is_baseline_profile {
3067            out.push(AvifProfileCompliance {
3068                profile: AvifProfile::Baseline,
3069                item_id,
3070                seq_profile,
3071                seq_level_idx_0,
3072                missing_av1c,
3073            });
3074        }
3075        if brands.is_advanced_profile {
3076            out.push(AvifProfileCompliance {
3077                profile: AvifProfile::Advanced,
3078                item_id,
3079                seq_profile,
3080                seq_level_idx_0,
3081                missing_av1c,
3082            });
3083        }
3084    }
3085    out
3086}
3087
3088// ===========================================================================
3089// Derived-image geometry resolution (HEIF §6.3 / §6.6.2)
3090// ===========================================================================
3091//
3092// §6.3 defines the *output image* of any image item as the result of
3093// applying its transformative item properties — in ItemPropertyAssociation
3094// order — to the item's *reconstructed image*. For a coded item the
3095// reconstructed image is the decoded picture (`ispe` dimensions); for a
3096// derived item it is the result of the derivation operation (the
3097// descriptor's `output_width`/`output_height` for `grid` / `iovl`, or the
3098// single input's output image for `iden`).
3099//
3100// The helpers below resolve this geometry **without decoding any AV1
3101// bitstream** — they walk only the box-level item-property graph. They are
3102// the dimension half of derived-image evaluation: a caller that later wires
3103// in a real AV1 decoder uses them to size the output canvas, place overlay
3104// inputs, and crop/rotate identity derivations, and to validate that the
3105// declared geometry is self-consistent before any pixels exist.
3106
3107/// A transformative item property that changes (or preserves) the pixel
3108/// dimensions of the image it is applied to, in the order it appears in the
3109/// item's `ipma` association. Descriptive properties (`ispe`, `colr`,
3110/// `pixi`, …) do not appear here — only the four transformative properties
3111/// HEIF defines that affect output geometry.
3112///
3113/// Spec: ISO/IEC 23008-12 §6.5.10 (`irot`), §6.5.12 (`imir`), §6.5.8 /
3114/// §6.5.9 (`clap`), §6.5.13 (`iscl`).
3115#[doc(hidden)]
3116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3117pub enum DimTransform {
3118    /// Counter-clockwise rotation, `angle` × 90°. 90°/270° swap width and
3119    /// height; 0°/180° preserve them.
3120    Rotate { angle: u8 },
3121    /// Mirror about an axis. Does not change dimensions.
3122    Mirror { axis: u8 },
3123    /// Clean-aperture crop. The output dimensions are the clean-aperture
3124    /// width/height rounded to nearest integer (`§6.5.9` rationals), or the
3125    /// input unchanged when the crop falls outside the input rectangle.
3126    Crop {
3127        width_n: i32,
3128        width_d: i32,
3129        height_n: i32,
3130        height_d: i32,
3131    },
3132    /// Rational scale per §6.5.13: `ceil(input * num / den)` independently
3133    /// on each axis.
3134    Scale {
3135        target_width_numerator: u16,
3136        target_width_denominator: u16,
3137        target_height_numerator: u16,
3138        target_height_denominator: u16,
3139    },
3140}
3141
3142impl DimTransform {
3143    /// Apply this transform's effect on dimensions to `(w, h)`. Returns the
3144    /// post-transform `(width, height)`. Defensive: a degenerate crop or a
3145    /// zero-denominator scale leaves the dimensions unchanged (mirroring the
3146    /// no-op fall-throughs in [`crate::transform`]).
3147    pub fn apply_dims(&self, w: u32, h: u32) -> (u32, u32) {
3148        match *self {
3149            DimTransform::Rotate { angle } => {
3150                if angle % 2 == 1 {
3151                    (h, w)
3152                } else {
3153                    (w, h)
3154                }
3155            }
3156            DimTransform::Mirror { .. } => (w, h),
3157            DimTransform::Crop {
3158                width_n,
3159                width_d,
3160                height_n,
3161                height_d,
3162            } => {
3163                if width_d == 0 || height_d == 0 {
3164                    return (w, h);
3165                }
3166                // §6.5.9: clean-aperture width/height are signed rationals;
3167                // round to nearest integer (same rounding as
3168                // `transform::apply_clap`).
3169                let cw = (width_n as i64 + width_d as i64 / 2) / width_d as i64;
3170                let ch = (height_n as i64 + height_d as i64 / 2) / height_d as i64;
3171                if cw <= 0 || ch <= 0 || cw > w as i64 || ch > h as i64 {
3172                    return (w, h);
3173                }
3174                (cw as u32, ch as u32)
3175            }
3176            DimTransform::Scale {
3177                target_width_numerator,
3178                target_width_denominator,
3179                target_height_numerator,
3180                target_height_denominator,
3181            } => {
3182                let iscl = crate::meta::Iscl {
3183                    target_width_numerator,
3184                    target_width_denominator,
3185                    target_height_numerator,
3186                    target_height_denominator,
3187                };
3188                iscl.scaled_dims(w, h).unwrap_or((w, h))
3189            }
3190        }
3191    }
3192}
3193
3194/// The ordered list of dimension-affecting transformative item properties
3195/// associated with `item_id`, in `ipma` association order. Non-transformative
3196/// properties are skipped. An item with no transformative properties yields
3197/// an empty vector (its output image equals its reconstructed image — §6.3).
3198///
3199/// Per §6.5.11, a `lsel` (layer selector) — when present — precedes every
3200/// transformative property in the association order; it carries no geometry
3201/// effect and so does not appear in the returned list, but the relative
3202/// order of the transformative properties that follow it is preserved.
3203pub fn transform_chain(meta: &crate::meta::Meta, item_id: u32) -> Vec<DimTransform> {
3204    use crate::meta::Property;
3205    let Some(assoc) = meta.assoc_by_id(item_id) else {
3206        return Vec::new();
3207    };
3208    let mut out = Vec::new();
3209    for pa in &assoc.entries {
3210        let Some(prop) = meta.properties.get(pa.index as usize) else {
3211            continue;
3212        };
3213        match prop {
3214            Property::Irot(r) => out.push(DimTransform::Rotate { angle: r.angle }),
3215            Property::Imir(m) => out.push(DimTransform::Mirror { axis: m.axis }),
3216            Property::Clap(c) => out.push(DimTransform::Crop {
3217                width_n: c.clean_aperture_width_n,
3218                width_d: c.clean_aperture_width_d,
3219                height_n: c.clean_aperture_height_n,
3220                height_d: c.clean_aperture_height_d,
3221            }),
3222            Property::Iscl(s) => out.push(DimTransform::Scale {
3223                target_width_numerator: s.target_width_numerator,
3224                target_width_denominator: s.target_width_denominator,
3225                target_height_numerator: s.target_height_numerator,
3226                target_height_denominator: s.target_height_denominator,
3227            }),
3228            _ => {}
3229        }
3230    }
3231    out
3232}
3233
3234/// Apply an item's transform chain to a reconstructed `(width, height)`,
3235/// returning the **output-image** dimensions per HEIF §6.3. Equivalent to
3236/// folding [`DimTransform::apply_dims`] over [`transform_chain`].
3237pub fn output_dims_from_reconstructed(
3238    meta: &crate::meta::Meta,
3239    item_id: u32,
3240    reconstructed_w: u32,
3241    reconstructed_h: u32,
3242) -> (u32, u32) {
3243    let mut dims = (reconstructed_w, reconstructed_h);
3244    for t in transform_chain(meta, item_id) {
3245        dims = t.apply_dims(dims.0, dims.1);
3246    }
3247    dims
3248}
3249
3250/// Resolve the **reconstructed-image** dimensions of an item from the
3251/// box-level graph alone (no AV1 decode):
3252///
3253/// * a `grid` item → its descriptor `output_width`/`output_height`;
3254/// * an `iovl` item → its descriptor `output_width`/`output_height`;
3255/// * an `iden` item → the output dimensions of its single `dimg` input
3256///   (recursively resolved);
3257/// * any other item (coded `av01`, etc.) → its `ispe` dimensions.
3258///
3259/// Returns `None` when the dimensions cannot be determined: a coded item
3260/// without an `ispe`, a `grid`/`iovl` whose descriptor payload can't be
3261/// resolved or parsed, an `iden` without exactly one input, or a derivation
3262/// chain that exceeds [`MAX_DERIVATION_DEPTH`] (cycle guard). `idat`/`mdat`
3263/// resolution of the `grid`/`iovl` descriptor bytes uses `file_bytes` and
3264/// the optional `idat` slice via the standard `iloc` resolver.
3265pub fn reconstructed_dims(
3266    meta: &crate::meta::Meta,
3267    item_id: u32,
3268    file_bytes: &[u8],
3269    idat: Option<&[u8]>,
3270) -> Option<(u32, u32)> {
3271    reconstructed_dims_inner(meta, item_id, file_bytes, idat, 0)
3272}
3273
3274/// Resolve a derived-item descriptor's payload bytes (`grid` / `iovl` body)
3275/// from its `iloc` entry, handling all three `construction_method`s:
3276/// `0` (file-offset, the bytes live in `mdat`/elsewhere in `file_bytes`),
3277/// `1` (idat-offset, the bytes live in the `meta` box's `idat` and are
3278/// passed in via `idat`), and `2` (item-offset, the bytes are a range of
3279/// another item's data named via the `'iloc'` item reference — resolved
3280/// through [`crate::parser::item_bytes_owned_full`], which follows the iref
3281/// and recurses through cm=0/cm=1 origin items). Returns `None` when the
3282/// item has no `iloc`, when an idat extent is referenced without an `idat`
3283/// slice, when a cm=2 chain is malformed/cyclic, or when any extent runs
3284/// past the backing buffer.
3285///
3286/// Spec: ISO/IEC 14496-12 §8.11.3 (`iloc` construction methods); HEIF
3287/// §6.6.2 derived-item payloads conventionally use `idat` (cm=1).
3288fn resolve_descriptor_bytes(
3289    meta: &crate::meta::Meta,
3290    item_id: u32,
3291    file_bytes: &[u8],
3292    idat: Option<&[u8]>,
3293) -> Option<Vec<u8>> {
3294    let loc = meta.location_by_id(item_id)?;
3295    let backing: &[u8] = match loc.construction_method {
3296        0 => file_bytes,
3297        1 => idat?,
3298        // construction_method 2 (item-offset): the descriptor bytes are a
3299        // range of another item's data, named via the `'iloc'` item
3300        // reference (ISO/IEC 14496-12 §8.11.3.3). Delegate to the cm=2-aware
3301        // resolver, which consults the whole `Meta` to follow the iref and
3302        // recurse through cm=0/cm=1 origin items. A grid / iovl descriptor
3303        // stored this way (uncommon but spec-legal) now resolves.
3304        2 => return crate::parser::item_bytes_owned_full(file_bytes, meta, item_id).ok(),
3305        _ => return None,
3306    };
3307    if loc.extents.is_empty() {
3308        return None;
3309    }
3310    let mut out = Vec::new();
3311    for e in &loc.extents {
3312        let start = loc.base_offset.checked_add(e.offset)?;
3313        let end = start.checked_add(e.length)?;
3314        let (start, end) = (usize::try_from(start).ok()?, usize::try_from(end).ok()?);
3315        if end > backing.len() || start > end {
3316            return None;
3317        }
3318        out.extend_from_slice(&backing[start..end]);
3319    }
3320    Some(out)
3321}
3322
3323/// Maximum derivation-chain recursion depth before [`reconstructed_dims`]
3324/// gives up. A well-formed file nests at most a handful of derivations; a
3325/// deeper chain is almost certainly a `dimg` cycle, which this bound breaks
3326/// without unbounded recursion.
3327pub const MAX_DERIVATION_DEPTH: u32 = 16;
3328
3329fn reconstructed_dims_inner(
3330    meta: &crate::meta::Meta,
3331    item_id: u32,
3332    file_bytes: &[u8],
3333    idat: Option<&[u8]>,
3334    depth: u32,
3335) -> Option<(u32, u32)> {
3336    if depth > MAX_DERIVATION_DEPTH {
3337        return None;
3338    }
3339    let item = meta.item_by_id(item_id)?;
3340    let grid = b(b"grid");
3341    let dimg = b(b"dimg");
3342    if item.item_type == grid {
3343        let bytes = resolve_descriptor_bytes(meta, item_id, file_bytes, idat)?;
3344        let g = crate::grid::ImageGrid::parse(&bytes).ok()?;
3345        return Some((g.output_width, g.output_height));
3346    }
3347    if item.item_type == crate::meta::ITEM_TYPE_IOVL {
3348        let bytes = resolve_descriptor_bytes(meta, item_id, file_bytes, idat)?;
3349        let refs = meta.iref_targets(&dimg, item_id).len();
3350        let o = ImageOverlay::parse(&bytes, refs).ok()?;
3351        return Some((o.output_width, o.output_height));
3352    }
3353    if item.item_type == crate::meta::ITEM_TYPE_IDEN {
3354        let inputs = meta.iref_targets(&dimg, item_id);
3355        if inputs.len() != 1 {
3356            return None;
3357        }
3358        // §6.6.2.1 + §6.3: the iden's reconstructed image is the *output
3359        // image* of its single input.
3360        let (iw, ih) = reconstructed_dims_inner(meta, inputs[0], file_bytes, idat, depth + 1)?;
3361        return Some(output_dims_from_reconstructed(meta, inputs[0], iw, ih));
3362    }
3363    if item.item_type == crate::meta::ITEM_TYPE_TMAP {
3364        // av1-avif §4.2.2 (tone-map derived image item): the rendered image
3365        // has the spatial extents of the *base* image input. The base image
3366        // is input `0` of the tmap's `dimg` iref; the remaining inputs are
3367        // gain map(s) which may be coded at a lower resolution and are
3368        // up-sampled to the base extents during tone mapping. The tmap's
3369        // reconstructed image therefore takes the base input's output
3370        // dimensions.
3371        let inputs = meta.iref_targets(&dimg, item_id);
3372        let base = *inputs.first()?;
3373        let (iw, ih) = reconstructed_dims_inner(meta, base, file_bytes, idat, depth + 1)?;
3374        return Some(output_dims_from_reconstructed(meta, base, iw, ih));
3375    }
3376    if item.item_type == crate::meta::ITEM_TYPE_SATO {
3377        // av1-avif §4.2.3.1: the Sample Transform Derived Image Item and all
3378        // of its inputs "shall be associated with … an 'ispe' property" and
3379        // "have the same dimensions as defined by the 'ispe' property". So a
3380        // well-formed sato carries its own `ispe` (handled by the coded-item
3381        // fall-through below). When it does not, fall back to its first
3382        // input's reconstructed dimensions — every input shares the sato's
3383        // extents, so input 0 is representative.
3384        if let Some(crate::meta::Property::Ispe(ispe)) = meta.property_for(item_id, &b(b"ispe")) {
3385            return Some((ispe.width, ispe.height));
3386        }
3387        let inputs = meta.iref_targets(&dimg, item_id);
3388        let base = *inputs.first()?;
3389        let (iw, ih) = reconstructed_dims_inner(meta, base, file_bytes, idat, depth + 1)?;
3390        return Some(output_dims_from_reconstructed(meta, base, iw, ih));
3391    }
3392    // Coded item: reconstructed dimensions are the `ispe` extents.
3393    match meta.property_for(item_id, &b(b"ispe")) {
3394        Some(crate::meta::Property::Ispe(ispe)) => Some((ispe.width, ispe.height)),
3395        _ => None,
3396    }
3397}
3398
3399/// Where one input image of an `iovl` overlay lands on the canvas, and what
3400/// portion of it is visible after clipping. All coordinates are canvas-space
3401/// pixels with the origin at the top-left corner (§6.6.2.2.3).
3402///
3403/// The input image occupies `[offset_x, offset_x + input_width)` ×
3404/// `[offset_y, offset_y + input_height)`. Per §6.6.2.2.3 a pixel is included
3405/// in the reconstructed image only when its canvas coordinate is in
3406/// `[0, output_width)` × `[0, output_height)`; the [`visible`](Self::visible)
3407/// rectangle is that intersection, expressed back in canvas coordinates.
3408#[doc(hidden)]
3409#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3410pub struct OverlayPlacement {
3411    /// The source image item id (from the parallel `dimg` iref `to_ids`).
3412    pub source_item_id: u32,
3413    /// Declared horizontal offset of the input's left column on the canvas.
3414    pub offset_x: i64,
3415    /// Declared vertical offset of the input's top row on the canvas.
3416    pub offset_y: i64,
3417    /// Input image output-image width (post its own transforms).
3418    pub input_width: u32,
3419    /// Input image output-image height.
3420    pub input_height: u32,
3421}
3422
3423impl OverlayPlacement {
3424    /// Every field as a positional argument, in declaration order
3425    /// (the record is `#[non_exhaustive]`: build it here, or from
3426    /// `Default` where one exists, then read / assign the public
3427    /// fields).
3428    #[allow(clippy::too_many_arguments)]
3429    pub fn new(
3430        source_item_id: u32,
3431        offset_x: i64,
3432        offset_y: i64,
3433        input_width: u32,
3434        input_height: u32,
3435    ) -> Self {
3436        Self {
3437            source_item_id,
3438            offset_x,
3439            offset_y,
3440            input_width,
3441            input_height,
3442        }
3443    }
3444}
3445
3446impl OverlayPlacement {
3447    /// The visible canvas rectangle as `(x, y, width, height)` after
3448    /// clipping the input against `[0, canvas_w) × [0, canvas_h)`. Returns
3449    /// `None` when the input lands entirely off-canvas (no visible pixels).
3450    pub fn visible(&self, canvas_w: u32, canvas_h: u32) -> Option<(u32, u32, u32, u32)> {
3451        let left = self.offset_x.max(0);
3452        let top = self.offset_y.max(0);
3453        let right = (self.offset_x + i64::from(self.input_width)).min(i64::from(canvas_w));
3454        let bottom = (self.offset_y + i64::from(self.input_height)).min(i64::from(canvas_h));
3455        if right <= left || bottom <= top {
3456            return None;
3457        }
3458        Some((
3459            left as u32,
3460            top as u32,
3461            (right - left) as u32,
3462            (bottom - top) as u32,
3463        ))
3464    }
3465
3466    /// True when the input image is wholly inside the canvas (no clipping
3467    /// occurs).
3468    pub fn fully_visible(&self, canvas_w: u32, canvas_h: u32) -> bool {
3469        self.offset_x >= 0
3470            && self.offset_y >= 0
3471            && self.offset_x + i64::from(self.input_width) <= i64::from(canvas_w)
3472            && self.offset_y + i64::from(self.input_height) <= i64::from(canvas_h)
3473    }
3474
3475    /// True when no pixel of the input lands on the canvas.
3476    pub fn off_canvas(&self, canvas_w: u32, canvas_h: u32) -> bool {
3477        self.visible(canvas_w, canvas_h).is_none()
3478    }
3479}
3480
3481/// A fully resolved `iovl` overlay derivation: the parsed descriptor plus
3482/// each input's resolved placement against the canvas (§6.6.2.2).
3483#[doc(hidden)]
3484#[derive(Clone, Debug, PartialEq, Eq)]
3485pub struct OverlayResolution {
3486    /// The `iovl` derived item id.
3487    pub iovl_item_id: u32,
3488    /// The parsed descriptor (canvas fill, output dimensions, offsets).
3489    pub descriptor: ImageOverlay,
3490    /// One placement per input, bottom-most first (layering order matches
3491    /// the `dimg` iref `to_ids` order, §6.6.2.2.1).
3492    pub placements: Vec<OverlayPlacement>,
3493}
3494
3495impl OverlayResolution {
3496    /// Every field as a positional argument, in declaration order
3497    /// (the record is `#[non_exhaustive]`: build it here, or from
3498    /// `Default` where one exists, then read / assign the public
3499    /// fields).
3500    #[allow(clippy::too_many_arguments)]
3501    pub fn new(
3502        iovl_item_id: u32,
3503        descriptor: ImageOverlay,
3504        placements: Vec<OverlayPlacement>,
3505    ) -> Self {
3506        Self {
3507            iovl_item_id,
3508            descriptor,
3509            placements,
3510        }
3511    }
3512}
3513
3514impl OverlayResolution {
3515    /// Canvas dimensions `(output_width, output_height)`.
3516    pub fn canvas(&self) -> (u32, u32) {
3517        (self.descriptor.output_width, self.descriptor.output_height)
3518    }
3519
3520    /// True when at least one canvas pixel is left to the fill colour — i.e.
3521    /// the union of every input's visible rectangle does not cover the whole
3522    /// canvas. Computed conservatively from a coverage scan bounded by the
3523    /// canvas area; for very large canvases (> 4M pixels) it returns `true`
3524    /// without scanning, since a partial fill is the common case and the
3525    /// exact answer isn't worth a multi-megabyte allocation.
3526    pub fn canvas_partially_filled(&self) -> bool {
3527        let (cw, ch) = self.canvas();
3528        let area = u64::from(cw) * u64::from(ch);
3529        if area == 0 {
3530            return false;
3531        }
3532        if area > 4_000_000 {
3533            return true;
3534        }
3535        let mut covered = vec![false; area as usize];
3536        for p in &self.placements {
3537            if let Some((x, y, w, h)) = p.visible(cw, ch) {
3538                for row in y..y + h {
3539                    let base = (u64::from(row) * u64::from(cw)) as usize;
3540                    for col in x..x + w {
3541                        covered[base + col as usize] = true;
3542                    }
3543                }
3544            }
3545        }
3546        covered.iter().any(|&c| !c)
3547    }
3548}
3549
3550/// Resolve every `iovl` overlay item in `meta` end-to-end: parse the
3551/// descriptor, pair each placement with its `dimg` source item, and resolve
3552/// each source's output dimensions from the box graph. Returns one
3553/// [`OverlayResolution`] per `iovl` item, in `iinf` declaration order;
3554/// `iovl` items whose descriptor can't be resolved/parsed are skipped.
3555///
3556/// Spec: ISO/IEC 23008-12 §6.6.2.2. `file_bytes` + the optional `idat`
3557/// slice resolve the descriptor payload via the standard `iloc` resolver;
3558/// each input's dimensions come from [`reconstructed_dims`] +
3559/// [`output_dims_from_reconstructed`] so inputs that are themselves grids,
3560/// overlays, or transformed coded items resolve correctly.
3561pub fn resolve_overlays(
3562    meta: &crate::meta::Meta,
3563    file_bytes: &[u8],
3564    idat: Option<&[u8]>,
3565) -> Vec<OverlayResolution> {
3566    let dimg = b(b"dimg");
3567    let mut out = Vec::new();
3568    for iovl_id in meta.item_ids_of_type(&crate::meta::ITEM_TYPE_IOVL) {
3569        let Some(bytes) = resolve_descriptor_bytes(meta, iovl_id, file_bytes, idat) else {
3570            continue;
3571        };
3572        let sources = meta.iref_targets(&dimg, iovl_id);
3573        let Ok(descriptor) = ImageOverlay::parse(&bytes, sources.len()) else {
3574            continue;
3575        };
3576        let mut placements = Vec::with_capacity(descriptor.entries.len());
3577        for (entry, &src) in descriptor.entries.iter().zip(sources.iter()) {
3578            let (iw, ih) = reconstructed_dims(meta, src, file_bytes, idat)
3579                .map(|(w, h)| output_dims_from_reconstructed(meta, src, w, h))
3580                .unwrap_or((0, 0));
3581            placements.push(OverlayPlacement {
3582                source_item_id: src,
3583                offset_x: i64::from(entry.horizontal_offset),
3584                offset_y: i64::from(entry.vertical_offset),
3585                input_width: iw,
3586                input_height: ih,
3587            });
3588        }
3589        out.push(OverlayResolution {
3590            iovl_item_id: iovl_id,
3591            descriptor,
3592            placements,
3593        });
3594    }
3595    out
3596}
3597
3598/// A fully resolved `iden` identity derivation: the single source item, its
3599/// reconstructed dimensions, the transform chain applied by the iden item
3600/// itself, and the resulting output dimensions (§6.6.2.1 + §6.3).
3601#[doc(hidden)]
3602#[derive(Clone, Debug, PartialEq, Eq)]
3603pub struct IdenResolution {
3604    /// The `iden` derived item id.
3605    pub iden_item_id: u32,
3606    /// The single `dimg` source item id (the image the identity derivation
3607    /// re-presents), or `None` when the iden does not have exactly one input.
3608    pub source_item_id: Option<u32>,
3609    /// The source's reconstructed dimensions, or `None` when unresolvable.
3610    pub source_dims: Option<(u32, u32)>,
3611    /// The dimension-affecting transformative properties on the **iden item
3612    /// itself**, in `ipma` order. These are the whole point of an identity
3613    /// derivation (§6.6.2.1 NOTE 2: e.g. a `clap` crop of the original).
3614    pub transforms: Vec<DimTransform>,
3615    /// The iden item's output dimensions after applying `transforms` to
3616    /// `source_dims`, or `None` when `source_dims` is `None`.
3617    pub output_dims: Option<(u32, u32)>,
3618}
3619
3620impl IdenResolution {
3621    /// Every field as a positional argument, in declaration order
3622    /// (the record is `#[non_exhaustive]`: build it here, or from
3623    /// `Default` where one exists, then read / assign the public
3624    /// fields).
3625    #[allow(clippy::too_many_arguments)]
3626    pub fn new(
3627        iden_item_id: u32,
3628        source_item_id: Option<u32>,
3629        source_dims: Option<(u32, u32)>,
3630        transforms: Vec<DimTransform>,
3631        output_dims: Option<(u32, u32)>,
3632    ) -> Self {
3633        Self {
3634            iden_item_id,
3635            source_item_id,
3636            source_dims,
3637            transforms,
3638            output_dims,
3639        }
3640    }
3641}
3642
3643/// Resolve every `iden` identity-derivation item in `meta` end-to-end:
3644/// find the single `dimg` source, resolve its reconstructed dimensions from
3645/// the box graph, collect the transformative properties on the iden item,
3646/// and compute the resulting output dimensions. Returns one
3647/// [`IdenResolution`] per `iden` item, in `iinf` declaration order.
3648///
3649/// Spec: ISO/IEC 23008-12 §6.6.2.1. The reconstructed image of an `iden`
3650/// item is the *output image* of its single input (§6.3); the iden's own
3651/// output image then applies the iden item's transformative properties.
3652/// Because the source's output dimensions already fold in *its* transforms,
3653/// `source_dims` here is the source's output image, and `output_dims` folds
3654/// the iden's transforms on top.
3655pub fn resolve_iden_derivations(
3656    meta: &crate::meta::Meta,
3657    file_bytes: &[u8],
3658    idat: Option<&[u8]>,
3659) -> Vec<IdenResolution> {
3660    let dimg = b(b"dimg");
3661    let mut out = Vec::new();
3662    for iden_id in meta.item_ids_of_type(&crate::meta::ITEM_TYPE_IDEN) {
3663        let inputs = meta.iref_targets(&dimg, iden_id);
3664        let source_item_id = if inputs.len() == 1 {
3665            Some(inputs[0])
3666        } else {
3667            None
3668        };
3669        let source_dims = source_item_id.and_then(|src| {
3670            reconstructed_dims(meta, src, file_bytes, idat)
3671                .map(|(w, h)| output_dims_from_reconstructed(meta, src, w, h))
3672        });
3673        let transforms = transform_chain(meta, iden_id);
3674        let output_dims = source_dims.map(|(w, h)| {
3675            let mut d = (w, h);
3676            for t in &transforms {
3677                d = t.apply_dims(d.0, d.1);
3678            }
3679            d
3680        });
3681        out.push(IdenResolution {
3682            iden_item_id: iden_id,
3683            source_item_id,
3684            source_dims,
3685            transforms,
3686            output_dims,
3687        });
3688    }
3689    out
3690}
3691
3692/// A fully resolved `tmap` tone-map derivation: the base image input, the
3693/// gain-map input(s), and the rendered (reconstructed) dimensions.
3694///
3695/// av1-avif §4.2.2 — a tone-map derived image item layers an additional
3696/// "gain map" image onto a base image so that a single file serves both an
3697/// SDR (the base) and an HDR (`base · 2^(W·gain)`) rendition. The rendered
3698/// image has the spatial extents of the **base** image; gain maps may be
3699/// coded at a lower resolution and are up-sampled. The structured byte-level
3700/// reconstruction lives in [`GainMapMetadata`] + its §6 application surface;
3701/// this record describes the derivation *geometry* — which item is the base,
3702/// which are gain maps, and the resulting output dimensions — without an AV1
3703/// decode, mirroring [`OverlayResolution`] / [`IdenResolution`].
3704#[doc(hidden)]
3705#[derive(Clone, Debug, PartialEq, Eq)]
3706pub struct ToneMapResolution {
3707    /// The `tmap` derived item id.
3708    pub tmap_item_id: u32,
3709    /// The base image item id (`dimg` input `0`), or `None` when the tmap
3710    /// has no `dimg` inputs at all.
3711    pub base_item_id: Option<u32>,
3712    /// Every gain-map image item id (`dimg` inputs `1..`). Empty when the
3713    /// tmap declares only a base input.
3714    pub gain_map_item_ids: Vec<u32>,
3715    /// The base image's reconstructed dimensions (its `ispe`, folding in any
3716    /// of its own derivations), or `None` when unresolvable. These are the
3717    /// rendered dimensions of the tone-mapped output.
3718    pub rendered_dims: Option<(u32, u32)>,
3719    /// Each gain map's reconstructed dimensions, paired with the gain-map
3720    /// item id, in `dimg` order: `(gain_map_item_id, dims)`. A gain map
3721    /// coded smaller than [`Self::rendered_dims`] is up-sampled during tone
3722    /// mapping; surfacing the coded extents lets callers report the
3723    /// up-sampling ratio. `dims` is `None` when the gain map's dimensions
3724    /// are unresolvable (missing `ispe`, malformed derivation, …).
3725    pub gain_map_dims: Vec<(u32, Option<(u32, u32)>)>,
3726}
3727
3728impl ToneMapResolution {
3729    /// Every field as a positional argument, in declaration order
3730    /// (the record is `#[non_exhaustive]`: build it here, or from
3731    /// `Default` where one exists, then read / assign the public
3732    /// fields).
3733    #[allow(clippy::too_many_arguments)]
3734    pub fn new(
3735        tmap_item_id: u32,
3736        base_item_id: Option<u32>,
3737        gain_map_item_ids: Vec<u32>,
3738        rendered_dims: Option<(u32, u32)>,
3739        gain_map_dims: Vec<(u32, Option<(u32, u32)>)>,
3740    ) -> Self {
3741        Self {
3742            tmap_item_id,
3743            base_item_id,
3744            gain_map_item_ids,
3745            rendered_dims,
3746            gain_map_dims,
3747        }
3748    }
3749}
3750
3751impl ToneMapResolution {
3752    /// True when at least one gain map is coded at a strictly smaller
3753    /// resolution than the rendered (base) dimensions — i.e. the tone-map
3754    /// reconstruction must up-sample that gain map. `false` when the base
3755    /// dims are unknown, when there are no gain maps, or when every gain
3756    /// map already matches the rendered extents.
3757    pub fn upsamples_gain_map(&self) -> bool {
3758        let Some((bw, bh)) = self.rendered_dims else {
3759            return false;
3760        };
3761        self.gain_map_dims.iter().any(|&(_, dims)| match dims {
3762            Some((gw, gh)) => gw < bw || gh < bh,
3763            None => false,
3764        })
3765    }
3766}
3767
3768/// Resolve every `tmap` tone-map derivation item in `meta` end-to-end: read
3769/// its `dimg` inputs (base = input 0, gain maps = the rest) and resolve each
3770/// input's reconstructed dimensions from the box graph. Returns one
3771/// [`ToneMapResolution`] per `tmap` item, in `iinf` declaration order.
3772///
3773/// Spec: av1-avif §4.2.2. The rendered output takes the base image's
3774/// extents; gain maps may be coded smaller. No AV1 decode is performed — the
3775/// gain-map byte reconstruction is a separate concern handled by
3776/// [`GainMapMetadata`]. This resolver only describes the derivation geometry,
3777/// mirroring [`resolve_overlays`] / [`resolve_iden_derivations`].
3778pub fn resolve_tone_maps(
3779    meta: &crate::meta::Meta,
3780    file_bytes: &[u8],
3781    idat: Option<&[u8]>,
3782) -> Vec<ToneMapResolution> {
3783    let dimg = b(b"dimg");
3784    let mut out = Vec::new();
3785    for tmap_id in meta.item_ids_of_type(&crate::meta::ITEM_TYPE_TMAP) {
3786        let inputs = meta.iref_targets(&dimg, tmap_id);
3787        let base_item_id = inputs.first().copied();
3788        let gain_map_item_ids: Vec<u32> = inputs.iter().skip(1).copied().collect();
3789        // Base image output dimensions are the rendered tone-map extents.
3790        let rendered_dims = base_item_id.and_then(|base| {
3791            reconstructed_dims(meta, base, file_bytes, idat)
3792                .map(|(w, h)| output_dims_from_reconstructed(meta, base, w, h))
3793        });
3794        let gain_map_dims = gain_map_item_ids
3795            .iter()
3796            .map(|&gid| {
3797                let dims = reconstructed_dims(meta, gid, file_bytes, idat)
3798                    .map(|(w, h)| output_dims_from_reconstructed(meta, gid, w, h));
3799                (gid, dims)
3800            })
3801            .collect::<Vec<_>>();
3802        out.push(ToneMapResolution {
3803            tmap_item_id: tmap_id,
3804            base_item_id,
3805            gain_map_item_ids,
3806            rendered_dims,
3807            gain_map_dims,
3808        });
3809    }
3810    out
3811}
3812
3813/// Where one tile of a `'grid'` derived image lands on the reconstructed
3814/// canvas, and what portion of it survives the right/bottom trim
3815/// (ISO/IEC 23008-12 §6.6.2.3.1). Tiles are placed row-major, top row first,
3816/// left to right, at `(col · tile_width, row · tile_height)` without gap or
3817/// overlap; pixels at or beyond `output_width` / `output_height` are
3818/// trimmed.
3819#[doc(hidden)]
3820#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3821pub struct GridTilePlacement {
3822    /// The tile's source image item id (the `dimg` `to_id` at this index).
3823    pub source_item_id: u32,
3824    /// Zero-based grid row (`tile_index / columns`).
3825    pub row: u16,
3826    /// Zero-based grid column (`tile_index % columns`).
3827    pub col: u16,
3828    /// Canvas x of the tile's left column (`col · tile_width`).
3829    pub origin_x: u32,
3830    /// Canvas y of the tile's top row (`row · tile_height`).
3831    pub origin_y: u32,
3832}
3833
3834impl GridTilePlacement {
3835    /// Every field as a positional argument, in declaration order
3836    /// (the record is `#[non_exhaustive]`: build it here, or from
3837    /// `Default` where one exists, then read / assign the public
3838    /// fields).
3839    #[allow(clippy::too_many_arguments)]
3840    pub fn new(source_item_id: u32, row: u16, col: u16, origin_x: u32, origin_y: u32) -> Self {
3841        Self {
3842            source_item_id,
3843            row,
3844            col,
3845            origin_x,
3846            origin_y,
3847        }
3848    }
3849}
3850
3851impl GridTilePlacement {
3852    /// The visible canvas rectangle `(x, y, width, height)` after trimming
3853    /// the tile against `[0, output_width) × [0, output_height)`
3854    /// (§6.6.2.3.1 right/bottom trim). Returns `None` when the tile lands
3855    /// entirely outside the canvas — which a spec-conformant grid never
3856    /// does (the tiles must cover the canvas), but a malformed descriptor
3857    /// might. `tile_w` / `tile_h` are the common tile dimensions.
3858    pub fn visible(
3859        &self,
3860        tile_w: u32,
3861        tile_h: u32,
3862        output_width: u32,
3863        output_height: u32,
3864    ) -> Option<(u32, u32, u32, u32)> {
3865        let right = (u64::from(self.origin_x) + u64::from(tile_w)).min(u64::from(output_width));
3866        let bottom = (u64::from(self.origin_y) + u64::from(tile_h)).min(u64::from(output_height));
3867        if right <= u64::from(self.origin_x) || bottom <= u64::from(self.origin_y) {
3868            return None;
3869        }
3870        Some((
3871            self.origin_x,
3872            self.origin_y,
3873            (right - u64::from(self.origin_x)) as u32,
3874            (bottom - u64::from(self.origin_y)) as u32,
3875        ))
3876    }
3877
3878    /// True when the tile is trimmed on the right or bottom edge — i.e. it
3879    /// extends past `output_width` / `output_height`. The right column and
3880    /// bottom row of a grid whose `output` dims aren't a whole multiple of
3881    /// the tile size are trimmed this way.
3882    pub fn is_trimmed(
3883        &self,
3884        tile_w: u32,
3885        tile_h: u32,
3886        output_width: u32,
3887        output_height: u32,
3888    ) -> bool {
3889        u64::from(self.origin_x) + u64::from(tile_w) > u64::from(output_width)
3890            || u64::from(self.origin_y) + u64::from(tile_h) > u64::from(output_height)
3891    }
3892}
3893
3894/// A fully resolved `'grid'` derivation: the parsed descriptor, the common
3895/// tile dimensions, and each tile's canvas placement (§6.6.2.3).
3896#[doc(hidden)]
3897#[derive(Clone, Debug, PartialEq, Eq)]
3898pub struct GridResolution {
3899    /// The `'grid'` derived item id.
3900    pub grid_item_id: u32,
3901    /// The parsed descriptor (row/column counts + output dimensions).
3902    pub descriptor: crate::grid::ImageGrid,
3903    /// The common tile dimensions `(tile_width, tile_height)` — every input
3904    /// tile shares these (§6.6.2.3.1, "All input images shall have exactly
3905    /// the same width and height"). Taken from the first tile's
3906    /// reconstructed dimensions; `None` when that tile is unresolvable.
3907    pub tile_dims: Option<(u32, u32)>,
3908    /// One placement per tile, row-major (top row first, left to right),
3909    /// in `dimg` `to_ids` order (§6.6.2.3.1).
3910    pub placements: Vec<GridTilePlacement>,
3911}
3912
3913impl GridResolution {
3914    /// Every field as a positional argument, in declaration order
3915    /// (the record is `#[non_exhaustive]`: build it here, or from
3916    /// `Default` where one exists, then read / assign the public
3917    /// fields).
3918    #[allow(clippy::too_many_arguments)]
3919    pub fn new(
3920        grid_item_id: u32,
3921        descriptor: crate::grid::ImageGrid,
3922        tile_dims: Option<(u32, u32)>,
3923        placements: Vec<GridTilePlacement>,
3924    ) -> Self {
3925        Self {
3926            grid_item_id,
3927            descriptor,
3928            tile_dims,
3929            placements,
3930        }
3931    }
3932}
3933
3934impl GridResolution {
3935    /// Canvas dimensions `(output_width, output_height)`.
3936    pub fn canvas(&self) -> (u32, u32) {
3937        (self.descriptor.output_width, self.descriptor.output_height)
3938    }
3939
3940    /// True when the tiles "cover" the canvas as §6.6.2.3.1 requires:
3941    /// `tile_width · columns ≥ output_width` and
3942    /// `tile_height · rows ≥ output_height`. `false` when [`Self::tile_dims`]
3943    /// is unknown (cannot verify coverage).
3944    pub fn covers_canvas(&self) -> bool {
3945        let Some((tw, th)) = self.tile_dims else {
3946            return false;
3947        };
3948        u64::from(tw) * u64::from(self.descriptor.columns)
3949            >= u64::from(self.descriptor.output_width)
3950            && u64::from(th) * u64::from(self.descriptor.rows)
3951                >= u64::from(self.descriptor.output_height)
3952    }
3953
3954    /// The number of tiles trimmed on the right or bottom edge (§6.6.2.3.1).
3955    /// Zero when the output dimensions are a whole multiple of the tile size.
3956    /// Always zero when [`Self::tile_dims`] is unknown.
3957    pub fn trimmed_tile_count(&self) -> usize {
3958        let Some((tw, th)) = self.tile_dims else {
3959            return 0;
3960        };
3961        let (ow, oh) = self.canvas();
3962        self.placements
3963            .iter()
3964            .filter(|p| p.is_trimmed(tw, th, ow, oh))
3965            .count()
3966    }
3967}
3968
3969/// Resolve every `'grid'` derived item in `meta` end-to-end: parse the
3970/// descriptor, pair each `dimg` input with its row-major canvas placement,
3971/// and resolve the common tile dimensions from the first tile's box-graph
3972/// reconstructed dimensions. Returns one [`GridResolution`] per `'grid'`
3973/// item, in `iinf` declaration order; grids whose descriptor can't be
3974/// resolved/parsed are skipped.
3975///
3976/// Spec: ISO/IEC 23008-12 §6.6.2.3. Tiles are placed at
3977/// `(col · tile_width, row · tile_height)` and the canvas is trimmed on the
3978/// right/bottom to `output_width × output_height`. No AV1 decode — the
3979/// geometry comes from the box graph alone, mirroring [`resolve_overlays`].
3980pub fn resolve_grids(
3981    meta: &crate::meta::Meta,
3982    file_bytes: &[u8],
3983    idat: Option<&[u8]>,
3984) -> Vec<GridResolution> {
3985    let grid_type = crate::parser::ITEM_TYPE_GRID;
3986    let dimg = b(b"dimg");
3987    let mut out = Vec::new();
3988    for grid_id in meta.item_ids_of_type(&grid_type) {
3989        let Some(bytes) = resolve_descriptor_bytes(meta, grid_id, file_bytes, idat) else {
3990            continue;
3991        };
3992        let Ok(descriptor) = crate::grid::ImageGrid::parse(&bytes) else {
3993            continue;
3994        };
3995        let tiles = meta.iref_targets(&dimg, grid_id);
3996        // Common tile dimensions = first tile's reconstructed output image.
3997        let tile_dims = tiles.first().and_then(|&first| {
3998            reconstructed_dims(meta, first, file_bytes, idat)
3999                .map(|(w, h)| output_dims_from_reconstructed(meta, first, w, h))
4000        });
4001        let cols = descriptor.columns;
4002        let (tw, th) = tile_dims.unwrap_or((0, 0));
4003        let mut placements = Vec::with_capacity(tiles.len());
4004        for (idx, &tile_id) in tiles.iter().enumerate() {
4005            let row = (idx as u32 / cols.max(1) as u32) as u16;
4006            let col = (idx as u32 % cols.max(1) as u32) as u16;
4007            placements.push(GridTilePlacement {
4008                source_item_id: tile_id,
4009                row,
4010                col,
4011                origin_x: u32::from(col) * tw,
4012                origin_y: u32::from(row) * th,
4013            });
4014        }
4015        out.push(GridResolution {
4016            grid_item_id: grid_id,
4017            descriptor,
4018            tile_dims,
4019            placements,
4020        });
4021    }
4022    out
4023}
4024
4025/// The kind of a node in a [`DerivationGraph`] — i.e. how its reconstructed
4026/// image is obtained (HEIF §6.6.1). A coded leaf is decoded directly from an
4027/// AV1 bitstream; every other variant is a derived image item whose
4028/// reconstructed image is computed from its `'dimg'` inputs.
4029#[non_exhaustive]
4030#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4031pub enum DerivationKind {
4032    /// A coded image item (`'av01'`): the leaf a renderer actually decodes.
4033    /// Its reconstructed image is the decoded AV1 frame, with extents from
4034    /// the item's `'ispe'` property.
4035    Coded,
4036    /// A `'grid'` tile-mosaic derivation (§6.6.2.3).
4037    Grid,
4038    /// An `'iovl'` overlay derivation (§6.6.2.2).
4039    Overlay,
4040    /// An `'iden'` identity derivation (§6.6.2.1) — its reconstructed image
4041    /// is its single input's output image; the geometry comes entirely from
4042    /// the input plus this item's own transformative properties.
4043    Identity,
4044    /// A `'tmap'` tone-map / gain-map derivation (av1-avif §4.2.2). Its
4045    /// rendered extents are the base (input 0) image's.
4046    ToneMap,
4047    /// A `'sato'` Sample-Transform derivation (av1-avif §4.2.3).
4048    SampleTransform,
4049    /// A derived item whose `item_type` this crate does not model
4050    /// structurally; its inputs are still walked so a renderer sees the
4051    /// dependency edges, but its geometry is taken from its first input.
4052    Unknown,
4053}
4054
4055impl DerivationKind {
4056    /// Classify an item by its `item_type`.
4057    fn classify(item_type: &BoxType, is_derived: bool) -> Self {
4058        if item_type == &crate::parser::ITEM_TYPE_AV01 {
4059            DerivationKind::Coded
4060        } else if item_type == &crate::parser::ITEM_TYPE_GRID {
4061            DerivationKind::Grid
4062        } else if item_type == &crate::meta::ITEM_TYPE_IOVL {
4063            DerivationKind::Overlay
4064        } else if item_type == &crate::meta::ITEM_TYPE_IDEN {
4065            DerivationKind::Identity
4066        } else if item_type == &crate::meta::ITEM_TYPE_TMAP {
4067            DerivationKind::ToneMap
4068        } else if item_type == &crate::meta::ITEM_TYPE_SATO {
4069            DerivationKind::SampleTransform
4070        } else if is_derived {
4071            DerivationKind::Unknown
4072        } else {
4073            // A non-`av01` item with no `'dimg'` inputs: treat as a coded
4074            // leaf (it is the bottom of the chain and a renderer hands its
4075            // bytes to whatever decoder matches the item type).
4076            DerivationKind::Coded
4077        }
4078    }
4079
4080    /// True for the coded-leaf kind — the only nodes a renderer decodes
4081    /// directly.
4082    pub fn is_coded(self) -> bool {
4083        matches!(self, DerivationKind::Coded)
4084    }
4085}
4086
4087/// One node in a [`DerivationGraph`]: an image item reachable from the root
4088/// primary by walking `'dimg'` edges, together with its resolved geometry.
4089///
4090/// Geometry is the box-graph-only resolution (no AV1 decode):
4091/// `reconstructed_dims` is the node's reconstructed image size (§6.3) and
4092/// `output_dims` folds the node's own transformative item properties on top
4093/// (§6.6.1). For a coded leaf the two differ only when the leaf carries a
4094/// `'clap'`/`'irot'`/`'imir'`/`'iscl'` of its own.
4095#[non_exhaustive]
4096#[derive(Clone, Debug, PartialEq, Eq)]
4097pub struct DerivationNode {
4098    /// The image item id.
4099    pub item_id: u32,
4100    /// How this node's reconstructed image is obtained.
4101    pub kind: DerivationKind,
4102    /// The `'dimg'` input item ids, in reference order (empty for a coded
4103    /// leaf). For `'grid'` this is the row-major tile list; for `'iovl'` the
4104    /// bottom-to-top layer list; for `'iden'`/`'tmap'` the single/base input
4105    /// first.
4106    pub inputs: Vec<u32>,
4107    /// The reconstructed-image dimensions, or `None` when unresolvable from
4108    /// the box graph (e.g. a coded leaf with no `'ispe'`, or a derivation
4109    /// whose descriptor cannot be parsed).
4110    pub reconstructed_dims: Option<(u32, u32)>,
4111    /// The output-image dimensions: [`Self::reconstructed_dims`] with this
4112    /// node's own transform chain folded in (§6.3 / §6.6.1).
4113    pub output_dims: Option<(u32, u32)>,
4114    /// Recursion depth from the root primary (the root is depth 0).
4115    pub depth: u32,
4116}
4117
4118impl DerivationNode {
4119    /// Every field as a positional argument, in declaration order
4120    /// (the record is `#[non_exhaustive]`: build it here, or from
4121    /// `Default` where one exists, then read / assign the public
4122    /// fields).
4123    #[allow(clippy::too_many_arguments)]
4124    pub fn new(
4125        item_id: u32,
4126        kind: DerivationKind,
4127        inputs: Vec<u32>,
4128        reconstructed_dims: Option<(u32, u32)>,
4129        output_dims: Option<(u32, u32)>,
4130        depth: u32,
4131    ) -> Self {
4132        Self {
4133            item_id,
4134            kind,
4135            inputs,
4136            reconstructed_dims,
4137            output_dims,
4138            depth,
4139        }
4140    }
4141}
4142
4143impl DerivationNode {
4144    /// The node's own dimension-affecting transform chain (`'clap'` →
4145    /// `'irot'` → `'imir'` → `'iscl'`, in `ipma` order). Convenience wrapper
4146    /// over [`transform_chain`].
4147    pub fn transform_chain(&self, meta: &crate::meta::Meta) -> Vec<DimTransform> {
4148        transform_chain(meta, self.item_id)
4149    }
4150}
4151
4152/// A fully-walked derivation graph rooted at one image item (typically the
4153/// primary), enumerating every image item reachable through `'dimg'` edges
4154/// and the distinct coded leaves a renderer must decode (HEIF §6.6).
4155///
4156/// This is the decode-free dependency planner: given a derived primary, it
4157/// reports (a) the output canvas size, (b) every node's resolved geometry,
4158/// and (c) the set of coded `'av01'` items — with no duplicates and in
4159/// first-visit order — that have to be decoded before the derivation can be
4160/// reconstructed. It ties together the per-derivation resolvers
4161/// ([`resolve_grids`] / [`resolve_overlays`] / [`resolve_iden_derivations`] /
4162/// [`resolve_tone_maps`]) into a single traversal that also handles
4163/// **nested** derivations (e.g. an `'iden'` crop of a `'grid'`, or a
4164/// `'tmap'` whose base is itself a `'grid'`).
4165///
4166/// The traversal is depth-bounded by [`MAX_DERIVATION_DEPTH`] so a `'dimg'`
4167/// cycle terminates; a node already visited is recorded once (its first
4168/// visit) and not re-expanded, so a diamond-shaped graph (two derivations
4169/// sharing an input) lists the shared leaf once.
4170#[non_exhaustive]
4171#[derive(Clone, Debug, PartialEq, Eq)]
4172pub struct DerivationGraph {
4173    /// The root item id the graph was built from.
4174    pub root_item_id: u32,
4175    /// Every reachable node, in first-visit (pre-order) order. The root is
4176    /// always `nodes[0]`.
4177    pub nodes: Vec<DerivationNode>,
4178    /// The distinct coded leaf item ids, in first-visit order — the decode
4179    /// set. A renderer decodes each of these once and composes them per the
4180    /// derivation tree.
4181    pub coded_leaf_ids: Vec<u32>,
4182    /// True when the walk hit [`MAX_DERIVATION_DEPTH`] before fully
4183    /// expanding the graph (a probable `'dimg'` cycle or pathological
4184    /// nesting). The partial graph collected up to the bound is still
4185    /// returned.
4186    pub truncated: bool,
4187}
4188
4189impl DerivationGraph {
4190    /// Every field as a positional argument, in declaration order
4191    /// (the record is `#[non_exhaustive]`: build it here, or from
4192    /// `Default` where one exists, then read / assign the public
4193    /// fields).
4194    #[allow(clippy::too_many_arguments)]
4195    pub fn new(
4196        root_item_id: u32,
4197        nodes: Vec<DerivationNode>,
4198        coded_leaf_ids: Vec<u32>,
4199        truncated: bool,
4200    ) -> Self {
4201        Self {
4202            root_item_id,
4203            nodes,
4204            coded_leaf_ids,
4205            truncated,
4206        }
4207    }
4208}
4209
4210impl DerivationGraph {
4211    /// The root primary's output-image dimensions (the final canvas a
4212    /// renderer paints). `None` when the root geometry is unresolvable.
4213    pub fn output_dims(&self) -> Option<(u32, u32)> {
4214        self.nodes.first().and_then(|n| n.output_dims)
4215    }
4216
4217    /// True when the root is a coded image item (not a derivation) — i.e.
4218    /// the file's primary is a plain coded picture and the graph has exactly
4219    /// one node.
4220    pub fn root_is_coded(&self) -> bool {
4221        self.nodes
4222            .first()
4223            .is_some_and(|n| n.kind == DerivationKind::Coded)
4224    }
4225
4226    /// Look up a node by item id (linear scan; graphs are small).
4227    pub fn node(&self, item_id: u32) -> Option<&DerivationNode> {
4228        self.nodes.iter().find(|n| n.item_id == item_id)
4229    }
4230
4231    /// The number of derived (non-coded) nodes in the graph.
4232    pub fn derived_node_count(&self) -> usize {
4233        self.nodes.iter().filter(|n| !n.kind.is_coded()).count()
4234    }
4235
4236    /// The decode set paired with each leaf's reconstructed dimensions — the
4237    /// `(item_id, Option<(width, height)>)` a renderer needs to size each
4238    /// AV1 output buffer before composing the derivation. The dims are the
4239    /// leaf's `'ispe'` extents (its own `'clap'`/`'irot'` are applied during
4240    /// composition, not to the decode buffer), or `None` when the leaf
4241    /// carries no `'ispe'`. In first-visit (decode) order, de-duplicated.
4242    pub fn coded_leaf_dims(&self) -> Vec<(u32, Option<(u32, u32)>)> {
4243        self.coded_leaf_ids
4244            .iter()
4245            .map(|&id| (id, self.node(id).and_then(|n| n.reconstructed_dims)))
4246            .collect()
4247    }
4248
4249    /// All node item ids at a given derivation `depth` (root = depth 0), in
4250    /// first-visit order. Depth 0 is always just the root.
4251    pub fn nodes_at_depth(&self, depth: u32) -> Vec<u32> {
4252        self.nodes
4253            .iter()
4254            .filter(|n| n.depth == depth)
4255            .map(|n| n.item_id)
4256            .collect()
4257    }
4258}
4259
4260/// Walk the `'dimg'` derivation graph rooted at `root_item_id`, resolving
4261/// every node's box-graph geometry and collecting the distinct coded leaves
4262/// (HEIF §6.6). See [`DerivationGraph`].
4263///
4264/// No AV1 decode: all geometry comes from the box graph
4265/// ([`reconstructed_dims`] + [`transform_chain`]). The walk is depth-bounded
4266/// by [`MAX_DERIVATION_DEPTH`] (cycle guard) and visits each item id once.
4267pub fn build_derivation_graph(
4268    meta: &crate::meta::Meta,
4269    root_item_id: u32,
4270    file_bytes: &[u8],
4271    idat: Option<&[u8]>,
4272) -> DerivationGraph {
4273    let dimg = b(b"dimg");
4274    let mut nodes: Vec<DerivationNode> = Vec::new();
4275    let mut visited: Vec<u32> = Vec::new();
4276    let mut truncated = false;
4277
4278    // Iterative pre-order walk over a small explicit stack of (item, depth)
4279    // so deep/adversarial nesting cannot blow the native stack. Children are
4280    // pushed in reverse so they pop in `dimg` reference order.
4281    let mut stack: Vec<(u32, u32)> = vec![(root_item_id, 0)];
4282    while let Some((item_id, depth)) = stack.pop() {
4283        if visited.contains(&item_id) {
4284            continue;
4285        }
4286        if depth > MAX_DERIVATION_DEPTH {
4287            truncated = true;
4288            continue;
4289        }
4290        visited.push(item_id);
4291
4292        let Some(item) = meta.item_by_id(item_id) else {
4293            // A `dimg` target that names no known item: record nothing but
4294            // don't fail the whole walk.
4295            continue;
4296        };
4297        let inputs = meta.iref_targets(&dimg, item_id);
4298        let is_derived = !inputs.is_empty();
4299        let kind = DerivationKind::classify(&item.item_type, is_derived);
4300
4301        let reconstructed = reconstructed_dims_inner(meta, item_id, file_bytes, idat, 0);
4302        let output =
4303            reconstructed.map(|(w, h)| output_dims_from_reconstructed(meta, item_id, w, h));
4304
4305        if !kind.is_coded() {
4306            // Push children in reverse for pre-order `dimg` ordering.
4307            for &child in inputs.iter().rev() {
4308                stack.push((child, depth + 1));
4309            }
4310        }
4311
4312        nodes.push(DerivationNode {
4313            item_id,
4314            kind,
4315            inputs,
4316            reconstructed_dims: reconstructed,
4317            output_dims: output,
4318            depth,
4319        });
4320    }
4321
4322    // `nodes` is currently in stack-pop order; re-sort into ascending depth
4323    // then first-visit (visited) order so callers get a stable pre-order
4324    // view with the root first.
4325    let visit_rank = |id: u32| visited.iter().position(|&v| v == id).unwrap_or(usize::MAX);
4326    nodes.sort_by_key(|n| (n.depth, visit_rank(n.item_id)));
4327
4328    // Coded leaves in stable pre-order (the sorted node order), deduplicated
4329    // — a diamond graph lists a shared leaf once.
4330    let coded_leaf_ids: Vec<u32> = nodes
4331        .iter()
4332        .filter(|n| n.kind.is_coded())
4333        .map(|n| n.item_id)
4334        .collect();
4335
4336    DerivationGraph {
4337        root_item_id,
4338        nodes,
4339        coded_leaf_ids,
4340        truncated,
4341    }
4342}
4343
4344#[cfg(test)]
4345mod tests {
4346    use super::*;
4347
4348    /// 16-bit `iovl` with two source images stacked at `(10, 20)` and
4349    /// `(30, 40)` on a 256×256 canvas filled white-opaque.
4350    #[test]
4351    fn iovl_parses_two_entries_16bit_fields() {
4352        let mut buf = Vec::new();
4353        buf.push(0); // version
4354        buf.push(0); // flags = 0 → 16-bit fields
4355        for v in [65535u16, 65535, 65535, 65535] {
4356            buf.extend_from_slice(&v.to_be_bytes());
4357        }
4358        buf.extend_from_slice(&256u16.to_be_bytes()); // output_width
4359        buf.extend_from_slice(&256u16.to_be_bytes()); // output_height
4360        buf.extend_from_slice(&10i16.to_be_bytes()); // h
4361        buf.extend_from_slice(&20i16.to_be_bytes()); // v
4362        buf.extend_from_slice(&30i16.to_be_bytes()); // h
4363        buf.extend_from_slice(&40i16.to_be_bytes()); // v
4364        let o = ImageOverlay::parse(&buf, 2).unwrap();
4365        assert_eq!(o.canvas_fill_value, [65535, 65535, 65535, 65535]);
4366        assert_eq!(o.output_width, 256);
4367        assert_eq!(o.output_height, 256);
4368        assert_eq!(
4369            o.entries,
4370            vec![
4371                OverlayEntry {
4372                    horizontal_offset: 10,
4373                    vertical_offset: 20
4374                },
4375                OverlayEntry {
4376                    horizontal_offset: 30,
4377                    vertical_offset: 40
4378                }
4379            ]
4380        );
4381    }
4382
4383    /// 32-bit field variant (`flags & 1 == 1`) — needed for canvases
4384    /// larger than 65535 pixels.
4385    #[test]
4386    fn iovl_parses_32bit_fields() {
4387        let mut buf = Vec::new();
4388        buf.push(0); // version
4389        buf.push(1); // flags = 1 → 32-bit fields
4390        for v in [0u16, 0, 0, 0] {
4391            buf.extend_from_slice(&v.to_be_bytes());
4392        }
4393        // 100 000 × 8 000 (8·10⁸ pixels): 32-bit fields, inside the
4394        // container's 2³⁰-pixel descriptor bound.
4395        buf.extend_from_slice(&100_000u32.to_be_bytes()); // output_width
4396        buf.extend_from_slice(&8_000u32.to_be_bytes()); // output_height
4397        buf.extend_from_slice(&(-5i32).to_be_bytes()); // h (negative clips)
4398        buf.extend_from_slice(&10_000i32.to_be_bytes()); // v
4399        let o = ImageOverlay::parse(&buf, 1).unwrap();
4400        assert_eq!(o.output_width, 100_000);
4401        assert_eq!(o.output_height, 8_000);
4402        assert_eq!(o.entries[0].horizontal_offset, -5);
4403        assert_eq!(o.entries[0].vertical_offset, 10_000);
4404    }
4405
4406    /// Negative `horizontal_offset` (signed) decoded correctly in
4407    /// 16-bit mode — a placement intentionally clipped at the left
4408    /// edge.
4409    #[test]
4410    fn iovl_negative_offset_signed_round_trip() {
4411        let mut buf = Vec::new();
4412        buf.push(0);
4413        buf.push(0);
4414        for _ in 0..4 {
4415            buf.extend_from_slice(&0u16.to_be_bytes());
4416        }
4417        buf.extend_from_slice(&64u16.to_be_bytes());
4418        buf.extend_from_slice(&64u16.to_be_bytes());
4419        buf.extend_from_slice(&(-3i16).to_be_bytes());
4420        buf.extend_from_slice(&(-4i16).to_be_bytes());
4421        let o = ImageOverlay::parse(&buf, 1).unwrap();
4422        assert_eq!(o.entries[0].horizontal_offset, -3);
4423        assert_eq!(o.entries[0].vertical_offset, -4);
4424    }
4425
4426    /// `iovl` with `reference_count` larger than payload is rejected
4427    /// before allocating.
4428    #[test]
4429    fn iovl_rejects_oversized_reference_count() {
4430        let mut buf = Vec::new();
4431        buf.push(0);
4432        buf.push(0);
4433        for _ in 0..4 {
4434            buf.extend_from_slice(&0u16.to_be_bytes());
4435        }
4436        buf.extend_from_slice(&16u16.to_be_bytes());
4437        buf.extend_from_slice(&16u16.to_be_bytes());
4438        // Payload only has room for 0 entries; claim 100.
4439        assert!(ImageOverlay::parse(&buf, 100).is_err());
4440    }
4441
4442    /// `iovl` rejects unrecognised versions.
4443    #[test]
4444    fn iovl_rejects_nonzero_version() {
4445        let buf = vec![1u8, 0]; // version=1
4446        assert!(ImageOverlay::parse(&buf, 0).is_err());
4447    }
4448
4449    /// Build a minimal `grpl` containing one `altr` group with three
4450    /// alternate item ids.
4451    fn build_grpl_altr() -> Vec<u8> {
4452        let mut buf = Vec::new();
4453        // EntityToGroupBox: size(4) + 'altr' + FullBox(v=0,f=0) + group_id(4) + count(4) + ids
4454        let mut child = vec![0u8; 4]; // FullBox
4455        child.extend_from_slice(&42u32.to_be_bytes()); // group_id
4456        child.extend_from_slice(&3u32.to_be_bytes()); // num_entities
4457        child.extend_from_slice(&1u32.to_be_bytes());
4458        child.extend_from_slice(&2u32.to_be_bytes());
4459        child.extend_from_slice(&3u32.to_be_bytes());
4460        let size = (8 + child.len()) as u32;
4461        buf.extend_from_slice(&size.to_be_bytes());
4462        buf.extend_from_slice(b"altr");
4463        buf.extend_from_slice(&child);
4464        buf
4465    }
4466
4467    /// `parse_grpl` extracts an `altr` group and surfaces its entity
4468    /// list in declaration order.
4469    #[test]
4470    fn grpl_parses_altr_group() {
4471        let grpl = build_grpl_altr();
4472        let groups = parse_grpl(&grpl).unwrap();
4473        assert_eq!(groups.len(), 1);
4474        let g = &groups[0];
4475        assert_eq!(&g.grouping_type, b"altr");
4476        assert!(g.is_alternates());
4477        assert!(!g.is_stereo_pair());
4478        assert!(!g.is_equivalence());
4479        assert!(!g.is_panorama());
4480        assert_eq!(g.group_id, 42);
4481        assert_eq!(g.entity_ids, vec![1, 2, 3]);
4482    }
4483
4484    /// `pano` group (HEIF §6.8.8.1): entities listed in increasing
4485    /// panorama order; the helper classifies the grouping type.
4486    #[test]
4487    fn grpl_parses_pano_group() {
4488        let mut buf = Vec::new();
4489        let mut child = vec![0u8; 4]; // FullBox
4490        child.extend_from_slice(&5u32.to_be_bytes()); // group_id
4491        child.extend_from_slice(&3u32.to_be_bytes()); // num_entities
4492        child.extend_from_slice(&21u32.to_be_bytes()); // first in panorama order
4493        child.extend_from_slice(&22u32.to_be_bytes());
4494        child.extend_from_slice(&23u32.to_be_bytes());
4495        let size = (8 + child.len()) as u32;
4496        buf.extend_from_slice(&size.to_be_bytes());
4497        buf.extend_from_slice(b"pano");
4498        buf.extend_from_slice(&child);
4499        let groups = parse_grpl(&buf).unwrap();
4500        assert_eq!(groups.len(), 1);
4501        let g = &groups[0];
4502        assert!(g.is_panorama());
4503        assert!(!g.is_alternates());
4504        assert!(!g.is_stereo_pair());
4505        assert!(!g.is_equivalence());
4506        assert_eq!(g.entity_ids, vec![21, 22, 23]);
4507    }
4508
4509    /// `prgr` / `brst` / `msrc` groups classify via their respective
4510    /// projections and are mutually exclusive with the others.
4511    #[test]
4512    fn grpl_classifies_prgr_brst_msrc_groups() {
4513        let build = |fourcc: &[u8; 4], ids: &[u32]| {
4514            let mut child = vec![0u8; 4]; // FullBox
4515            child.extend_from_slice(&7u32.to_be_bytes()); // group_id
4516            child.extend_from_slice(&(ids.len() as u32).to_be_bytes());
4517            for &id in ids {
4518                child.extend_from_slice(&id.to_be_bytes());
4519            }
4520            let mut buf = Vec::new();
4521            let size = (8 + child.len()) as u32;
4522            buf.extend_from_slice(&size.to_be_bytes());
4523            buf.extend_from_slice(fourcc);
4524            buf.extend_from_slice(&child);
4525            buf
4526        };
4527
4528        let prgr = &parse_grpl(&build(b"prgr", &[1, 2, 3])).unwrap()[0];
4529        assert!(prgr.is_progressive());
4530        assert!(!prgr.is_burst() && !prgr.is_multi_source() && !prgr.is_alternates());
4531        assert_eq!(prgr.entity_ids, vec![1, 2, 3]);
4532
4533        let brst = &parse_grpl(&build(b"brst", &[10, 11])).unwrap()[0];
4534        assert!(brst.is_burst());
4535        assert!(!brst.is_progressive() && !brst.is_multi_source());
4536
4537        let msrc = &parse_grpl(&build(b"msrc", &[5, 6])).unwrap()[0];
4538        assert!(msrc.is_multi_source());
4539        assert!(!msrc.is_progressive() && !msrc.is_burst());
4540    }
4541
4542    /// `tsyn` time-synchronized group + `iaug` audio-to-image group
4543    /// classify via their projections; the `iaug` flags LSB drives the
4544    /// audio-repeat semantic (HEIF §6.8.3 / §6.8.4).
4545    #[test]
4546    fn grpl_classifies_tsyn_and_iaug_groups() {
4547        // grouping child with an explicit 24-bit flags value.
4548        let build = |fourcc: &[u8; 4], flags: u32, ids: &[u32]| {
4549            let mut child = Vec::new();
4550            child.push(0u8); // version
4551            child.extend_from_slice(&flags.to_be_bytes()[1..]); // 24-bit flags
4552            child.extend_from_slice(&9u32.to_be_bytes()); // group_id
4553            child.extend_from_slice(&(ids.len() as u32).to_be_bytes());
4554            for &id in ids {
4555                child.extend_from_slice(&id.to_be_bytes());
4556            }
4557            let mut buf = Vec::new();
4558            let size = (8 + child.len()) as u32;
4559            buf.extend_from_slice(&size.to_be_bytes());
4560            buf.extend_from_slice(fourcc);
4561            buf.extend_from_slice(&child);
4562            buf
4563        };
4564
4565        let tsyn = &parse_grpl(&build(b"tsyn", 0, &[1, 2, 3])).unwrap()[0];
4566        assert!(tsyn.is_time_synchronized());
4567        assert!(!tsyn.is_audio_to_image() && !tsyn.is_burst());
4568        assert_eq!(tsyn.entity_ids, vec![1, 2, 3]);
4569        assert_eq!(tsyn.flags, 0);
4570
4571        // iaug with the repeat flag clear.
4572        let iaug = &parse_grpl(&build(b"iaug", 0, &[20, 21])).unwrap()[0];
4573        assert!(iaug.is_audio_to_image());
4574        assert!(!iaug.audio_repeats());
4575        assert_eq!(iaug.entity_ids, vec![20, 21]);
4576
4577        // iaug with the repeat flag set.
4578        let iaug_rep = &parse_grpl(&build(b"iaug", 0x1, &[20, 21])).unwrap()[0];
4579        assert!(iaug_rep.is_audio_to_image());
4580        assert!(iaug_rep.audio_repeats());
4581        assert_eq!(iaug_rep.flags, 0x1);
4582
4583        // audio_repeats is only meaningful for iaug — a set flag on a
4584        // different grouping type does not report a repeat.
4585        let ster = &parse_grpl(&build(b"ster", 0x1, &[20, 21])).unwrap()[0];
4586        assert!(!ster.audio_repeats());
4587    }
4588
4589    /// `ster` / `iaug` carry an exactly-two `shall`; `cardinality_ok`
4590    /// flags a violation and passes any other grouping type (§6.8.5 /
4591    /// §6.8.4).
4592    #[test]
4593    fn grpl_cardinality_shall() {
4594        let build = |fourcc: &[u8; 4], ids: &[u32]| {
4595            let mut child = vec![0u8; 4];
4596            child.extend_from_slice(&1u32.to_be_bytes());
4597            child.extend_from_slice(&(ids.len() as u32).to_be_bytes());
4598            for &id in ids {
4599                child.extend_from_slice(&id.to_be_bytes());
4600            }
4601            let mut buf = Vec::new();
4602            let size = (8 + child.len()) as u32;
4603            buf.extend_from_slice(&size.to_be_bytes());
4604            buf.extend_from_slice(fourcc);
4605            buf.extend_from_slice(&child);
4606            buf
4607        };
4608
4609        assert!(parse_grpl(&build(b"ster", &[1, 2])).unwrap()[0].cardinality_ok());
4610        assert!(!parse_grpl(&build(b"ster", &[1])).unwrap()[0].cardinality_ok());
4611        assert!(!parse_grpl(&build(b"ster", &[1, 2, 3])).unwrap()[0].cardinality_ok());
4612        assert!(parse_grpl(&build(b"iaug", &[1, 2])).unwrap()[0].cardinality_ok());
4613        assert!(!parse_grpl(&build(b"iaug", &[1])).unwrap()[0].cardinality_ok());
4614        // A grouping type with no fixed-count shall passes for any count.
4615        assert!(parse_grpl(&build(b"altr", &[1, 2, 3, 4])).unwrap()[0].cardinality_ok());
4616    }
4617
4618    /// The five §6.8.6 bracketed-set grouping types classify through
4619    /// `bracketing_kind` / `is_bracketed_set`; non-bracket types return
4620    /// `None`.
4621    #[test]
4622    fn grpl_classifies_bracketed_set_groups() {
4623        let build = |fourcc: &[u8; 4], ids: &[u32]| {
4624            let mut child = vec![0u8; 4]; // FullBox
4625            child.extend_from_slice(&3u32.to_be_bytes()); // group_id
4626            child.extend_from_slice(&(ids.len() as u32).to_be_bytes());
4627            for &id in ids {
4628                child.extend_from_slice(&id.to_be_bytes());
4629            }
4630            let mut buf = Vec::new();
4631            let size = (8 + child.len()) as u32;
4632            buf.extend_from_slice(&size.to_be_bytes());
4633            buf.extend_from_slice(fourcc);
4634            buf.extend_from_slice(&child);
4635            buf
4636        };
4637
4638        let cases = [
4639            (&b"aebr"[..], BracketingKind::AutoExposure),
4640            (&b"wbbr"[..], BracketingKind::WhiteBalance),
4641            (&b"fobr"[..], BracketingKind::Focus),
4642            (&b"afbr"[..], BracketingKind::FlashExposure),
4643            (&b"dobr"[..], BracketingKind::DepthOfField),
4644        ];
4645        for (fourcc, kind) in cases {
4646            let mut fc = [0u8; 4];
4647            fc.copy_from_slice(fourcc);
4648            let g = &parse_grpl(&build(&fc, &[1, 2])).unwrap()[0];
4649            assert!(g.is_bracketed_set());
4650            assert_eq!(g.bracketing_kind(), Some(kind));
4651            assert_eq!(kind.four_cc(), &fc);
4652        }
4653
4654        // altr is not a bracketed set.
4655        let altr = &parse_grpl(&build(b"altr", &[1, 2])).unwrap()[0];
4656        assert!(!altr.is_bracketed_set());
4657        assert_eq!(altr.bracketing_kind(), None);
4658    }
4659
4660    /// `slid` slideshow + `albc`/`favc` user-collection groups classify
4661    /// through their projections (HEIF §6.8.9 / §6.8.7).
4662    #[test]
4663    fn grpl_classifies_slid_and_user_collections() {
4664        let build = |fourcc: &[u8; 4], ids: &[u32]| {
4665            let mut child = vec![0u8; 4]; // FullBox
4666            child.extend_from_slice(&4u32.to_be_bytes()); // group_id
4667            child.extend_from_slice(&(ids.len() as u32).to_be_bytes());
4668            for &id in ids {
4669                child.extend_from_slice(&id.to_be_bytes());
4670            }
4671            let mut buf = Vec::new();
4672            let size = (8 + child.len()) as u32;
4673            buf.extend_from_slice(&size.to_be_bytes());
4674            buf.extend_from_slice(fourcc);
4675            buf.extend_from_slice(&child);
4676            buf
4677        };
4678
4679        let slid = &parse_grpl(&build(b"slid", &[1, 2, 3])).unwrap()[0];
4680        assert!(slid.is_slideshow());
4681        assert!(!slid.is_user_collection() && !slid.is_bracketed_set());
4682
4683        let albc = &parse_grpl(&build(b"albc", &[4, 5])).unwrap()[0];
4684        assert!(albc.is_album_collection() && albc.is_user_collection());
4685        assert!(!albc.is_favorites_collection() && !albc.is_slideshow());
4686
4687        let favc = &parse_grpl(&build(b"favc", &[6])).unwrap()[0];
4688        assert!(favc.is_favorites_collection() && favc.is_user_collection());
4689        assert!(!favc.is_album_collection());
4690    }
4691
4692    /// `ster` group convention: two entities, first is left view.
4693    #[test]
4694    fn grpl_parses_ster_pair() {
4695        let mut buf = Vec::new();
4696        let mut child = vec![0u8; 4]; // FullBox
4697        child.extend_from_slice(&7u32.to_be_bytes()); // group_id
4698        child.extend_from_slice(&2u32.to_be_bytes()); // num_entities
4699        child.extend_from_slice(&10u32.to_be_bytes()); // left view
4700        child.extend_from_slice(&11u32.to_be_bytes()); // right view
4701        let size = (8 + child.len()) as u32;
4702        buf.extend_from_slice(&size.to_be_bytes());
4703        buf.extend_from_slice(b"ster");
4704        buf.extend_from_slice(&child);
4705        let groups = parse_grpl(&buf).unwrap();
4706        assert_eq!(groups.len(), 1);
4707        assert!(groups[0].is_stereo_pair());
4708        assert_eq!(groups[0].entity_ids, vec![10, 11]);
4709    }
4710
4711    /// Multiple groups in one `grpl` come out in declaration order.
4712    #[test]
4713    fn grpl_parses_multiple_groups() {
4714        let mut buf = Vec::new();
4715        // altr group
4716        let mut a = vec![0u8; 4];
4717        a.extend_from_slice(&1u32.to_be_bytes());
4718        a.extend_from_slice(&1u32.to_be_bytes());
4719        a.extend_from_slice(&100u32.to_be_bytes());
4720        let asz = (8 + a.len()) as u32;
4721        buf.extend_from_slice(&asz.to_be_bytes());
4722        buf.extend_from_slice(b"altr");
4723        buf.extend_from_slice(&a);
4724        // eqiv group
4725        let mut e = vec![0u8; 4];
4726        e.extend_from_slice(&2u32.to_be_bytes());
4727        e.extend_from_slice(&0u32.to_be_bytes()); // empty group
4728        let esz = (8 + e.len()) as u32;
4729        buf.extend_from_slice(&esz.to_be_bytes());
4730        buf.extend_from_slice(b"eqiv");
4731        buf.extend_from_slice(&e);
4732        let groups = parse_grpl(&buf).unwrap();
4733        assert_eq!(groups.len(), 2);
4734        assert!(groups[0].is_alternates());
4735        assert!(groups[1].is_equivalence());
4736        assert!(groups[1].entity_ids.is_empty());
4737    }
4738
4739    /// Truncated entity list is rejected before allocation overflow.
4740    #[test]
4741    fn grpl_rejects_truncated_entity_list() {
4742        let mut buf = Vec::new();
4743        let mut child = vec![0u8; 4];
4744        child.extend_from_slice(&1u32.to_be_bytes()); // group_id
4745        child.extend_from_slice(&5u32.to_be_bytes()); // claims 5 entities…
4746        child.extend_from_slice(&100u32.to_be_bytes()); // …but ships only 1
4747        let size = (8 + child.len()) as u32;
4748        buf.extend_from_slice(&size.to_be_bytes());
4749        buf.extend_from_slice(b"altr");
4750        buf.extend_from_slice(&child);
4751        assert!(parse_grpl(&buf).is_err());
4752    }
4753
4754    /// Mif1Compliance flags every missing required box.
4755    #[test]
4756    fn mif1_compliance_missing_list() {
4757        let bare = Mif1Compliance::default();
4758        assert!(!bare.is_compliant());
4759        let m = bare.missing();
4760        // Order is fixed; every required box should appear.
4761        assert!(m.contains(&"hdlr"));
4762        assert!(m.contains(&"pitm"));
4763        assert!(m.contains(&"iinf"));
4764        assert!(m.contains(&"infe"));
4765        assert!(m.contains(&"iloc"));
4766        assert!(m.contains(&"iprp"));
4767    }
4768
4769    /// Mif1Compliance with every required flag set reports compliant.
4770    #[test]
4771    fn mif1_compliance_full() {
4772        let m = Mif1Compliance {
4773            has_hdlr: true,
4774            has_pitm: true,
4775            has_iinf: true,
4776            has_iloc: true,
4777            has_iprp: true,
4778            infe_count: 1,
4779            claims_mif1: true,
4780        };
4781        assert!(m.is_compliant());
4782        assert!(m.missing().is_empty());
4783    }
4784
4785    // ---- sato (Sample Transform Derived Image Item) -----------------
4786
4787    /// Build a `sato` payload (header byte + token_count + token bytes).
4788    /// `bit_depth` is the 2-bit selector; `tokens` is the raw byte
4789    /// stream (caller is responsible for appending constant payloads).
4790    fn build_sato_raw(bit_depth: u8, tokens: &[u8]) -> Vec<u8> {
4791        let mut buf = Vec::with_capacity(2 + tokens.len());
4792        // version = 0 in the high 2 bits, bit_depth in the low 2.
4793        buf.push(bit_depth & 0x03);
4794        assert!(tokens.iter().filter(|&&t| t != 0).count() <= 255);
4795        // token_count = number of *tokens* (not bytes); the caller has
4796        // already serialised constant payloads inline. We need the
4797        // count of token bytes (each token is one byte plus optional
4798        // constant payload), which is hard to recover from bytes
4799        // alone, so the helpers below count it themselves.
4800        // Inserted by callers via build_sato_tokens.
4801        buf.push(0);
4802        buf.extend_from_slice(tokens);
4803        buf
4804    }
4805
4806    /// Build a well-formed `sato` payload with the given header
4807    /// `bit_depth` and an iterable of `Token`s. Constants serialise to
4808    /// the bit-depth-keyed byte width per spec Table 1.
4809    fn build_sato(bit_depth: u8, tokens: &[Token]) -> Vec<u8> {
4810        let const_bytes = match bit_depth & 0x03 {
4811            0 => 1,
4812            1 => 2,
4813            2 => 4,
4814            3 => 8,
4815            _ => unreachable!(),
4816        };
4817        let mut body = Vec::new();
4818        for t in tokens {
4819            match t {
4820                Token::Constant(v) => {
4821                    body.push(0);
4822                    match const_bytes {
4823                        1 => body.push(*v as i8 as u8),
4824                        2 => body.extend_from_slice(&(*v as i16).to_be_bytes()),
4825                        4 => body.extend_from_slice(&(*v as i32).to_be_bytes()),
4826                        8 => body.extend_from_slice(&v.to_be_bytes()),
4827                        _ => unreachable!(),
4828                    }
4829                }
4830                Token::Sample(n) => body.push(*n),
4831                Token::Unary(raw) | Token::Binary(raw) | Token::Reserved(raw) => body.push(*raw),
4832            }
4833        }
4834        let mut buf = build_sato_raw(bit_depth, &body);
4835        buf[1] = tokens.len() as u8;
4836        buf
4837    }
4838
4839    /// Round-trip a minimal constant-only expression at every supported
4840    /// bit_depth (8 / 16 / 32 / 64 bits intermediate).
4841    #[test]
4842    fn sato_parses_constant_only_each_bit_depth() {
4843        for (bd, expected_bits) in [(0, 8u32), (1, 16), (2, 32), (3, 64)] {
4844            let payload = build_sato(bd, &[Token::Constant(-1)]);
4845            let st = SampleTransform::parse(&payload, 0).unwrap();
4846            assert_eq!(st.version, 0);
4847            assert_eq!(st.bit_depth, bd);
4848            assert_eq!(st.num_bits(), expected_bits);
4849            assert_eq!(st.tokens, vec![Token::Constant(-1)]);
4850            // Evaluating with no inputs yields the constant.
4851            assert_eq!(st.evaluate(&[]).unwrap(), -1);
4852        }
4853    }
4854
4855    /// A two-sample postfix sum expression — Sample(1), Sample(2),
4856    /// Binary(128) — evaluates to L + R.
4857    #[test]
4858    fn sato_evaluates_sum_of_two_samples() {
4859        let payload = build_sato(0, &[Token::Sample(1), Token::Sample(2), Token::Binary(128)]);
4860        let st = SampleTransform::parse(&payload, 2).unwrap();
4861        assert_eq!(st.tokens.len(), 3);
4862        assert_eq!(st.evaluate(&[40, 25]).unwrap(), 65);
4863        // Overflow clamps to max_value for 8-bit intermediate.
4864        assert_eq!(st.evaluate(&[120, 120]).unwrap(), 127);
4865    }
4866
4867    /// Difference is right-popped first — av1-avif Table 2 row 129.
4868    /// `Sample(1) Sample(2) Binary(129)` is `L - R = inputs[0] -
4869    /// inputs[1]`.
4870    #[test]
4871    fn sato_evaluates_difference_with_right_pop_first() {
4872        let payload = build_sato(
4873            1, // 16-bit intermediate avoids 8-bit clamps
4874            &[Token::Sample(1), Token::Sample(2), Token::Binary(129)],
4875        );
4876        let st = SampleTransform::parse(&payload, 2).unwrap();
4877        assert_eq!(st.evaluate(&[100, 30]).unwrap(), 70);
4878        assert_eq!(st.evaluate(&[30, 100]).unwrap(), -70);
4879    }
4880
4881    /// Worked example from av1-avif Appendix A — combine an 8-bit MSB
4882    /// item and an 8-bit residual to produce a 16-bit result via
4883    /// `(msb << 8) | residual` ≡ Sample(1) Constant(256) * Sample(2) +.
4884    /// Verified via the `or` operator (bitwise) — spec accepts either.
4885    #[test]
4886    fn sato_evaluates_msb_residual_recombine() {
4887        // Tokens: Sample(1), Constant(8), Binary(135 pow → 2^8=256),
4888        //         Binary(130 product), Sample(2), Binary(128 sum)
4889        let payload = build_sato(
4890            1, // 16-bit intermediate
4891            &[
4892                Token::Sample(1),
4893                Token::Constant(2),
4894                Token::Constant(8),
4895                Token::Binary(135), // 2^8 = 256
4896                Token::Binary(130), // sample(1) * 256
4897                Token::Sample(2),
4898                Token::Binary(128), // + sample(2)
4899            ],
4900        );
4901        let st = SampleTransform::parse(&payload, 2).unwrap();
4902        // msb=0x12, lsb=0x34 → 0x1234 = 4660
4903        assert_eq!(st.evaluate(&[0x12, 0x34]).unwrap(), 0x1234);
4904    }
4905
4906    /// Unary `negation` (token 64) flips the sign of the top of stack.
4907    #[test]
4908    fn sato_evaluates_unary_negation() {
4909        let payload = build_sato(1, &[Token::Sample(1), Token::Unary(64)]);
4910        let st = SampleTransform::parse(&payload, 1).unwrap();
4911        assert_eq!(st.evaluate(&[42]).unwrap(), -42);
4912    }
4913
4914    /// Unary `bsr` (token 67) returns the MSB index for L > 0, else 0.
4915    #[test]
4916    fn sato_evaluates_unary_bsr() {
4917        let payload = build_sato(1, &[Token::Sample(1), Token::Unary(67)]);
4918        let st = SampleTransform::parse(&payload, 1).unwrap();
4919        assert_eq!(st.evaluate(&[1]).unwrap(), 0); // log2(1) = 0
4920        assert_eq!(st.evaluate(&[2]).unwrap(), 1);
4921        assert_eq!(st.evaluate(&[255]).unwrap(), 7);
4922        assert_eq!(st.evaluate(&[0]).unwrap(), 0); // spec: 0 for L <= 0
4923        assert_eq!(st.evaluate(&[-5]).unwrap(), 0);
4924    }
4925
4926    /// Quotient (token 131) returns L for R == 0, else L/R truncated
4927    /// toward zero.
4928    #[test]
4929    fn sato_evaluates_quotient_with_zero_divisor_returns_left() {
4930        let payload = build_sato(1, &[Token::Sample(1), Token::Sample(2), Token::Binary(131)]);
4931        let st = SampleTransform::parse(&payload, 2).unwrap();
4932        assert_eq!(st.evaluate(&[10, 3]).unwrap(), 3); // 10/3 → 3
4933        assert_eq!(st.evaluate(&[-10, 3]).unwrap(), -3); // truncate toward zero
4934        assert_eq!(st.evaluate(&[10, 0]).unwrap(), 10); // R == 0 → L
4935    }
4936
4937    /// Pow (token 135) returns 0 for L == 0, otherwise L^R truncated.
4938    #[test]
4939    fn sato_evaluates_pow_zero_base() {
4940        let payload = build_sato(1, &[Token::Sample(1), Token::Sample(2), Token::Binary(135)]);
4941        let st = SampleTransform::parse(&payload, 2).unwrap();
4942        assert_eq!(st.evaluate(&[0, 5]).unwrap(), 0);
4943        assert_eq!(st.evaluate(&[2, 3]).unwrap(), 8);
4944        assert_eq!(st.evaluate(&[3, 0]).unwrap(), 1);
4945    }
4946
4947    /// Min / max (tokens 136 / 137) pick the appropriate operand.
4948    #[test]
4949    fn sato_evaluates_min_max() {
4950        let min_p = build_sato(1, &[Token::Sample(1), Token::Sample(2), Token::Binary(136)]);
4951        let max_p = build_sato(1, &[Token::Sample(1), Token::Sample(2), Token::Binary(137)]);
4952        let min_st = SampleTransform::parse(&min_p, 2).unwrap();
4953        let max_st = SampleTransform::parse(&max_p, 2).unwrap();
4954        assert_eq!(min_st.evaluate(&[10, 3]).unwrap(), 3);
4955        assert_eq!(max_st.evaluate(&[10, 3]).unwrap(), 10);
4956        assert_eq!(min_st.evaluate(&[-5, -2]).unwrap(), -5);
4957        assert_eq!(max_st.evaluate(&[-5, -2]).unwrap(), -2);
4958    }
4959
4960    /// `token_count = 0` is rejected per av1-avif §4.2.3.3 assert
4961    /// `66976029`.
4962    #[test]
4963    fn sato_rejects_zero_token_count() {
4964        let buf = vec![0u8, 0]; // header, token_count=0
4965        let err = SampleTransform::parse(&buf, 0).unwrap_err();
4966        assert!(format!("{err:?}").contains("token_count"));
4967    }
4968
4969    /// version != 0 is rejected by the strict parser (spec: "Readers
4970    /// shall ignore" — we map that to an error so the caller decides).
4971    #[test]
4972    fn sato_rejects_nonzero_version() {
4973        let mut buf = build_sato(0, &[Token::Constant(0)]);
4974        buf[0] = 1u8 << 6; // version=1
4975        assert!(SampleTransform::parse(&buf, 0).is_err());
4976        // parse_relaxed still surfaces it.
4977        let relaxed = SampleTransform::parse_relaxed(&buf).unwrap();
4978        assert_eq!(relaxed.version, 1);
4979    }
4980
4981    /// A sample-operand token whose value exceeds reference_count is
4982    /// rejected per av1-avif §4.2.3.4 assert `1f569fa5`.
4983    #[test]
4984    fn sato_rejects_sample_index_over_reference_count() {
4985        let payload = build_sato(0, &[Token::Sample(5)]);
4986        assert!(SampleTransform::parse(&payload, 3).is_err());
4987        assert!(SampleTransform::parse(&payload, 5).is_ok());
4988    }
4989
4990    /// Reserved token values (33..=63, 68..=127, 138..=255) are
4991    /// rejected by `parse` per av1-avif §4.2.3.3.
4992    #[test]
4993    fn sato_rejects_reserved_token_values() {
4994        for raw in [33u8, 50, 63, 68, 100, 127, 138, 200, 255] {
4995            let payload = build_sato_raw(0, &[raw]);
4996            // Fix token_count to 1.
4997            let mut p = payload;
4998            p[1] = 1;
4999            assert!(
5000                SampleTransform::parse(&p, 32).is_err(),
5001                "expected error for reserved token {raw}"
5002            );
5003            // parse_relaxed surfaces it as a Reserved variant.
5004            let relaxed = SampleTransform::parse_relaxed(&p).unwrap();
5005            assert_eq!(relaxed.tokens, vec![Token::Reserved(raw)]);
5006        }
5007    }
5008
5009    /// A binary operator on a single-operand stack is caught by the
5010    /// stack-discipline check.
5011    #[test]
5012    fn sato_rejects_binary_op_without_enough_operands() {
5013        let payload = build_sato(0, &[Token::Sample(1), Token::Binary(128)]);
5014        assert!(SampleTransform::parse(&payload, 32).is_err());
5015    }
5016
5017    /// An expression that leaves more than one element on the stack
5018    /// is rejected (av1-avif §4.2.3.4 assert `bac41e3a`).
5019    #[test]
5020    fn sato_rejects_expression_with_leftover_stack() {
5021        let payload = build_sato(0, &[Token::Sample(1), Token::Sample(2)]);
5022        let err = SampleTransform::parse(&payload, 32).unwrap_err();
5023        assert!(format!("{err:?}").contains("leaves 2"));
5024    }
5025
5026    /// Truncated payload (claims `token_count = 3` but only ships 1
5027    /// token byte) is rejected.
5028    #[test]
5029    fn sato_rejects_truncated_token_stream() {
5030        let payload = vec![0u8, 3, 1]; // header, token_count=3, only one token
5031        assert!(SampleTransform::parse(&payload, 32).is_err());
5032        assert!(SampleTransform::parse_relaxed(&payload).is_err());
5033    }
5034
5035    /// Constant payload that runs off the end of the buffer is
5036    /// rejected (8-byte constant at `bit_depth=3` but only 4 bytes
5037    /// remaining).
5038    #[test]
5039    fn sato_rejects_truncated_constant_payload() {
5040        // bit_depth=3 → 8-byte constants
5041        let mut buf = vec![3u8, 1, 0]; // bit_depth=3, token_count=1, token=Constant
5042        buf.extend_from_slice(&[0u8, 0, 0, 0]); // only 4 bytes of the needed 8
5043        assert!(SampleTransform::parse(&buf, 0).is_err());
5044    }
5045
5046    /// Number-of-bits mapping matches av1-avif Table 1 verbatim.
5047    #[test]
5048    fn sato_num_bits_table_1() {
5049        let mk = |bd: u8| SampleTransform {
5050            version: 0,
5051            bit_depth: bd,
5052            tokens: vec![Token::Constant(0)],
5053        };
5054        assert_eq!(mk(0).num_bits(), 8);
5055        assert_eq!(mk(1).num_bits(), 16);
5056        assert_eq!(mk(2).num_bits(), 32);
5057        assert_eq!(mk(3).num_bits(), 64);
5058    }
5059
5060    /// min_value / max_value cover every supported bit depth.
5061    #[test]
5062    fn sato_min_max_value_per_bit_depth() {
5063        let mk = |bd: u8| SampleTransform {
5064            version: 0,
5065            bit_depth: bd,
5066            tokens: vec![Token::Constant(0)],
5067        };
5068        assert_eq!(mk(0).min_value(), -128);
5069        assert_eq!(mk(0).max_value(), 127);
5070        assert_eq!(mk(1).min_value(), -32_768);
5071        assert_eq!(mk(1).max_value(), 32_767);
5072        assert_eq!(mk(2).min_value(), i32::MIN as i64);
5073        assert_eq!(mk(2).max_value(), i32::MAX as i64);
5074        assert_eq!(mk(3).min_value(), i64::MIN);
5075        assert_eq!(mk(3).max_value(), i64::MAX);
5076    }
5077
5078    /// `Token::is_operand` / `is_unary` / `is_binary` classification
5079    /// matches the variant the parser yields.
5080    #[test]
5081    fn sato_token_classification_helpers() {
5082        assert!(Token::Constant(0).is_operand());
5083        assert!(Token::Sample(1).is_operand());
5084        assert!(!Token::Unary(64).is_operand());
5085        assert!(!Token::Binary(128).is_operand());
5086        assert!(Token::Unary(64).is_unary());
5087        assert!(!Token::Binary(128).is_unary());
5088        assert!(Token::Binary(128).is_binary());
5089        assert!(!Token::Unary(64).is_binary());
5090    }
5091
5092    /// `evaluate` errors when given fewer inputs than the expression
5093    /// requires (defence in depth — parse() also enforces the
5094    /// reference_count constraint).
5095    #[test]
5096    fn sato_evaluate_errors_when_inputs_short() {
5097        let payload = build_sato(0, &[Token::Sample(3)]);
5098        let st = SampleTransform::parse(&payload, 3).unwrap();
5099        let err = st.evaluate(&[1, 2]).unwrap_err();
5100        assert!(format!("{err:?}").contains("out of range"));
5101    }
5102
5103    // -------------------------------------------------------------------
5104    // Tone Map compliance audit (av1-avif v1.2.0 §4.2.2)
5105    // -------------------------------------------------------------------
5106
5107    use crate::meta::{IrefEntry, ItemInfo, Meta};
5108
5109    fn make_infe(id: u32, item_type: &[u8; 4], flags: u32) -> ItemInfo {
5110        ItemInfo {
5111            id,
5112            item_type: *item_type,
5113            name: String::new(),
5114            content_type: None,
5115            content_encoding: None,
5116            item_uri_type: None,
5117            flags,
5118        }
5119    }
5120
5121    /// Build a `grpl` payload containing one `altr` EntityToGroupBox
5122    /// over the given ids.
5123    fn build_altr_grpl(group_id: u32, ids: &[u32]) -> Vec<u8> {
5124        let mut child = vec![0u8; 4]; // FullBox(version=0, flags=0)
5125        child.extend_from_slice(&group_id.to_be_bytes());
5126        child.extend_from_slice(&(ids.len() as u32).to_be_bytes());
5127        for id in ids {
5128            child.extend_from_slice(&id.to_be_bytes());
5129        }
5130        let size = (8 + child.len()) as u32;
5131        let mut buf = Vec::new();
5132        buf.extend_from_slice(&size.to_be_bytes());
5133        buf.extend_from_slice(b"altr");
5134        buf.extend_from_slice(&child);
5135        buf
5136    }
5137
5138    /// Happy path: one tmap item with one base (no gain map), grouped
5139    /// with the base by an `altr` entity group. Compliant.
5140    #[test]
5141    fn audit_tone_map_altr_pairing_compliant() {
5142        let meta = Meta {
5143            items: vec![
5144                make_infe(1, b"av01", 0), // base, visible
5145                make_infe(2, b"tmap", 0), // tmap derived item
5146            ],
5147            irefs: vec![IrefEntry {
5148                reference_type: *b"dimg",
5149                from_id: 2,
5150                to_ids: vec![1],
5151            }],
5152            grpl: Some(build_altr_grpl(7, &[1, 2])),
5153            ..Meta::default()
5154        };
5155        let results = audit_tone_map(&meta);
5156        assert_eq!(results.len(), 1);
5157        let r = &results[0];
5158        assert_eq!(r.tmap_item_id, 2);
5159        assert_eq!(r.base_item_id, Some(1));
5160        assert!(r.gain_map_item_ids.is_empty());
5161        assert!(r.paired_in_altr);
5162        assert!(r.gain_maps_hidden);
5163        assert!(r.is_compliant());
5164        assert!(r.missing().is_empty());
5165    }
5166
5167    /// tmap + base + gain map, where the gain map is hidden as the
5168    /// spec recommends. Should pass when there's also an `altr` group.
5169    #[test]
5170    fn audit_tone_map_hidden_gain_map_compliant() {
5171        let meta = Meta {
5172            items: vec![
5173                make_infe(1, b"av01", 0),    // base, visible
5174                make_infe(2, b"av01", 0x01), // gain map, hidden
5175                make_infe(3, b"tmap", 0),    // tmap derived item
5176            ],
5177            irefs: vec![IrefEntry {
5178                reference_type: *b"dimg",
5179                from_id: 3,
5180                to_ids: vec![1, 2],
5181            }],
5182            grpl: Some(build_altr_grpl(9, &[1, 3])),
5183            ..Meta::default()
5184        };
5185        let r = &audit_tone_map(&meta)[0];
5186        assert_eq!(r.base_item_id, Some(1));
5187        assert_eq!(r.gain_map_item_ids, vec![2]);
5188        assert!(r.paired_in_altr);
5189        assert!(r.gain_maps_hidden);
5190        assert!(r.is_compliant());
5191    }
5192
5193    /// No `grpl` at all → `altr` pairing fails; visible gain map →
5194    /// gain-map hidden check fails. Both `missing` entries surface.
5195    #[test]
5196    fn audit_tone_map_flags_both_failures() {
5197        let meta = Meta {
5198            items: vec![
5199                make_infe(1, b"av01", 0), // base, visible
5200                make_infe(2, b"av01", 0), // gain map NOT hidden
5201                make_infe(3, b"tmap", 0),
5202            ],
5203            irefs: vec![IrefEntry {
5204                reference_type: *b"dimg",
5205                from_id: 3,
5206                to_ids: vec![1, 2],
5207            }],
5208            grpl: None,
5209            ..Meta::default()
5210        };
5211        let r = &audit_tone_map(&meta)[0];
5212        assert!(!r.paired_in_altr);
5213        assert!(!r.gain_maps_hidden);
5214        assert!(!r.is_compliant());
5215        let m = r.missing();
5216        assert!(m.contains(&"altr-pairs-base-and-tmap"));
5217        assert!(m.contains(&"gain-map-hidden"));
5218    }
5219
5220    /// `grpl` present but the `altr` group lists only the base — the
5221    /// pairing check should still fail when the tmap id is absent.
5222    #[test]
5223    fn audit_tone_map_altr_without_tmap_id_fails_pairing() {
5224        let meta = Meta {
5225            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"tmap", 0)],
5226            irefs: vec![IrefEntry {
5227                reference_type: *b"dimg",
5228                from_id: 2,
5229                to_ids: vec![1],
5230            }],
5231            grpl: Some(build_altr_grpl(11, &[1, 99])), // tmap not in altr
5232            ..Meta::default()
5233        };
5234        let r = &audit_tone_map(&meta)[0];
5235        assert!(!r.paired_in_altr);
5236        // No gain map → hidden check trivially true.
5237        assert!(r.gain_maps_hidden);
5238        assert!(!r.is_compliant());
5239    }
5240
5241    /// A tmap with no `dimg` iref at all surfaces `base_item_id =
5242    /// None` and fails the altr-pairing check (nothing to pair with).
5243    #[test]
5244    fn audit_tone_map_no_dimg_iref() {
5245        let meta = Meta {
5246            items: vec![make_infe(2, b"tmap", 0)],
5247            irefs: vec![],
5248            grpl: None,
5249            ..Meta::default()
5250        };
5251        let r = &audit_tone_map(&meta)[0];
5252        assert_eq!(r.base_item_id, None);
5253        assert!(r.gain_map_item_ids.is_empty());
5254        assert!(!r.paired_in_altr);
5255        // No gain map → hidden check trivially true.
5256        assert!(r.gain_maps_hidden);
5257    }
5258
5259    /// File with no `tmap` items returns an empty audit list.
5260    #[test]
5261    fn audit_tone_map_empty_when_no_tmap_items() {
5262        let meta = Meta {
5263            items: vec![make_infe(1, b"av01", 0)],
5264            ..Meta::default()
5265        };
5266        assert!(audit_tone_map(&meta).is_empty());
5267    }
5268
5269    /// Multiple `tmap` items audited in `iinf` declaration order.
5270    #[test]
5271    fn audit_tone_map_reports_each_tmap_item() {
5272        let meta = Meta {
5273            items: vec![
5274                make_infe(1, b"av01", 0),
5275                make_infe(2, b"tmap", 0),
5276                make_infe(3, b"av01", 0x01), // hidden
5277                make_infe(4, b"tmap", 0),
5278            ],
5279            irefs: vec![
5280                IrefEntry {
5281                    reference_type: *b"dimg",
5282                    from_id: 2,
5283                    to_ids: vec![1],
5284                },
5285                IrefEntry {
5286                    reference_type: *b"dimg",
5287                    from_id: 4,
5288                    to_ids: vec![1, 3],
5289                },
5290            ],
5291            grpl: Some(build_altr_grpl(1, &[1, 2, 4])),
5292            ..Meta::default()
5293        };
5294        let r = audit_tone_map(&meta);
5295        assert_eq!(r.len(), 2);
5296        assert_eq!(r[0].tmap_item_id, 2);
5297        assert_eq!(r[1].tmap_item_id, 4);
5298        assert!(r[0].is_compliant());
5299        assert!(r[1].is_compliant());
5300    }
5301
5302    /// `ItemInfo::is_hidden` honours `flags & 0x01` and ignores higher
5303    /// bits.
5304    #[test]
5305    fn item_info_is_hidden_reads_low_bit_only() {
5306        assert!(!make_infe(1, b"av01", 0).is_hidden());
5307        assert!(make_infe(1, b"av01", 0x01).is_hidden());
5308        // High bits set but bit 0 clear → not hidden.
5309        assert!(!make_infe(1, b"av01", 0xfffe).is_hidden());
5310        // Mixed: bit 0 set + other bits set → hidden.
5311        assert!(make_infe(1, b"av01", 0xff03).is_hidden());
5312    }
5313
5314    // -------------------------------------------------------------------
5315    // Grid derivation chain — transformative-property audit (av1-avif §7)
5316    // -------------------------------------------------------------------
5317
5318    use crate::meta::{Clap, Imir, Irot, ItemPropertyAssociation, Property, PropertyAssociation};
5319
5320    /// Sample Clap value — arbitrary, the audit only cares about presence.
5321    fn sample_clap() -> Clap {
5322        Clap {
5323            clean_aperture_width_n: 1,
5324            clean_aperture_width_d: 1,
5325            clean_aperture_height_n: 1,
5326            clean_aperture_height_d: 1,
5327            horiz_off_n: 0,
5328            horiz_off_d: 1,
5329            vert_off_n: 0,
5330            vert_off_d: 1,
5331        }
5332    }
5333
5334    fn assoc(item_id: u32, indices: &[u16]) -> ItemPropertyAssociation {
5335        ItemPropertyAssociation {
5336            item_id,
5337            entries: indices
5338                .iter()
5339                .map(|i| PropertyAssociation {
5340                    index: *i,
5341                    essential: false,
5342                })
5343                .collect(),
5344        }
5345    }
5346
5347    /// Compliant grid: no tile carries a transformative property — the
5348    /// grid item may itself carry `irot` / `imir` / `clap` (we put `irot`
5349    /// on the grid here to prove the audit ignores grid-level transforms).
5350    #[test]
5351    fn audit_grid_derivations_clean_chain_compliant() {
5352        let meta = Meta {
5353            items: vec![
5354                make_infe(1, b"grid", 0),
5355                make_infe(2, b"av01", 0),
5356                make_infe(3, b"av01", 0),
5357            ],
5358            irefs: vec![IrefEntry {
5359                reference_type: *b"dimg",
5360                from_id: 1,
5361                to_ids: vec![2, 3],
5362            }],
5363            // Index 0: irot on the grid item (permitted by §7).
5364            properties: vec![Property::Irot(Irot { angle: 1 })],
5365            associations: vec![assoc(1, &[0])],
5366            ..Meta::default()
5367        };
5368        let r = audit_grid_derivations(&meta);
5369        assert_eq!(r.len(), 1);
5370        assert_eq!(r[0].grid_item_id, 1);
5371        assert_eq!(r[0].tile_item_ids, vec![2, 3]);
5372        assert!(
5373            r[0].offenders.is_empty(),
5374            "grid-level transforms must not flag the chain"
5375        );
5376        assert!(r[0].is_compliant());
5377        assert!(r[0].offending_tile_ids().is_empty());
5378    }
5379
5380    /// Non-compliant grid: a tile carries `irot`. The audit reports the
5381    /// offending tile + `irot` kind, and `is_compliant` flips to false.
5382    #[test]
5383    fn audit_grid_derivations_tile_irot_flagged() {
5384        let meta = Meta {
5385            items: vec![
5386                make_infe(1, b"grid", 0),
5387                make_infe(2, b"av01", 0),
5388                make_infe(3, b"av01", 0),
5389            ],
5390            irefs: vec![IrefEntry {
5391                reference_type: *b"dimg",
5392                from_id: 1,
5393                to_ids: vec![2, 3],
5394            }],
5395            properties: vec![Property::Irot(Irot { angle: 2 })],
5396            // Tile 3 carries the property — chain violation.
5397            associations: vec![assoc(3, &[0])],
5398            ..Meta::default()
5399        };
5400        let r = &audit_grid_derivations(&meta)[0];
5401        assert!(!r.is_compliant());
5402        assert_eq!(r.offenders, vec![(3, *b"irot")]);
5403        assert_eq!(r.offending_tile_ids(), vec![3]);
5404    }
5405
5406    /// A single tile carrying all three transformative kinds surfaces
5407    /// three offender entries in the stable `(clap, irot, imir)` order
5408    /// the audit produces.
5409    #[test]
5410    fn audit_grid_derivations_tile_with_all_four_kinds() {
5411        let meta = Meta {
5412            items: vec![make_infe(1, b"grid", 0), make_infe(2, b"av01", 0)],
5413            irefs: vec![IrefEntry {
5414                reference_type: *b"dimg",
5415                from_id: 1,
5416                to_ids: vec![2],
5417            }],
5418            properties: vec![
5419                Property::Clap(sample_clap()),
5420                Property::Irot(Irot { angle: 1 }),
5421                Property::Imir(Imir { axis: 0 }),
5422                Property::Iscl(crate::meta::Iscl {
5423                    target_width_numerator: 1,
5424                    target_width_denominator: 2,
5425                    target_height_numerator: 1,
5426                    target_height_denominator: 2,
5427                }),
5428            ],
5429            associations: vec![assoc(2, &[0, 1, 2, 3])],
5430            ..Meta::default()
5431        };
5432        let r = &audit_grid_derivations(&meta)[0];
5433        assert_eq!(
5434            r.offenders,
5435            vec![(2, *b"clap"), (2, *b"irot"), (2, *b"imir"), (2, *b"iscl"),]
5436        );
5437        // Tile id is unique even though it offends four times.
5438        assert_eq!(r.offending_tile_ids(), vec![2]);
5439    }
5440
5441    /// HEIF §6.5.13 `'iscl'` is a transformative item property; av1-avif
5442    /// §7 forbids any transformative property on a `'dimg'` input tile.
5443    /// A solo `'iscl'` on a tile must surface as an offender.
5444    #[test]
5445    fn audit_grid_derivations_tile_iscl_flagged() {
5446        let meta = Meta {
5447            items: vec![
5448                make_infe(1, b"grid", 0),
5449                make_infe(2, b"av01", 0),
5450                make_infe(3, b"av01", 0),
5451            ],
5452            irefs: vec![IrefEntry {
5453                reference_type: *b"dimg",
5454                from_id: 1,
5455                to_ids: vec![2, 3],
5456            }],
5457            properties: vec![Property::Iscl(crate::meta::Iscl {
5458                target_width_numerator: 1,
5459                target_width_denominator: 2,
5460                target_height_numerator: 1,
5461                target_height_denominator: 2,
5462            })],
5463            // Only tile 2 carries the iscl; tile 3 is clean.
5464            associations: vec![assoc(2, &[0])],
5465            ..Meta::default()
5466        };
5467        let r = &audit_grid_derivations(&meta)[0];
5468        assert!(!r.is_compliant());
5469        assert_eq!(r.offenders, vec![(2, *b"iscl")]);
5470        assert_eq!(r.offending_tile_ids(), vec![2]);
5471    }
5472
5473    /// HEIF §6.5.17 `'rref'` is descriptive (not transformative). A
5474    /// tile carrying `'rref'` must NOT be reported by the §7 audit —
5475    /// the §7 `shall` is scoped to transformative properties only.
5476    #[test]
5477    fn audit_grid_derivations_tile_rref_not_flagged() {
5478        let meta = Meta {
5479            items: vec![make_infe(1, b"grid", 0), make_infe(2, b"av01", 0)],
5480            irefs: vec![IrefEntry {
5481                reference_type: *b"dimg",
5482                from_id: 1,
5483                to_ids: vec![2],
5484            }],
5485            properties: vec![Property::Rref(crate::meta::Rref {
5486                reference_types: vec![*b"dimg"],
5487            })],
5488            associations: vec![assoc(2, &[0])],
5489            ..Meta::default()
5490        };
5491        let r = &audit_grid_derivations(&meta)[0];
5492        assert!(r.is_compliant());
5493        assert!(r.offenders.is_empty());
5494    }
5495
5496    /// An `'iscl'` on the grid item itself is explicitly permitted by
5497    /// §7 ("transformations are only permitted on the grid item
5498    /// itself"), so it must not surface as an offender.
5499    #[test]
5500    fn audit_grid_derivations_grid_level_iscl_permitted() {
5501        let meta = Meta {
5502            items: vec![make_infe(1, b"grid", 0), make_infe(2, b"av01", 0)],
5503            irefs: vec![IrefEntry {
5504                reference_type: *b"dimg",
5505                from_id: 1,
5506                to_ids: vec![2],
5507            }],
5508            properties: vec![Property::Iscl(crate::meta::Iscl {
5509                target_width_numerator: 1,
5510                target_width_denominator: 2,
5511                target_height_numerator: 1,
5512                target_height_denominator: 2,
5513            })],
5514            // Iscl associated with the grid (id 1), tile 2 clean.
5515            associations: vec![assoc(1, &[0])],
5516            ..Meta::default()
5517        };
5518        let r = &audit_grid_derivations(&meta)[0];
5519        assert!(r.is_compliant());
5520        assert!(r.offenders.is_empty());
5521    }
5522
5523    /// Two tiles offending in different ways. Both surface; the unique
5524    /// tile-id list collapses duplicates.
5525    #[test]
5526    fn audit_grid_derivations_multiple_offending_tiles() {
5527        let meta = Meta {
5528            items: vec![
5529                make_infe(1, b"grid", 0),
5530                make_infe(2, b"av01", 0),
5531                make_infe(3, b"av01", 0),
5532                make_infe(4, b"av01", 0),
5533            ],
5534            irefs: vec![IrefEntry {
5535                reference_type: *b"dimg",
5536                from_id: 1,
5537                to_ids: vec![2, 3, 4],
5538            }],
5539            properties: vec![
5540                Property::Imir(Imir { axis: 1 }),
5541                Property::Clap(sample_clap()),
5542            ],
5543            associations: vec![
5544                // Tile 3 carries imir; tile 4 carries clap; tile 2 clean.
5545                assoc(3, &[0]),
5546                assoc(4, &[1]),
5547            ],
5548            ..Meta::default()
5549        };
5550        let r = &audit_grid_derivations(&meta)[0];
5551        assert!(!r.is_compliant());
5552        // clap-on-4 sorts before imir-on-3 in the per-tile loop:
5553        // we walk tiles in dimg order (2, 3, 4) and emit (clap, irot, imir)
5554        // per tile, so the result is [(3, imir), (4, clap)].
5555        assert_eq!(r.offenders, vec![(3, *b"imir"), (4, *b"clap")]);
5556        assert_eq!(r.offending_tile_ids(), vec![3, 4]);
5557    }
5558
5559    /// File with no `grid` items returns an empty audit list — the
5560    /// constraint is vacuous and the strict-compliant predicate folds
5561    /// to true.
5562    #[test]
5563    fn audit_grid_derivations_empty_when_no_grid_items() {
5564        let meta = Meta {
5565            items: vec![make_infe(1, b"av01", 0)],
5566            ..Meta::default()
5567        };
5568        assert!(audit_grid_derivations(&meta).is_empty());
5569    }
5570
5571    /// Multiple grid items each get their own audit record, in `iinf`
5572    /// declaration order.
5573    #[test]
5574    fn audit_grid_derivations_reports_each_grid_item() {
5575        let meta = Meta {
5576            items: vec![
5577                make_infe(1, b"grid", 0), // first grid
5578                make_infe(2, b"av01", 0),
5579                make_infe(3, b"av01", 0),
5580                make_infe(4, b"grid", 0), // second grid
5581                make_infe(5, b"av01", 0),
5582            ],
5583            irefs: vec![
5584                IrefEntry {
5585                    reference_type: *b"dimg",
5586                    from_id: 1,
5587                    to_ids: vec![2, 3],
5588                },
5589                IrefEntry {
5590                    reference_type: *b"dimg",
5591                    from_id: 4,
5592                    to_ids: vec![5],
5593                },
5594            ],
5595            properties: vec![Property::Irot(Irot { angle: 3 })],
5596            // Tile 5 (second grid's tile) carries an irot.
5597            associations: vec![assoc(5, &[0])],
5598            ..Meta::default()
5599        };
5600        let r = audit_grid_derivations(&meta);
5601        assert_eq!(r.len(), 2);
5602        assert_eq!(r[0].grid_item_id, 1);
5603        assert!(r[0].is_compliant());
5604        assert_eq!(r[1].grid_item_id, 4);
5605        assert!(!r[1].is_compliant());
5606        assert_eq!(r[1].offenders, vec![(5, *b"irot")]);
5607    }
5608
5609    /// A grid item with no `dimg` iref still emits an audit record with
5610    /// empty tile + offender vectors. The constraint is trivially
5611    /// satisfied (no chain → no offending tiles); a malformed grid is
5612    /// caught elsewhere.
5613    #[test]
5614    fn audit_grid_derivations_grid_without_dimg_is_compliant() {
5615        let meta = Meta {
5616            items: vec![make_infe(1, b"grid", 0)],
5617            ..Meta::default()
5618        };
5619        let r = &audit_grid_derivations(&meta)[0];
5620        assert_eq!(r.grid_item_id, 1);
5621        assert!(r.tile_item_ids.is_empty());
5622        assert!(r.is_compliant());
5623    }
5624
5625    // -------------------------------------------------------------------
5626    // Identity (`iden`) compliance audit (HEIF §6.6.2.1 + §6.6.1)
5627    // -------------------------------------------------------------------
5628
5629    use crate::meta::{IlocExtent, ItemLocation};
5630
5631    /// Build a placeholder [`ItemLocation`] with a single non-empty
5632    /// extent — useful for the `has_item_body == true` (non-conformant)
5633    /// shape.
5634    fn make_iloc_with_body(id: u32) -> ItemLocation {
5635        ItemLocation {
5636            id,
5637            construction_method: 0,
5638            data_reference_index: 0,
5639            base_offset: 0,
5640            extents: vec![IlocExtent {
5641                offset: 1000,
5642                length: 16,
5643                extent_index: 0,
5644            }],
5645        }
5646    }
5647
5648    /// `ItemLocation` with one extent whose `length == 0`. HEIF §6.6.2.1
5649    /// requires the iden to have no body; a zero-length extent is
5650    /// equivalent (no actual bytes) and the audit treats it as compliant.
5651    fn make_iloc_zero_length(id: u32) -> ItemLocation {
5652        ItemLocation {
5653            id,
5654            construction_method: 0,
5655            data_reference_index: 0,
5656            base_offset: 0,
5657            extents: vec![IlocExtent {
5658                offset: 0,
5659                length: 0,
5660                extent_index: 0,
5661            }],
5662        }
5663    }
5664
5665    /// Happy path: one `'iden'` item with exactly one `'dimg'` input,
5666    /// exactly one `'dimg'` iref entry for the iden's `from_item_ID`,
5667    /// and no `'iloc'` entry at all (so trivially no body).
5668    #[test]
5669    fn audit_iden_compliant_no_iloc_entry() {
5670        let meta = Meta {
5671            items: vec![
5672                make_infe(1, b"av01", 0), // source
5673                make_infe(2, b"iden", 0), // identity derivation
5674            ],
5675            irefs: vec![IrefEntry {
5676                reference_type: *b"dimg",
5677                from_id: 2,
5678                to_ids: vec![1],
5679            }],
5680            ..Meta::default()
5681        };
5682        let r = audit_iden_derivations(&meta);
5683        assert_eq!(r.len(), 1);
5684        assert_eq!(r[0].iden_item_id, 2);
5685        assert_eq!(r[0].dimg_reference_count, 1);
5686        assert_eq!(r[0].dimg_iref_count, 1);
5687        assert!(!r[0].has_item_body);
5688        assert_eq!(r[0].source_item_id, Some(1));
5689        assert!(r[0].is_compliant());
5690        assert!(r[0].missing().is_empty());
5691    }
5692
5693    /// An `'iden'` with an `'iloc'` entry that carries a zero-length
5694    /// extent passes the "no item body" check — the spec defines "body"
5695    /// in terms of actual byte content, not the presence of an iloc
5696    /// entry. Compliant.
5697    #[test]
5698    fn audit_iden_compliant_iloc_with_zero_length_extent() {
5699        let meta = Meta {
5700            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"iden", 0)],
5701            locations: vec![make_iloc_zero_length(2)],
5702            irefs: vec![IrefEntry {
5703                reference_type: *b"dimg",
5704                from_id: 2,
5705                to_ids: vec![1],
5706            }],
5707            ..Meta::default()
5708        };
5709        let r = &audit_iden_derivations(&meta)[0];
5710        assert!(!r.has_item_body);
5711        assert!(r.is_compliant());
5712    }
5713
5714    /// HEIF §6.6.2.1: an `'iden'` item with a non-empty extent has a
5715    /// body, which is forbidden. The audit flags it.
5716    #[test]
5717    fn audit_iden_flags_non_empty_item_body() {
5718        let meta = Meta {
5719            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"iden", 0)],
5720            locations: vec![make_iloc_with_body(2)],
5721            irefs: vec![IrefEntry {
5722                reference_type: *b"dimg",
5723                from_id: 2,
5724                to_ids: vec![1],
5725            }],
5726            ..Meta::default()
5727        };
5728        let r = &audit_iden_derivations(&meta)[0];
5729        assert!(r.has_item_body);
5730        assert!(!r.is_compliant());
5731        assert!(r.missing().contains(&"no-item-body"));
5732    }
5733
5734    /// HEIF §6.6.2.1: `reference_count` of an `'iden'`'s `'dimg'` iref
5735    /// shall be exactly 1. A zero-input iden fails.
5736    #[test]
5737    fn audit_iden_flags_zero_dimg_inputs() {
5738        let meta = Meta {
5739            items: vec![make_infe(2, b"iden", 0)],
5740            irefs: vec![],
5741            ..Meta::default()
5742        };
5743        let r = &audit_iden_derivations(&meta)[0];
5744        assert_eq!(r.dimg_reference_count, 0);
5745        assert_eq!(r.dimg_iref_count, 0);
5746        assert_eq!(r.source_item_id, None);
5747        assert!(!r.is_compliant());
5748        let m = r.missing();
5749        assert!(m.contains(&"dimg-reference-count-eq-1"));
5750        assert!(m.contains(&"dimg-iref-count-eq-1"));
5751    }
5752
5753    /// HEIF §6.6.2.1: `reference_count` of an `'iden'`'s `'dimg'` iref
5754    /// shall be exactly 1. Two inputs fail. (HEIF §6.6.1 separately
5755    /// forbids multiple iref entries with the same `from_item_ID`; this
5756    /// case is one entry with reference_count = 2, so §6.6.1 is OK but
5757    /// §6.6.2.1 is not.)
5758    #[test]
5759    fn audit_iden_flags_two_dimg_inputs_in_single_iref() {
5760        let meta = Meta {
5761            items: vec![
5762                make_infe(1, b"av01", 0),
5763                make_infe(2, b"av01", 0),
5764                make_infe(3, b"iden", 0),
5765            ],
5766            irefs: vec![IrefEntry {
5767                reference_type: *b"dimg",
5768                from_id: 3,
5769                to_ids: vec![1, 2],
5770            }],
5771            ..Meta::default()
5772        };
5773        let r = &audit_iden_derivations(&meta)[0];
5774        assert_eq!(r.dimg_reference_count, 2);
5775        // Single iref entry → §6.6.1 still satisfied even though it
5776        // carries two to_ids.
5777        assert_eq!(r.dimg_iref_count, 1);
5778        assert_eq!(r.source_item_id, None);
5779        assert!(!r.is_compliant());
5780        assert!(r.missing().contains(&"dimg-reference-count-eq-1"));
5781        assert!(!r.missing().contains(&"dimg-iref-count-eq-1"));
5782    }
5783
5784    /// HEIF §6.6.1: number of `'dimg'` SingleItemTypeReferenceBoxes with
5785    /// the same `from_item_ID` shall be at most 1. Two separate iref
5786    /// entries fail.
5787    #[test]
5788    fn audit_iden_flags_multiple_dimg_iref_entries() {
5789        let meta = Meta {
5790            items: vec![
5791                make_infe(1, b"av01", 0),
5792                make_infe(2, b"av01", 0),
5793                make_infe(3, b"iden", 0),
5794            ],
5795            irefs: vec![
5796                IrefEntry {
5797                    reference_type: *b"dimg",
5798                    from_id: 3,
5799                    to_ids: vec![1],
5800                },
5801                IrefEntry {
5802                    reference_type: *b"dimg",
5803                    from_id: 3,
5804                    to_ids: vec![2],
5805                },
5806            ],
5807            ..Meta::default()
5808        };
5809        let r = &audit_iden_derivations(&meta)[0];
5810        assert_eq!(r.dimg_iref_count, 2);
5811        // `iref_targets` returns the first matching entry only, so the
5812        // observed reference_count is 1 (from entry 0). §6.6.1 violation
5813        // is reported via `dimg_iref_count`.
5814        assert_eq!(r.dimg_reference_count, 1);
5815        assert!(!r.is_compliant());
5816        assert!(r.missing().contains(&"dimg-iref-count-eq-1"));
5817    }
5818
5819    /// File with no `'iden'` items returns an empty audit list. Other
5820    /// derived items (`grid`, `tmap`, `sato`, `iovl`) are not picked up.
5821    #[test]
5822    fn audit_iden_empty_when_no_iden_items() {
5823        let meta = Meta {
5824            items: vec![
5825                make_infe(1, b"av01", 0),
5826                make_infe(2, b"grid", 0),
5827                make_infe(3, b"tmap", 0),
5828                make_infe(4, b"sato", 0),
5829                make_infe(5, b"iovl", 0),
5830            ],
5831            ..Meta::default()
5832        };
5833        assert!(audit_iden_derivations(&meta).is_empty());
5834    }
5835
5836    /// Multiple `'iden'` items are audited in `iinf` declaration order.
5837    /// One compliant + one with a forbidden item body — the two records
5838    /// surface independently with the right ids.
5839    #[test]
5840    fn audit_iden_reports_each_iden_item_in_iinf_order() {
5841        let meta = Meta {
5842            items: vec![
5843                make_infe(1, b"av01", 0),
5844                make_infe(2, b"iden", 0), // compliant
5845                make_infe(3, b"av01", 0),
5846                make_infe(4, b"iden", 0), // has body → non-compliant
5847            ],
5848            locations: vec![make_iloc_with_body(4)],
5849            irefs: vec![
5850                IrefEntry {
5851                    reference_type: *b"dimg",
5852                    from_id: 2,
5853                    to_ids: vec![1],
5854                },
5855                IrefEntry {
5856                    reference_type: *b"dimg",
5857                    from_id: 4,
5858                    to_ids: vec![3],
5859                },
5860            ],
5861            ..Meta::default()
5862        };
5863        let r = audit_iden_derivations(&meta);
5864        assert_eq!(r.len(), 2);
5865        assert_eq!(r[0].iden_item_id, 2);
5866        assert!(r[0].is_compliant());
5867        assert_eq!(r[1].iden_item_id, 4);
5868        assert!(!r[1].is_compliant());
5869        assert!(r[1].has_item_body);
5870    }
5871
5872    /// A non-`'dimg'` iref of the same shape (e.g. `'cdsc'`) must not
5873    /// be counted by the audit — only `'dimg'` references contribute to
5874    /// HEIF §6.6.2.1 / §6.6.1 input enumeration.
5875    #[test]
5876    fn audit_iden_ignores_non_dimg_irefs() {
5877        let meta = Meta {
5878            items: vec![
5879                make_infe(1, b"av01", 0),
5880                make_infe(2, b"iden", 0),
5881                make_infe(9, b"Exif", 0),
5882            ],
5883            irefs: vec![
5884                IrefEntry {
5885                    reference_type: *b"dimg",
5886                    from_id: 2,
5887                    to_ids: vec![1],
5888                },
5889                // `cdsc` from a metadata item — irrelevant to iden input.
5890                IrefEntry {
5891                    reference_type: *b"cdsc",
5892                    from_id: 9,
5893                    to_ids: vec![2],
5894                },
5895            ],
5896            ..Meta::default()
5897        };
5898        let r = &audit_iden_derivations(&meta)[0];
5899        assert_eq!(r.dimg_iref_count, 1);
5900        assert_eq!(r.dimg_reference_count, 1);
5901        assert!(r.is_compliant());
5902    }
5903
5904    // -------------------------------------------------------------------
5905    // AV1 Alpha bit-depth match audit (av1-avif v1.2.0 §4.1)
5906    // -------------------------------------------------------------------
5907
5908    use crate::meta::{AuxC, ItemPropertyAssociation as Ipa, PropertyAssociation as Pa};
5909
5910    /// Build an `av1C` payload whose first three bytes carry the
5911    /// `(high_bitdepth, twelve_bit)` flag pair for the given bit depth.
5912    /// Bit 7 is the `marker` (always 1), bits 6..0 of byte 0 are
5913    /// `version` (always 1) — but the audit only consults byte 2, so
5914    /// we just zero the first two bytes here.
5915    fn av1c_with_bit_depth(bit_depth: u8) -> Vec<u8> {
5916        let (high, twelve) = match bit_depth {
5917            8 => (0u8, 0u8),
5918            10 => (1, 0),
5919            12 => (1, 1),
5920            _ => panic!("test helper supports 8/10/12 only"),
5921        };
5922        // byte 0: marker(1) + version(7) — recreated nominally as 0x81.
5923        // byte 1: profile/seq_level — irrelevant to bit depth.
5924        // byte 2: high_bitdepth(1)<<6 | twelve_bit(1)<<5 | rest.
5925        let b2 = (high << 6) | (twelve << 5);
5926        vec![0x81, 0x00, b2]
5927    }
5928
5929    fn ipa(item_id: u32, indices: &[u16]) -> Ipa {
5930        Ipa {
5931            item_id,
5932            entries: indices
5933                .iter()
5934                .map(|i| Pa {
5935                    index: *i,
5936                    essential: false,
5937                })
5938                .collect(),
5939        }
5940    }
5941
5942    fn make_alpha_auxc() -> AuxC {
5943        AuxC {
5944            aux_type: crate::alpha::ALPHA_URN_PREFIX.to_string(),
5945            aux_subtype: Vec::new(),
5946        }
5947    }
5948
5949    fn make_depth_auxc() -> AuxC {
5950        AuxC {
5951            aux_type: crate::meta::AUX_URN_DEPTH_MPEG.to_string(),
5952            aux_subtype: Vec::new(),
5953        }
5954    }
5955
5956    /// `decode_av1c_bit_depth` covers the three legal AV1 depths.
5957    #[test]
5958    fn decode_av1c_bit_depth_recognises_8_10_12() {
5959        assert_eq!(decode_av1c_bit_depth(&av1c_with_bit_depth(8)), Some(8));
5960        assert_eq!(decode_av1c_bit_depth(&av1c_with_bit_depth(10)), Some(10));
5961        assert_eq!(decode_av1c_bit_depth(&av1c_with_bit_depth(12)), Some(12));
5962    }
5963
5964    /// Truncated `av1C` (< 3 bytes) is rejected without panicking.
5965    #[test]
5966    fn decode_av1c_bit_depth_handles_truncation() {
5967        assert_eq!(decode_av1c_bit_depth(&[]), None);
5968        assert_eq!(decode_av1c_bit_depth(&[0x81]), None);
5969        assert_eq!(decode_av1c_bit_depth(&[0x81, 0x00]), None);
5970    }
5971
5972    /// Happy path: 8-bit master + 8-bit alpha → compliant.
5973    #[test]
5974    fn audit_alpha_bit_depth_match_compliant() {
5975        let meta = Meta {
5976            items: vec![
5977                make_infe(1, b"av01", 0), // master
5978                make_infe(2, b"av01", 0), // alpha
5979            ],
5980            irefs: vec![IrefEntry {
5981                reference_type: *b"auxl",
5982                from_id: 2,
5983                to_ids: vec![1],
5984            }],
5985            properties: vec![
5986                Property::Av1C(av1c_with_bit_depth(8)), // index 0 — master av1C
5987                Property::Av1C(av1c_with_bit_depth(8)), // index 1 — alpha av1C
5988                Property::AuxC(make_alpha_auxc()),      // index 2
5989            ],
5990            associations: vec![ipa(1, &[0]), ipa(2, &[1, 2])],
5991            ..Meta::default()
5992        };
5993        let r = audit_alpha_bit_depth(&meta);
5994        assert_eq!(r.len(), 1);
5995        let rec = &r[0];
5996        assert_eq!(rec.alpha_item_id, 2);
5997        assert_eq!(rec.master_item_id, 1);
5998        assert_eq!(rec.alpha_bit_depth, Some(8));
5999        assert_eq!(rec.master_bit_depth, Some(8));
6000        assert!(!rec.alpha_missing_av1c);
6001        assert!(!rec.master_missing_av1c);
6002        assert!(rec.is_compliant());
6003        assert!(rec.missing().is_empty());
6004    }
6005
6006    /// 10-bit master + 8-bit alpha → §4.1 mismatch flagged.
6007    #[test]
6008    fn audit_alpha_bit_depth_mismatch_flagged() {
6009        let meta = Meta {
6010            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
6011            irefs: vec![IrefEntry {
6012                reference_type: *b"auxl",
6013                from_id: 2,
6014                to_ids: vec![1],
6015            }],
6016            properties: vec![
6017                Property::Av1C(av1c_with_bit_depth(10)), // master 10-bit
6018                Property::Av1C(av1c_with_bit_depth(8)),  // alpha 8-bit
6019                Property::AuxC(make_alpha_auxc()),
6020            ],
6021            associations: vec![ipa(1, &[0]), ipa(2, &[1, 2])],
6022            ..Meta::default()
6023        };
6024        let rec = &audit_alpha_bit_depth(&meta)[0];
6025        assert_eq!(rec.alpha_bit_depth, Some(8));
6026        assert_eq!(rec.master_bit_depth, Some(10));
6027        assert!(!rec.is_compliant());
6028        assert_eq!(rec.missing(), vec!["alpha-master-bit-depth-mismatch"]);
6029    }
6030
6031    /// 12-bit on both sides also matches.
6032    #[test]
6033    fn audit_alpha_bit_depth_12_bit_compliant() {
6034        let meta = Meta {
6035            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
6036            irefs: vec![IrefEntry {
6037                reference_type: *b"auxl",
6038                from_id: 2,
6039                to_ids: vec![1],
6040            }],
6041            properties: vec![
6042                Property::Av1C(av1c_with_bit_depth(12)),
6043                Property::Av1C(av1c_with_bit_depth(12)),
6044                Property::AuxC(make_alpha_auxc()),
6045            ],
6046            associations: vec![ipa(1, &[0]), ipa(2, &[1, 2])],
6047            ..Meta::default()
6048        };
6049        let rec = &audit_alpha_bit_depth(&meta)[0];
6050        assert!(rec.is_compliant());
6051    }
6052
6053    /// Alpha item missing `av1C` surfaces both the missing-av1c flag
6054    /// and the corresponding `missing()` entry. The §2.1 violation
6055    /// (every AV1 Image Item shall carry `av1C`) is reported alongside
6056    /// the §4.1 check.
6057    #[test]
6058    fn audit_alpha_bit_depth_alpha_missing_av1c() {
6059        let meta = Meta {
6060            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
6061            irefs: vec![IrefEntry {
6062                reference_type: *b"auxl",
6063                from_id: 2,
6064                to_ids: vec![1],
6065            }],
6066            properties: vec![
6067                Property::Av1C(av1c_with_bit_depth(10)),
6068                Property::AuxC(make_alpha_auxc()),
6069            ],
6070            // Alpha item 2 has only the auxC, no av1C.
6071            associations: vec![ipa(1, &[0]), ipa(2, &[1])],
6072            ..Meta::default()
6073        };
6074        let rec = &audit_alpha_bit_depth(&meta)[0];
6075        assert!(rec.alpha_missing_av1c);
6076        assert_eq!(rec.alpha_bit_depth, None);
6077        assert!(!rec.is_compliant());
6078        assert_eq!(rec.missing(), vec!["alpha-item-missing-av1C"]);
6079    }
6080
6081    /// Master item missing `av1C` surfaces the corresponding flag +
6082    /// `missing()` entry.
6083    #[test]
6084    fn audit_alpha_bit_depth_master_missing_av1c() {
6085        let meta = Meta {
6086            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
6087            irefs: vec![IrefEntry {
6088                reference_type: *b"auxl",
6089                from_id: 2,
6090                to_ids: vec![1],
6091            }],
6092            properties: vec![
6093                Property::Av1C(av1c_with_bit_depth(10)),
6094                Property::AuxC(make_alpha_auxc()),
6095            ],
6096            // Master item 1 has no av1C; alpha carries both.
6097            associations: vec![ipa(2, &[0, 1])],
6098            ..Meta::default()
6099        };
6100        let rec = &audit_alpha_bit_depth(&meta)[0];
6101        assert!(rec.master_missing_av1c);
6102        assert_eq!(rec.master_bit_depth, None);
6103        assert!(!rec.is_compliant());
6104        assert_eq!(rec.missing(), vec!["master-item-missing-av1C"]);
6105    }
6106
6107    /// Truncated `av1C` payload on the alpha side surfaces as
6108    /// `alpha-item-av1C-truncated` (distinct from the missing case).
6109    #[test]
6110    fn audit_alpha_bit_depth_truncated_av1c() {
6111        let meta = Meta {
6112            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
6113            irefs: vec![IrefEntry {
6114                reference_type: *b"auxl",
6115                from_id: 2,
6116                to_ids: vec![1],
6117            }],
6118            properties: vec![
6119                Property::Av1C(av1c_with_bit_depth(10)),
6120                // Alpha av1C is truncated to 2 bytes — present-but-unusable.
6121                Property::Av1C(vec![0x81, 0x00]),
6122                Property::AuxC(make_alpha_auxc()),
6123            ],
6124            associations: vec![ipa(1, &[0]), ipa(2, &[1, 2])],
6125            ..Meta::default()
6126        };
6127        let rec = &audit_alpha_bit_depth(&meta)[0];
6128        assert!(!rec.alpha_missing_av1c);
6129        assert_eq!(rec.alpha_bit_depth, None);
6130        assert!(!rec.is_compliant());
6131        assert_eq!(rec.missing(), vec!["alpha-item-av1C-truncated"]);
6132    }
6133
6134    /// Non-alpha auxiliaries (depth maps, gain maps) are out of §4.1's
6135    /// scope and must not surface in the audit. A depth-map auxC is the
6136    /// canonical confound — the same `auxl` iref + a non-alpha URN
6137    /// means the audit walks past.
6138    #[test]
6139    fn audit_alpha_bit_depth_skips_depth_map_auxiliary() {
6140        let meta = Meta {
6141            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
6142            irefs: vec![IrefEntry {
6143                reference_type: *b"auxl",
6144                from_id: 2,
6145                to_ids: vec![1],
6146            }],
6147            properties: vec![
6148                Property::Av1C(av1c_with_bit_depth(10)),
6149                Property::Av1C(av1c_with_bit_depth(8)),
6150                Property::AuxC(make_depth_auxc()), // depth, not alpha
6151            ],
6152            associations: vec![ipa(1, &[0]), ipa(2, &[1, 2])],
6153            ..Meta::default()
6154        };
6155        assert!(audit_alpha_bit_depth(&meta).is_empty());
6156    }
6157
6158    /// A single alpha item declared against multiple masters in one
6159    /// `auxl` `to_ids` list emits one record per master pairing, in
6160    /// `to_ids` order. The §4.1 `shall` applies per pairing.
6161    #[test]
6162    fn audit_alpha_bit_depth_one_record_per_master() {
6163        let meta = Meta {
6164            items: vec![
6165                make_infe(1, b"av01", 0), // master A, 8-bit
6166                make_infe(2, b"av01", 0), // master B, 10-bit
6167                make_infe(3, b"av01", 0), // alpha, 10-bit
6168            ],
6169            irefs: vec![IrefEntry {
6170                reference_type: *b"auxl",
6171                from_id: 3,
6172                to_ids: vec![1, 2],
6173            }],
6174            properties: vec![
6175                Property::Av1C(av1c_with_bit_depth(8)),  // master A
6176                Property::Av1C(av1c_with_bit_depth(10)), // master B
6177                Property::Av1C(av1c_with_bit_depth(10)), // alpha
6178                Property::AuxC(make_alpha_auxc()),
6179            ],
6180            associations: vec![ipa(1, &[0]), ipa(2, &[1]), ipa(3, &[2, 3])],
6181            ..Meta::default()
6182        };
6183        let r = audit_alpha_bit_depth(&meta);
6184        assert_eq!(r.len(), 2);
6185        assert_eq!(r[0].master_item_id, 1);
6186        assert!(!r[0].is_compliant()); // 10 vs 8 → mismatch
6187        assert_eq!(r[1].master_item_id, 2);
6188        assert!(r[1].is_compliant()); // 10 vs 10 → ok
6189    }
6190
6191    /// Files with no alpha auxiliaries emit no records.
6192    #[test]
6193    fn audit_alpha_bit_depth_empty_when_no_alpha() {
6194        let meta = Meta {
6195            items: vec![make_infe(1, b"av01", 0)],
6196            irefs: Vec::new(),
6197            properties: vec![Property::Av1C(av1c_with_bit_depth(10))],
6198            associations: vec![ipa(1, &[0])],
6199            ..Meta::default()
6200        };
6201        assert!(audit_alpha_bit_depth(&meta).is_empty());
6202    }
6203
6204    /// Multiple alpha auxiliaries (one per master in a multi-image
6205    /// collection) emit one record per `(alpha, master)` pair in iref
6206    /// declaration order.
6207    #[test]
6208    fn audit_alpha_bit_depth_multiple_alpha_items() {
6209        let meta = Meta {
6210            items: vec![
6211                make_infe(1, b"av01", 0),
6212                make_infe(2, b"av01", 0),
6213                make_infe(3, b"av01", 0),
6214                make_infe(4, b"av01", 0),
6215            ],
6216            irefs: vec![
6217                IrefEntry {
6218                    reference_type: *b"auxl",
6219                    from_id: 3,
6220                    to_ids: vec![1],
6221                },
6222                IrefEntry {
6223                    reference_type: *b"auxl",
6224                    from_id: 4,
6225                    to_ids: vec![2],
6226                },
6227            ],
6228            properties: vec![
6229                Property::Av1C(av1c_with_bit_depth(8)),
6230                Property::Av1C(av1c_with_bit_depth(10)),
6231                Property::Av1C(av1c_with_bit_depth(8)),
6232                Property::Av1C(av1c_with_bit_depth(10)),
6233                Property::AuxC(make_alpha_auxc()),
6234            ],
6235            associations: vec![ipa(1, &[0]), ipa(2, &[1]), ipa(3, &[2, 4]), ipa(4, &[3, 4])],
6236            ..Meta::default()
6237        };
6238        let r = audit_alpha_bit_depth(&meta);
6239        assert_eq!(r.len(), 2);
6240        assert_eq!(r[0].alpha_item_id, 3);
6241        assert_eq!(r[0].master_item_id, 1);
6242        assert!(r[0].is_compliant());
6243        assert_eq!(r[1].alpha_item_id, 4);
6244        assert_eq!(r[1].master_item_id, 2);
6245        assert!(r[1].is_compliant());
6246    }
6247
6248    // -------------------------------------------------------------------
6249    // §2.1 Sequence Header OBU count audit
6250    // -------------------------------------------------------------------
6251
6252    /// Build one OBU framed per AV1 §5.3.1/§5.3.2 with `obu_has_size_field
6253    /// == 1` and no extension header. `obu_type` goes in bits 6..3.
6254    /// `payload` is the OBU body bytes (their content doesn't matter for
6255    /// the audit — only the type and the framing are inspected).
6256    fn obu_with_size(obu_type: u8, payload: &[u8]) -> Vec<u8> {
6257        // header byte: 0|type(4)|ext(0)|has_size(1)|reserved(0)
6258        let hdr = ((obu_type & 0x0f) << 3) | 0b10;
6259        let mut out = vec![hdr];
6260        // leb128 encode payload length.
6261        let mut v = payload.len() as u32;
6262        loop {
6263            let mut b = (v & 0x7f) as u8;
6264            v >>= 7;
6265            if v != 0 {
6266                b |= 0x80;
6267                out.push(b);
6268            } else {
6269                out.push(b);
6270                break;
6271            }
6272        }
6273        out.extend_from_slice(payload);
6274        out
6275    }
6276
6277    /// Build a one-byte OBU with `obu_has_size_field == 0` — illegal in
6278    /// AVIF Image Item Data per av1-avif §2.1 + AV1 §5.3.1's container
6279    /// chaining requirement.
6280    fn obu_no_size(obu_type: u8) -> Vec<u8> {
6281        // header byte: 0|type(4)|ext(0)|has_size(0)|reserved(0)
6282        vec![(obu_type & 0x0f) << 3]
6283    }
6284
6285    /// Build a synthetic file whose iloc resolves item `1`'s payload to
6286    /// the given byte slice. Returns `(file_bytes, meta)`. The file
6287    /// bytes are just a flat buffer with the payload at offset 0 (so
6288    /// `iloc.base_offset = 0`, `extent.offset = 0`, `extent.length =
6289    /// payload.len()`).
6290    fn synth_av01_item(payload: &[u8]) -> (Vec<u8>, Meta) {
6291        use crate::meta::{IlocExtent, ItemLocation};
6292        let file = payload.to_vec();
6293        let length = file.len() as u64;
6294        let meta = Meta {
6295            items: vec![make_infe(1, b"av01", 0)],
6296            locations: vec![ItemLocation {
6297                id: 1,
6298                construction_method: 0,
6299                data_reference_index: 0,
6300                base_offset: 0,
6301                extents: vec![IlocExtent {
6302                    offset: 0,
6303                    length,
6304                    extent_index: 0,
6305                }],
6306            }],
6307            ..Meta::default()
6308        };
6309        (file, meta)
6310    }
6311
6312    /// leb128 round-trip on small + large values (within 32-bit range).
6313    #[test]
6314    fn read_leb128_decodes_single_and_multi_byte_values() {
6315        // single-byte: 0x05 → 5 in 1 byte
6316        assert_eq!(read_leb128(&[0x05, 0xff]), Ok((5, 1)));
6317        // two-byte: 0x80 0x01 → 128 in 2 bytes
6318        assert_eq!(read_leb128(&[0x80, 0x01, 0xff]), Ok((128, 2)));
6319        // five-byte: largest u32: 0xff ff ff ff 0f
6320        assert_eq!(
6321            read_leb128(&[0xff, 0xff, 0xff, 0xff, 0x0f]),
6322            Ok((0xffff_ffff, 5))
6323        );
6324    }
6325
6326    /// Truncation (continuation bit set on last byte) errors out.
6327    #[test]
6328    fn read_leb128_rejects_truncated_continuation() {
6329        assert!(read_leb128(&[0x80]).is_err());
6330        assert!(read_leb128(&[0x80, 0x80, 0x80]).is_err());
6331    }
6332
6333    /// A leb128 sequence longer than 8 bytes errors out (the §4.10.5
6334    /// loop bound).
6335    #[test]
6336    fn read_leb128_rejects_overlong_sequence() {
6337        assert!(read_leb128(&[0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]).is_err());
6338    }
6339
6340    /// Happy path: one Sequence Header + one Temporal Delimiter + one
6341    /// Frame OBU → `sequence_header_count == 1`, compliant.
6342    #[test]
6343    fn audit_sequence_header_obu_single_count_compliant() {
6344        let mut stream = Vec::new();
6345        stream.extend(obu_with_size(2, &[])); // OBU_TEMPORAL_DELIMITER
6346        stream.extend(obu_with_size(1, &[0x00, 0x01, 0x02])); // OBU_SEQUENCE_HEADER
6347        stream.extend(obu_with_size(6, &[0xab; 7])); // OBU_FRAME
6348        let (file, meta) = synth_av01_item(&stream);
6349        let r = audit_sequence_header_obu(&meta, &file);
6350        assert_eq!(r.len(), 1);
6351        let rec = &r[0];
6352        assert_eq!(rec.item_id, 1);
6353        assert_eq!(rec.sequence_header_count, 1);
6354        assert_eq!(rec.total_obu_count, 3);
6355        assert!(rec.is_compliant());
6356        assert!(rec.missing().is_empty());
6357    }
6358
6359    /// Two Sequence Headers in one item → §2.1 violation, flagged as
6360    /// `av01-item-multiple-sequence-header-obus`.
6361    #[test]
6362    fn audit_sequence_header_obu_two_count_flagged() {
6363        let mut stream = Vec::new();
6364        stream.extend(obu_with_size(1, &[0x00, 0x01])); // SH #1
6365        stream.extend(obu_with_size(1, &[0x00, 0x01])); // SH #2
6366        let (file, meta) = synth_av01_item(&stream);
6367        let rec = &audit_sequence_header_obu(&meta, &file)[0];
6368        assert_eq!(rec.sequence_header_count, 2);
6369        assert!(!rec.is_compliant());
6370        assert_eq!(
6371            rec.missing(),
6372            vec!["av01-item-multiple-sequence-header-obus"]
6373        );
6374    }
6375
6376    /// Zero Sequence Headers → §2.1 violation, flagged as
6377    /// `av01-item-missing-sequence-header-obu`.
6378    #[test]
6379    fn audit_sequence_header_obu_zero_count_flagged() {
6380        let mut stream = Vec::new();
6381        stream.extend(obu_with_size(2, &[])); // TD only
6382        stream.extend(obu_with_size(6, &[0xab; 4])); // FRAME
6383        let (file, meta) = synth_av01_item(&stream);
6384        let rec = &audit_sequence_header_obu(&meta, &file)[0];
6385        assert_eq!(rec.sequence_header_count, 0);
6386        assert_eq!(rec.total_obu_count, 2);
6387        assert!(!rec.is_compliant());
6388        assert_eq!(rec.missing(), vec!["av01-item-missing-sequence-header-obu"]);
6389    }
6390
6391    /// `obu_has_size_field == 0` in a chained stream → walker stops and
6392    /// reports `has_size_field_zero`. Per AV1 §5.3.1, an OBU without a
6393    /// size field cannot be chained with subsequent OBUs.
6394    #[test]
6395    fn audit_sequence_header_obu_size_field_zero_flagged() {
6396        let mut stream = Vec::new();
6397        stream.extend(obu_no_size(1)); // SH with no size
6398                                       // Following bytes can't be framed — anything after.
6399        stream.extend([0xff, 0xff, 0xff]);
6400        let (file, meta) = synth_av01_item(&stream);
6401        let rec = &audit_sequence_header_obu(&meta, &file)[0];
6402        assert!(rec.has_size_field_zero);
6403        assert!(!rec.is_compliant());
6404        // The SH header byte itself was decoded before we noticed the
6405        // missing size — count surfaces but compliance still false.
6406        assert_eq!(rec.sequence_header_count, 1);
6407        assert!(rec.missing().contains(&"av01-item-obu-has-size-field-zero"));
6408    }
6409
6410    /// A declared `obu_size` that runs past the item payload → walker
6411    /// reports `truncated_obu`.
6412    #[test]
6413    fn audit_sequence_header_obu_truncated_payload_flagged() {
6414        // Header for SH, claim 100-byte payload, but only give it 3.
6415        let mut stream = vec![(1u8 << 3) | 0b10]; // SH + has_size
6416        stream.push(100); // leb128(100), single byte
6417        stream.extend([0xde, 0xad, 0xbe]); // only 3 bytes of payload
6418        let (file, meta) = synth_av01_item(&stream);
6419        let rec = &audit_sequence_header_obu(&meta, &file)[0];
6420        assert!(rec.truncated_obu);
6421        assert!(!rec.is_compliant());
6422        assert!(rec.missing().contains(&"av01-item-obu-stream-truncated"));
6423    }
6424
6425    /// Truncated leb128 mid-OBU (continuation bit set on last byte) →
6426    /// `truncated_obu` flagged; no OBUs walked past the bad leb.
6427    #[test]
6428    fn audit_sequence_header_obu_truncated_leb128_flagged() {
6429        // Header byte SH + has_size, then a single byte 0x80 (continuation
6430        // bit set, no follow-on) — leb128 walker errors → truncated.
6431        let stream = vec![(1u8 << 3) | 0b10, 0x80];
6432        let (file, meta) = synth_av01_item(&stream);
6433        let rec = &audit_sequence_header_obu(&meta, &file)[0];
6434        assert!(rec.truncated_obu);
6435        assert!(!rec.is_compliant());
6436        // Header byte was readable, but the leb128 framing failure
6437        // bailed before the type-credit step — SH counter does not
6438        // fire because we cannot confirm we successfully framed an OBU.
6439        assert_eq!(rec.sequence_header_count, 0);
6440        assert_eq!(rec.total_obu_count, 0);
6441    }
6442
6443    /// `iinf` lists an av01 item but no `iloc` resolves it → missing_iloc.
6444    #[test]
6445    fn audit_sequence_header_obu_missing_iloc_flagged() {
6446        let meta = Meta {
6447            items: vec![make_infe(1, b"av01", 0)],
6448            // no locations
6449            ..Meta::default()
6450        };
6451        let r = audit_sequence_header_obu(&meta, &[]);
6452        assert_eq!(r.len(), 1);
6453        let rec = &r[0];
6454        assert!(rec.missing_iloc);
6455        assert!(!rec.is_compliant());
6456        assert_eq!(rec.missing(), vec!["av01-item-missing-iloc"]);
6457    }
6458
6459    /// Non-av01 items (e.g. `Exif`, `iden`, `grid`) are ignored.
6460    #[test]
6461    fn audit_sequence_header_obu_ignores_non_av01_items() {
6462        let meta = Meta {
6463            items: vec![
6464                make_infe(1, b"Exif", 0),
6465                make_infe(2, b"iden", 0),
6466                make_infe(3, b"grid", 0),
6467            ],
6468            ..Meta::default()
6469        };
6470        assert!(audit_sequence_header_obu(&meta, &[]).is_empty());
6471    }
6472
6473    /// An `obu_extension_flag == 1` OBU correctly skips the extension
6474    /// header byte before reading `obu_size`.
6475    #[test]
6476    fn audit_sequence_header_obu_handles_extension_header() {
6477        // header byte: SH(1)|ext(1)|has_size(1)|reserved(0)
6478        let hdr = (1u8 << 3) | 0b110;
6479        let mut stream = vec![hdr];
6480        stream.push(0x00); // extension header byte (temporal_id=0, spatial_id=0)
6481        stream.push(2); // leb128(2)
6482        stream.extend([0x11, 0x22]); // payload
6483        let (file, meta) = synth_av01_item(&stream);
6484        let rec = &audit_sequence_header_obu(&meta, &file)[0];
6485        assert_eq!(rec.sequence_header_count, 1);
6486        assert_eq!(rec.total_obu_count, 1);
6487        assert!(rec.is_compliant());
6488    }
6489
6490    /// Empty `av01` payload: 0 OBUs walked, 0 SH OBUs → flagged as
6491    /// missing SH.
6492    #[test]
6493    fn audit_sequence_header_obu_empty_payload_flagged_missing() {
6494        let (file, meta) = synth_av01_item(&[]);
6495        let rec = &audit_sequence_header_obu(&meta, &file)[0];
6496        assert_eq!(rec.sequence_header_count, 0);
6497        assert_eq!(rec.total_obu_count, 0);
6498        assert!(!rec.missing_iloc);
6499        assert!(!rec.truncated_obu);
6500        assert!(!rec.is_compliant());
6501        assert_eq!(rec.missing(), vec!["av01-item-missing-sequence-header-obu"]);
6502    }
6503
6504    /// Multiple AV1 Image Items in the same file each get their own
6505    /// audit record, in `item_ids_of_type` (declaration) order.
6506    #[test]
6507    fn audit_sequence_header_obu_one_record_per_av01_item() {
6508        use crate::meta::{IlocExtent, ItemLocation};
6509        // Build two items back-to-back in the file: item 1 compliant
6510        // (one SH), item 2 non-compliant (zero SH).
6511        let mut s1 = Vec::new();
6512        s1.extend(obu_with_size(2, &[])); // TD
6513        s1.extend(obu_with_size(1, &[0x00; 3])); // SH
6514        let mut s2 = Vec::new();
6515        s2.extend(obu_with_size(2, &[])); // TD only — no SH
6516        let mut file = Vec::new();
6517        let off1 = file.len() as u64;
6518        file.extend(&s1);
6519        let off2 = file.len() as u64;
6520        file.extend(&s2);
6521        let meta = Meta {
6522            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
6523            locations: vec![
6524                ItemLocation {
6525                    id: 1,
6526                    construction_method: 0,
6527                    data_reference_index: 0,
6528                    base_offset: 0,
6529                    extents: vec![IlocExtent {
6530                        offset: off1,
6531                        length: s1.len() as u64,
6532                        extent_index: 0,
6533                    }],
6534                },
6535                ItemLocation {
6536                    id: 2,
6537                    construction_method: 0,
6538                    data_reference_index: 0,
6539                    base_offset: 0,
6540                    extents: vec![IlocExtent {
6541                        offset: off2,
6542                        length: s2.len() as u64,
6543                        extent_index: 0,
6544                    }],
6545                },
6546            ],
6547            ..Meta::default()
6548        };
6549        let r = audit_sequence_header_obu(&meta, &file);
6550        assert_eq!(r.len(), 2);
6551        assert_eq!(r[0].item_id, 1);
6552        assert!(r[0].is_compliant());
6553        assert_eq!(r[1].item_id, 2);
6554        assert!(!r[1].is_compliant());
6555        assert_eq!(r[1].sequence_header_count, 0);
6556    }
6557
6558    // -- ISO 21496-1 Annex C.2 gain map metadata --------------------------
6559
6560    /// Push a `numerator(int32) / denominator(uint32)` rational pair onto
6561    /// a big-endian payload buffer.
6562    fn push_rational(buf: &mut Vec<u8>, num: i32, den: u32) {
6563        buf.extend_from_slice(&(num as u32).to_be_bytes());
6564        buf.extend_from_slice(&den.to_be_bytes());
6565    }
6566
6567    /// Build a single-channel (`is_multichannel == 0`) gain map metadata
6568    /// payload with deterministic, distinguishable rational values.
6569    fn build_singlechannel_gain_map() -> Vec<u8> {
6570        let mut buf = Vec::new();
6571        buf.extend_from_slice(&0u16.to_be_bytes()); // minimum_version
6572        buf.extend_from_slice(&0u16.to_be_bytes()); // writer_version
6573        buf.push(0x40); // is_multichannel=0, use_base_colour_space=1
6574        push_rational(&mut buf, 5, 2); // base_hdr_headroom
6575        push_rational(&mut buf, 8, 1); // alternate_hdr_headroom
6576                                       // one GainMapChannel
6577        push_rational(&mut buf, -1, 4); // gain_map_min
6578        push_rational(&mut buf, 3, 4); // gain_map_max
6579        push_rational(&mut buf, 1, 1); // gamma
6580        push_rational(&mut buf, -2, 16); // base_offset
6581        push_rational(&mut buf, 7, 16); // alternate_offset
6582        buf
6583    }
6584
6585    /// Single-channel payload parses with the right flags, headroom, and
6586    /// one channel record; the rationals round-trip exactly.
6587    #[test]
6588    fn gain_map_metadata_parses_single_channel() {
6589        let buf = build_singlechannel_gain_map();
6590        let m = GainMapMetadata::parse(&buf).unwrap();
6591        assert_eq!(m.minimum_version, 0);
6592        assert_eq!(m.writer_version, 0);
6593        assert!(!m.is_multichannel);
6594        assert!(m.use_base_colour_space);
6595        assert_eq!(m.channel_count(), 1);
6596        assert_eq!(
6597            m.base_hdr_headroom,
6598            GainMapRational {
6599                numerator: 5,
6600                denominator: 2
6601            }
6602        );
6603        assert_eq!(
6604            m.alternate_hdr_headroom,
6605            GainMapRational {
6606                numerator: 8,
6607                denominator: 1
6608            }
6609        );
6610        let c = m.channels[0];
6611        assert_eq!(
6612            c.gain_map_min,
6613            GainMapRational {
6614                numerator: -1,
6615                denominator: 4
6616            }
6617        );
6618        assert_eq!(c.gain_map_max.as_f64(), 0.75);
6619        assert_eq!(
6620            c.gamma,
6621            GainMapRational {
6622                numerator: 1,
6623                denominator: 1
6624            }
6625        );
6626        assert_eq!(c.base_offset.as_f64(), -0.125);
6627        assert_eq!(c.alternate_offset.numerator, 7);
6628    }
6629
6630    /// `is_multichannel == 1` yields three channel records (R, G, B
6631    /// order) read from 3 × 40 trailing bytes.
6632    #[test]
6633    fn gain_map_metadata_parses_three_channels_when_multichannel() {
6634        let mut buf = Vec::new();
6635        buf.extend_from_slice(&0u16.to_be_bytes()); // minimum_version
6636        buf.extend_from_slice(&1u16.to_be_bytes()); // writer_version
6637        buf.push(0x80); // is_multichannel=1, use_base_colour_space=0
6638        push_rational(&mut buf, 1, 1); // base_hdr_headroom
6639                                       // alternate_hdr_headroom must differ from base_hdr_headroom
6640                                       // (§5.2.7); 4/1 ≠ 1/1.
6641        push_rational(&mut buf, 4, 1); // alternate_hdr_headroom
6642        for ch in 0..3i32 {
6643            push_rational(&mut buf, ch, 1); // gain_map_min (R=0,G=1,B=2)
6644            push_rational(&mut buf, 10, 1); // gain_map_max
6645            push_rational(&mut buf, 2, 1); // gamma
6646            push_rational(&mut buf, 0, 1); // base_offset
6647            push_rational(&mut buf, 0, 1); // alternate_offset
6648        }
6649        let m = GainMapMetadata::parse(&buf).unwrap();
6650        assert!(m.is_multichannel);
6651        assert!(!m.use_base_colour_space);
6652        assert_eq!(m.channel_count(), 3);
6653        assert_eq!(m.channels[0].gain_map_min.numerator, 0);
6654        assert_eq!(m.channels[1].gain_map_min.numerator, 1);
6655        assert_eq!(m.channels[2].gain_map_min.numerator, 2);
6656    }
6657
6658    /// Annex C.2.1: trailing padding / future-optional metadata after the
6659    /// recognised fields is ignored, not an error.
6660    #[test]
6661    fn gain_map_metadata_ignores_trailing_bytes() {
6662        let mut buf = build_singlechannel_gain_map();
6663        buf.extend_from_slice(&[0xAA; 32]); // padding + hypothetical v2 fields
6664        let m = GainMapMetadata::parse(&buf).unwrap();
6665        assert_eq!(m.channel_count(), 1);
6666    }
6667
6668    /// Annex C.2.3: a `minimum_version` the reader doesn't understand is
6669    /// an `Unsupported` (display the base image), not malformed data.
6670    #[test]
6671    fn gain_map_metadata_unknown_min_version_is_unsupported() {
6672        let mut buf = build_singlechannel_gain_map();
6673        buf[0] = 0;
6674        buf[1] = 1; // minimum_version = 1
6675        let err = GainMapMetadata::parse(&buf).unwrap_err();
6676        assert!(matches!(err, Error::Unsupported(_)));
6677    }
6678
6679    /// A zero rational denominator is rejected (C.2.3 "shall not be 0").
6680    #[test]
6681    fn gain_map_metadata_rejects_zero_denominator() {
6682        let mut buf = build_singlechannel_gain_map();
6683        // base_hdr_headroom denominator sits at offset 5+4 = 9.
6684        buf[9] = 0;
6685        buf[10] = 0;
6686        buf[11] = 0;
6687        buf[12] = 0;
6688        let err = GainMapMetadata::parse(&buf).unwrap_err();
6689        assert!(matches!(err, Error::InvalidData(_)));
6690    }
6691
6692    /// `gamma_numerator == 0` is rejected (C.2.3 "gamma_numerator shall
6693    /// not be 0"), distinct from the denominator constraint.
6694    #[test]
6695    fn gain_map_metadata_rejects_zero_gamma_numerator() {
6696        let mut buf = build_singlechannel_gain_map();
6697        // Channel record starts at offset 21; gamma is field 3 →
6698        // 21 + 16 = 37, the gamma numerator (4 bytes).
6699        for b in buf.iter_mut().skip(37).take(4) {
6700            *b = 0;
6701        }
6702        let err = GainMapMetadata::parse(&buf).unwrap_err();
6703        assert!(matches!(err, Error::InvalidData(_)));
6704    }
6705
6706    /// A payload truncated before all channel bytes arrive is rejected.
6707    #[test]
6708    fn gain_map_metadata_rejects_truncated_channel() {
6709        let mut buf = build_singlechannel_gain_map();
6710        buf.truncate(buf.len() - 1); // drop last byte of the channel
6711        let err = GainMapMetadata::parse(&buf).unwrap_err();
6712        assert!(matches!(err, Error::InvalidData(_)));
6713    }
6714
6715    /// §5.2.5.3: per-component `max(G)` "shall be greater than or
6716    /// equal to the `min(G)` value". The default fixture has min=-1/4
6717    /// (-0.25) and max=3/4 (0.75), so swapping the two numerators
6718    /// flips the predicate and the parse must reject the payload.
6719    #[test]
6720    fn gain_map_metadata_rejects_max_below_min() {
6721        let mut buf = build_singlechannel_gain_map();
6722        // Channel record starts at offset 21; the gain_map_min and
6723        // gain_map_max sint32 numerators sit at +0 and +8 within it.
6724        // Swap them so max < min.
6725        let orig_min = i32::from_be_bytes(buf[21..25].try_into().unwrap());
6726        let orig_max = i32::from_be_bytes(buf[29..33].try_into().unwrap());
6727        buf[21..25].copy_from_slice(&orig_max.to_be_bytes());
6728        buf[29..33].copy_from_slice(&orig_min.to_be_bytes());
6729        let err = GainMapMetadata::parse(&buf).unwrap_err();
6730        assert!(matches!(err, Error::InvalidData(_)));
6731    }
6732
6733    /// §5.2.5.3 boundary: `max(G) == min(G)` is permitted ("greater
6734    /// than or equal to"). Build a channel where both equal 0/1 and
6735    /// confirm the parse succeeds.
6736    #[test]
6737    fn gain_map_metadata_accepts_max_equal_to_min() {
6738        let mut buf = build_singlechannel_gain_map();
6739        // Force gain_map_min and gain_map_max to the same rational
6740        // (0/1, the simplest legal value).
6741        buf[21..25].copy_from_slice(&0i32.to_be_bytes()); // min numerator
6742        buf[25..29].copy_from_slice(&1u32.to_be_bytes()); // min denominator
6743        buf[29..33].copy_from_slice(&0i32.to_be_bytes()); // max numerator
6744        buf[33..37].copy_from_slice(&1u32.to_be_bytes()); // max denominator
6745        let m = GainMapMetadata::parse(&buf).expect("max==min is permitted");
6746        assert_eq!(m.channels[0].gain_map_min.numerator, 0);
6747        assert_eq!(m.channels[0].gain_map_max.numerator, 0);
6748    }
6749
6750    /// §5.2.7: `alternate_hdr_headroom` "shall not be equal to" the
6751    /// `base_hdr_headroom`. Overwrite the alternate headroom with the
6752    /// base headroom's exact bytes and confirm the parse rejects it.
6753    #[test]
6754    fn gain_map_metadata_rejects_equal_hdr_headrooms() {
6755        let mut buf = build_singlechannel_gain_map();
6756        // base_hdr_headroom sits at offset 5..13; alternate at 13..21.
6757        let base = buf[5..13].to_vec();
6758        buf[13..21].copy_from_slice(&base);
6759        let err = GainMapMetadata::parse(&buf).unwrap_err();
6760        assert!(matches!(err, Error::InvalidData(_)));
6761    }
6762
6763    /// §5.2.7 uses rational *value* equality, not byte equality:
6764    /// 1/1 and 2/2 are the same value and so must trip the check
6765    /// even though the encoded bytes differ.
6766    #[test]
6767    fn gain_map_metadata_rejects_value_equal_hdr_headrooms() {
6768        let mut buf = build_singlechannel_gain_map();
6769        // base = 1/1, alternate = 2/2 → both = 1.0, must reject.
6770        buf[5..9].copy_from_slice(&1u32.to_be_bytes()); // base numerator
6771        buf[9..13].copy_from_slice(&1u32.to_be_bytes()); // base denominator
6772        buf[13..17].copy_from_slice(&2u32.to_be_bytes()); // alt numerator
6773        buf[17..21].copy_from_slice(&2u32.to_be_bytes()); // alt denominator
6774        let err = GainMapMetadata::parse(&buf).unwrap_err();
6775        assert!(matches!(err, Error::InvalidData(_)));
6776    }
6777
6778    // -- ISO 21496-1 §6 gain map application ------------------------------
6779
6780    /// Helper to compare two floats within a tight absolute tolerance.
6781    fn approx(a: f64, b: f64) {
6782        assert!((a - b).abs() < 1e-12, "expected {b}, got {a}");
6783    }
6784
6785    /// §6.3 Formula (3): `W == 0` at the baseline headroom, `W == 1` at
6786    /// the alternate headroom (positive span), and a linear interpolation
6787    /// in between. The single-channel fixture has H_baseline = 2.5 and
6788    /// H_alternate = 8.0 (span 5.5).
6789    #[test]
6790    fn weight_factor_spans_baseline_to_alternate() {
6791        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
6792        approx(m.weight_factor(2.5), 0.0); // at H_baseline → not applied
6793        approx(m.weight_factor(8.0), 1.0); // at H_alternate → fully applied
6794        approx(m.weight_factor(5.25), 0.5); // midpoint → half
6795                                            // Targets outside [H_baseline, H_alternate] clamp into [0, 1].
6796        approx(m.weight_factor(-100.0), 0.0);
6797        approx(m.weight_factor(100.0), 1.0);
6798    }
6799
6800    /// §6.3 Formula (3) with a negative span (H_alternate < H_baseline):
6801    /// `sign(H_alternate − H_baseline)` flips W negative, and full
6802    /// application yields `W == -1` (§6.3 NOTE 4).
6803    #[test]
6804    fn weight_factor_is_negative_when_alternate_below_baseline() {
6805        // base_hdr = 5/1 = 5.0, alternate_hdr = 1/1 = 1.0 → span = -4.
6806        let mut buf = build_singlechannel_gain_map();
6807        buf[5..9].copy_from_slice(&5u32.to_be_bytes()); // base num
6808        buf[9..13].copy_from_slice(&1u32.to_be_bytes()); // base den
6809        buf[13..17].copy_from_slice(&1u32.to_be_bytes()); // alt num
6810        buf[17..21].copy_from_slice(&1u32.to_be_bytes()); // alt den
6811        let m = GainMapMetadata::parse(&buf).unwrap();
6812        approx(m.weight_factor(5.0), 0.0); // at H_baseline
6813        approx(m.weight_factor(1.0), -1.0); // at H_alternate → W = -1
6814        approx(m.weight_factor(3.0), -0.5); // midpoint
6815    }
6816
6817    /// §6.2.1 Formula (1) inverse-normalization with γ = 1: a normalized
6818    /// sample of 1.0 maps to max(G), 0.0 to min(G), 0.5 to the midpoint.
6819    /// The fixture channel has min(G) = −0.25, max(G) = 0.75.
6820    #[test]
6821    fn unnormalize_log2_gain_inverts_normalization() {
6822        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
6823        let ch = m.channels[0];
6824        approx(ch.unnormalize_log2_gain(1.0), 0.75); // → max(G)
6825        approx(ch.unnormalize_log2_gain(0.0), -0.25); // → min(G)
6826        approx(ch.unnormalize_log2_gain(0.5), 0.25); // span 1.0 × 0.5 − 0.25
6827                                                     // Out-of-range stored sample saturates rather than producing NaN.
6828        approx(ch.unnormalize_log2_gain(2.0), 0.75);
6829        approx(ch.unnormalize_log2_gain(-1.0), -0.25);
6830    }
6831
6832    /// §6.2.1 Formula (1) with γ ≠ 1: the gamma inverse `x^(1/γ)` is
6833    /// applied before the [min, max] range scaling. With γ = 2 and a
6834    /// normalized sample of 0.25, `0.25^(1/2) = 0.5`, so the result is
6835    /// the same midpoint as a γ = 1 sample of 0.5.
6836    #[test]
6837    fn unnormalize_log2_gain_applies_gamma_inverse() {
6838        let mut buf = build_singlechannel_gain_map();
6839        // channel gamma lives at offset 21 + 16 = 37 (num) / 41 (den).
6840        buf[37..41].copy_from_slice(&2u32.to_be_bytes()); // gamma num = 2
6841        buf[41..45].copy_from_slice(&1u32.to_be_bytes()); // gamma den = 1
6842        let m = GainMapMetadata::parse(&buf).unwrap();
6843        let ch = m.channels[0];
6844        approx(ch.unnormalize_log2_gain(0.25), 0.25);
6845        approx(ch.unnormalize_log2_gain(1.0), 0.75); // 1^anything = 1 → max
6846        approx(ch.unnormalize_log2_gain(0.0), -0.25); // 0 → min
6847    }
6848
6849    /// §6.3 Formula (2) end to end: at the baseline headroom W = 0 so
6850    /// `2^(W·G) = 1` and the alternate reduces to
6851    /// `(Baseline + k_baseline) − k_alternate`. Fixture offsets are
6852    /// k_baseline = −0.125, k_alternate = 0.4375.
6853    #[test]
6854    fn apply_component_at_baseline_headroom_is_offset_only() {
6855        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
6856        // Baseline 1.0, target == H_baseline (2.5) → W = 0.
6857        let out = m.apply_component(1.0, 1.0, 2.5, 0).unwrap();
6858        approx(out, 1.0 + (-0.125) - 0.4375);
6859    }
6860
6861    /// §6.3 Formula (2) at full application (target == H_alternate, W = 1)
6862    /// with a maximal gain sample (G = max(G) = 0.75): the multiplicative
6863    /// term is `2^0.75`.
6864    #[test]
6865    fn apply_component_at_full_application() {
6866        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
6867        // baseline 1.0, normalized 1.0 → G = 0.75, target 8.0 → W = 1.
6868        let out = m.apply_component(1.0, 1.0, 8.0, 0).unwrap();
6869        let expected = (1.0 + (-0.125)) * 0.75f64.exp2() - 0.4375;
6870        approx(out, expected);
6871    }
6872
6873    /// §5.2.5.1 broadcast: a single-channel metadata record applies to
6874    /// all three RGB colour components, so each component uses the same
6875    /// channel values, and `apply_rgb` matches `apply_component` per slot.
6876    #[test]
6877    fn apply_rgb_broadcasts_single_channel_metadata() {
6878        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
6879        let baseline = [0.5, 1.0, 0.25];
6880        let gain = [0.0, 0.5, 1.0];
6881        let rgb = m.apply_rgb(baseline, gain, 8.0).unwrap();
6882        for c in 0..3 {
6883            let want = m.apply_component(baseline[c], gain[c], 8.0, c).unwrap();
6884            approx(rgb[c], want);
6885        }
6886    }
6887
6888    /// §5.2.5.1 per-component metadata: a three-channel record uses a
6889    /// distinct value per colour component. The R/G/B channels carry
6890    /// different min(G) values, so `channel_for` must index them rather
6891    /// than broadcast channel 0.
6892    #[test]
6893    fn apply_component_indexes_per_component_metadata() {
6894        // Three-channel payload: R/G/B min(G) = 0/0.5/1 (×1 den), max = 2,
6895        // gamma = 1, offsets 0; base_hdr = 1, alt_hdr = 2.
6896        let mut buf = Vec::new();
6897        buf.extend_from_slice(&0u16.to_be_bytes()); // minimum_version
6898        buf.extend_from_slice(&0u16.to_be_bytes()); // writer_version
6899        buf.push(0x80); // is_multichannel = 1
6900        push_rational(&mut buf, 1, 1); // base_hdr_headroom = 1.0
6901        push_rational(&mut buf, 2, 1); // alternate_hdr_headroom = 2.0
6902        let mins = [(0i32, 1u32), (1, 2), (1, 1)]; // 0.0, 0.5, 1.0
6903        for (mn, md) in mins {
6904            push_rational(&mut buf, mn, md); // gain_map_min
6905            push_rational(&mut buf, 2, 1); // gain_map_max = 2.0
6906            push_rational(&mut buf, 1, 1); // gamma = 1
6907            push_rational(&mut buf, 0, 1); // base_offset = 0
6908            push_rational(&mut buf, 0, 1); // alternate_offset = 0
6909        }
6910        let m = GainMapMetadata::parse(&buf).unwrap();
6911        assert_eq!(m.channel_count(), 3);
6912        // normalized = 0 selects each component's min(G); with W = 1 at
6913        // target 2.0 the result is baseline · 2^min(G).
6914        let baseline = 1.0;
6915        approx(
6916            m.apply_component(baseline, 0.0, 2.0, 0).unwrap(),
6917            baseline * 0.0f64.exp2(),
6918        );
6919        approx(
6920            m.apply_component(baseline, 0.0, 2.0, 1).unwrap(),
6921            baseline * 0.5f64.exp2(),
6922        );
6923        approx(
6924            m.apply_component(baseline, 0.0, 2.0, 2).unwrap(),
6925            baseline * 1.0f64.exp2(),
6926        );
6927    }
6928
6929    /// `apply_component` rejects an out-of-range colour-component index.
6930    #[test]
6931    fn apply_component_rejects_component_index_above_two() {
6932        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
6933        assert!(m.apply_component(1.0, 1.0, 8.0, 3).is_none());
6934    }
6935
6936    /// §6.2 NOTE 3 / §6.3 round trip: a gain map computed from a known
6937    /// baseline/alternate pair (Annex A.2 Formula (A.1)) reconstructs the
6938    /// alternate when applied with W = 1. With zero offsets and γ = 1, the
6939    /// log2-ratio stored as the gain and re-applied returns the alternate.
6940    #[test]
6941    fn apply_component_round_trips_a2_gain() {
6942        // Construct metadata whose single channel spans the exact gain
6943        // G = log2(alternate / baseline) for baseline = 2, alternate = 8
6944        // → G = log2(4) = 2.0. Store min = max = 2.0 so any normalized
6945        // sample unnormalizes to 2.0; offsets 0, gamma 1.
6946        let mut buf = Vec::new();
6947        buf.extend_from_slice(&0u16.to_be_bytes());
6948        buf.extend_from_slice(&0u16.to_be_bytes());
6949        buf.push(0x00); // single channel
6950        push_rational(&mut buf, 1, 1); // base_hdr = 1.0
6951        push_rational(&mut buf, 2, 1); // alt_hdr  = 2.0 (W = 1 at target 2)
6952        push_rational(&mut buf, 2, 1); // gain_map_min = 2.0
6953        push_rational(&mut buf, 2, 1); // gain_map_max = 2.0
6954        push_rational(&mut buf, 1, 1); // gamma
6955        push_rational(&mut buf, 0, 1); // base_offset
6956        push_rational(&mut buf, 0, 1); // alternate_offset
6957        let m = GainMapMetadata::parse(&buf).unwrap();
6958        // Alternate = (2 + 0) · 2^(1 · 2) − 0 = 2 · 4 = 8.
6959        approx(m.apply_component(2.0, 0.5, 2.0, 0).unwrap(), 8.0);
6960    }
6961
6962    /// `apply_plane_rgb` over an achromatic gain plane (one sample per
6963    /// pixel) matches per-pixel `apply_rgb` with the sample broadcast to
6964    /// all three colour components (§6.3 NOTE 2).
6965    #[test]
6966    fn apply_plane_rgb_achromatic_matches_per_pixel() {
6967        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
6968        // 2×1 image, interleaved RGB baseline.
6969        let baseline = [0.25, 0.5, 0.75, 1.0, 0.125, 0.6];
6970        let gain = [0.3, 0.9]; // one sample per pixel
6971        let out = m.apply_plane_rgb(&baseline, &gain, 2, 1, 6.0).unwrap();
6972        assert_eq!(out.len(), 6);
6973        for p in 0..2 {
6974            let base = [baseline[p * 3], baseline[p * 3 + 1], baseline[p * 3 + 2]];
6975            let want = m.apply_rgb(base, [gain[p]; 3], 6.0).unwrap();
6976            for c in 0..3 {
6977                approx(out[p * 3 + c], want[c]);
6978            }
6979        }
6980    }
6981
6982    /// `apply_plane_rgb` over an interleaved RGB gain plane consumes one
6983    /// gain sample per colour component.
6984    #[test]
6985    fn apply_plane_rgb_interleaved_uses_per_component_gain() {
6986        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
6987        let baseline = [0.25, 0.5, 0.75]; // 1×1
6988        let gain = [0.0, 0.5, 1.0]; // RGB gain plane, 1×1×3
6989        let out = m.apply_plane_rgb(&baseline, &gain, 1, 1, 8.0).unwrap();
6990        for c in 0..3 {
6991            let want = m.apply_component(baseline[c], gain[c], 8.0, c).unwrap();
6992            approx(out[c], want);
6993        }
6994    }
6995
6996    /// `apply_plane_rgb` rejects a baseline length that does not match
6997    /// `width × height × 3` and a gain length that is neither the
6998    /// achromatic nor the RGB size.
6999    #[test]
7000    fn apply_plane_rgb_rejects_mismatched_lengths() {
7001        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
7002        // baseline too short for 2×1×3 = 6.
7003        let err = m
7004            .apply_plane_rgb(&[0.0, 0.0, 0.0], &[0.0, 0.0], 2, 1, 6.0)
7005            .unwrap_err();
7006        assert!(matches!(err, Error::InvalidData(_)));
7007        // gain length 5 is neither 2 (achromatic) nor 6 (RGB).
7008        let baseline = [0.0; 6];
7009        let err = m
7010            .apply_plane_rgb(&baseline, &[0.0; 5], 2, 1, 6.0)
7011            .unwrap_err();
7012        assert!(matches!(err, Error::InvalidData(_)));
7013    }
7014
7015    /// `apply_plane_rgb` on a zero-area plane is a valid empty result.
7016    #[test]
7017    fn apply_plane_rgb_zero_area_is_empty() {
7018        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
7019        let out = m.apply_plane_rgb(&[], &[], 0, 4, 8.0).unwrap();
7020        assert!(out.is_empty());
7021    }
7022
7023    /// `normalize_full_range_plane` maps the code-range endpoints of
7024    /// each supported depth to exactly 0.0 / 1.0 and mid values
7025    /// proportionally; out-of-range samples and unknown depths reject.
7026    #[test]
7027    fn normalize_full_range_plane_endpoints_and_guards() {
7028        for depth in [8u8, 10, 12, 16] {
7029            let max = ((1u32 << depth) - 1) as u16;
7030            let out = normalize_full_range_plane(&[0, max, max / 2], depth).unwrap();
7031            approx(out[0], 0.0);
7032            approx(out[1], 1.0);
7033            approx(out[2], (max / 2) as f64 / max as f64);
7034        }
7035        // 1024 exceeds the 10-bit code range.
7036        assert!(matches!(
7037            normalize_full_range_plane(&[1024], 10).unwrap_err(),
7038            Error::InvalidData(_)
7039        ));
7040        assert!(matches!(
7041            normalize_full_range_plane(&[0], 9).unwrap_err(),
7042            Error::InvalidData(_)
7043        ));
7044    }
7045
7046    /// `apply_plane_rgb_coded` over a 10-bit coded achromatic gain
7047    /// plane equals `apply_plane_rgb` fed the same samples normalized
7048    /// by hand — the coded path is exactly the full-range scaling plus
7049    /// the §6.3 application.
7050    #[test]
7051    fn apply_plane_rgb_coded_matches_manual_normalization() {
7052        let m = GainMapMetadata::parse(&build_singlechannel_gain_map()).unwrap();
7053        let baseline = [0.25, 0.5, 0.75, 1.0, 0.125, 0.6]; // 2×1×3
7054        let coded: [u16; 2] = [307, 921]; // 10-bit samples
7055        let manual: Vec<f64> = coded.iter().map(|&s| s as f64 / 1023.0).collect();
7056        let want = m.apply_plane_rgb(&baseline, &manual, 2, 1, 6.0).unwrap();
7057        let got = m
7058            .apply_plane_rgb_coded(&baseline, &coded, 10, 2, 1, 6.0)
7059            .unwrap();
7060        assert_eq!(got.len(), want.len());
7061        for (g, w) in got.iter().zip(&want) {
7062            approx(*g, *w);
7063        }
7064    }
7065
7066    // -------------------------------------------------------------------
7067    // AVIF Profile compliance audit (av1-avif v1.2.0 §8.2 / §8.3)
7068    // -------------------------------------------------------------------
7069
7070    /// Build an `av1C` payload whose byte 1 packs
7071    /// `seq_profile (3) | seq_level_idx_0 (5)` per av1-isobmff §2.3.
7072    /// Other bytes are filled with conventional values (marker=1,
7073    /// version=1, chroma 4:2:0 mono-cleared).
7074    fn av1c_with_profile_level(seq_profile: u8, seq_level_idx_0: u8) -> Vec<u8> {
7075        assert!(seq_profile <= 7);
7076        assert!(seq_level_idx_0 <= 31);
7077        let b1 = (seq_profile << 5) | (seq_level_idx_0 & 0x1F);
7078        vec![0x81, b1, 0x00, 0x00]
7079    }
7080
7081    fn brands_with(baseline: bool, advanced: bool) -> crate::parser::BrandClass {
7082        crate::parser::BrandClass {
7083            is_image: true,
7084            is_miaf: true,
7085            is_baseline_profile: baseline,
7086            is_advanced_profile: advanced,
7087            ..crate::parser::BrandClass::default()
7088        }
7089    }
7090
7091    /// Decoded `seq_profile` / `seq_level_idx_0` round-trip through
7092    /// the byte-1 helpers across the boundary values that the spec's
7093    /// §8.2 / §8.3 audit branches on.
7094    #[test]
7095    fn decode_av1c_byte1_round_trips_profile_and_level() {
7096        // (seq_profile, seq_level_idx_0) coverage: 0/13 (Baseline edge),
7097        // 0/14 (over Baseline level), 1/16 (Advanced edge), 1/17 (over
7098        // Advanced level), 2/0 (Professional disallowed), 7/31 (max bits).
7099        let cases = [(0, 13), (0, 14), (1, 16), (1, 17), (2, 0), (7, 31)];
7100        for (p, l) in cases {
7101            let bytes = av1c_with_profile_level(p, l);
7102            assert_eq!(decode_av1c_seq_profile(&bytes), Some(p), "p={p} l={l}");
7103            assert_eq!(decode_av1c_seq_level_idx_0(&bytes), Some(l), "p={p} l={l}");
7104        }
7105    }
7106
7107    /// Both helpers tolerate truncation without panicking.
7108    #[test]
7109    fn decode_av1c_byte1_handles_truncation() {
7110        assert_eq!(decode_av1c_seq_profile(&[]), None);
7111        assert_eq!(decode_av1c_seq_profile(&[0x81]), None);
7112        assert_eq!(decode_av1c_seq_level_idx_0(&[]), None);
7113        assert_eq!(decode_av1c_seq_level_idx_0(&[0x81]), None);
7114    }
7115
7116    /// MA1B + Main Profile + level 5.1 (`seq_profile=0, idx=13`) is the
7117    /// spec-canonical Baseline shape. Compliant.
7118    #[test]
7119    fn audit_profile_baseline_main_level_5_1_compliant() {
7120        let meta = Meta {
7121            items: vec![make_infe(1, b"av01", 0)],
7122            properties: vec![Property::Av1C(av1c_with_profile_level(0, 13))],
7123            associations: vec![ipa(1, &[0])],
7124            ..Meta::default()
7125        };
7126        let r = audit_avif_profile_compliance(&meta, &brands_with(true, false));
7127        assert_eq!(r.len(), 1);
7128        assert_eq!(r[0].profile, AvifProfile::Baseline);
7129        assert_eq!(r[0].seq_profile, Some(0));
7130        assert_eq!(r[0].seq_level_idx_0, Some(13));
7131        assert!(r[0].is_compliant());
7132        assert!(r[0].missing().is_empty());
7133    }
7134
7135    /// MA1B + Main + level 5.2 (`idx=14`) tips the level over the
7136    /// §8.2 bound. Flagged with `seq-level-idx-out-of-range`.
7137    #[test]
7138    fn audit_profile_baseline_level_above_5_1_flagged() {
7139        let meta = Meta {
7140            items: vec![make_infe(1, b"av01", 0)],
7141            properties: vec![Property::Av1C(av1c_with_profile_level(0, 14))],
7142            associations: vec![ipa(1, &[0])],
7143            ..Meta::default()
7144        };
7145        let r = audit_avif_profile_compliance(&meta, &brands_with(true, false));
7146        assert_eq!(r.len(), 1);
7147        assert!(!r[0].is_compliant());
7148        assert_eq!(r[0].missing(), vec!["seq-level-idx-out-of-range"]);
7149    }
7150
7151    /// MA1B + High Profile (`seq_profile=1`) violates §8.2's "Main
7152    /// Profile" requirement. Flagged with `seq-profile-out-of-range`.
7153    #[test]
7154    fn audit_profile_baseline_high_profile_flagged() {
7155        let meta = Meta {
7156            items: vec![make_infe(1, b"av01", 0)],
7157            properties: vec![Property::Av1C(av1c_with_profile_level(1, 13))],
7158            associations: vec![ipa(1, &[0])],
7159            ..Meta::default()
7160        };
7161        let r = audit_avif_profile_compliance(&meta, &brands_with(true, false));
7162        assert_eq!(r.len(), 1);
7163        assert!(!r[0].is_compliant());
7164        assert_eq!(r[0].missing(), vec!["seq-profile-out-of-range"]);
7165    }
7166
7167    /// MA1A + High Profile + level 6.0 (`idx=16`) is the spec-canonical
7168    /// Advanced shape. Compliant.
7169    #[test]
7170    fn audit_profile_advanced_high_level_6_0_compliant() {
7171        let meta = Meta {
7172            items: vec![make_infe(1, b"av01", 0)],
7173            properties: vec![Property::Av1C(av1c_with_profile_level(1, 16))],
7174            associations: vec![ipa(1, &[0])],
7175            ..Meta::default()
7176        };
7177        let r = audit_avif_profile_compliance(&meta, &brands_with(false, true));
7178        assert_eq!(r.len(), 1);
7179        assert_eq!(r[0].profile, AvifProfile::Advanced);
7180        assert!(r[0].is_compliant());
7181    }
7182
7183    /// MA1A + Main Profile (`seq_profile=0`) is also compliant — Main
7184    /// is a subset of High per AV1 §A.2.
7185    #[test]
7186    fn audit_profile_advanced_main_profile_compliant() {
7187        let meta = Meta {
7188            items: vec![make_infe(1, b"av01", 0)],
7189            properties: vec![Property::Av1C(av1c_with_profile_level(0, 13))],
7190            associations: vec![ipa(1, &[0])],
7191            ..Meta::default()
7192        };
7193        let r = audit_avif_profile_compliance(&meta, &brands_with(false, true));
7194        assert!(r[0].is_compliant());
7195    }
7196
7197    /// MA1A + Professional (`seq_profile=2`) breaches §8.3's "≤ High"
7198    /// requirement. Flagged.
7199    #[test]
7200    fn audit_profile_advanced_professional_flagged() {
7201        let meta = Meta {
7202            items: vec![make_infe(1, b"av01", 0)],
7203            properties: vec![Property::Av1C(av1c_with_profile_level(2, 16))],
7204            associations: vec![ipa(1, &[0])],
7205            ..Meta::default()
7206        };
7207        let r = audit_avif_profile_compliance(&meta, &brands_with(false, true));
7208        assert!(!r[0].is_compliant());
7209        assert_eq!(r[0].missing(), vec!["seq-profile-out-of-range"]);
7210    }
7211
7212    /// MA1A + level 6.1 (`idx=17`) breaches §8.3's "≤ 6.0".
7213    #[test]
7214    fn audit_profile_advanced_level_above_6_0_flagged() {
7215        let meta = Meta {
7216            items: vec![make_infe(1, b"av01", 0)],
7217            properties: vec![Property::Av1C(av1c_with_profile_level(1, 17))],
7218            associations: vec![ipa(1, &[0])],
7219            ..Meta::default()
7220        };
7221        let r = audit_avif_profile_compliance(&meta, &brands_with(false, true));
7222        assert!(!r[0].is_compliant());
7223        assert_eq!(r[0].missing(), vec!["seq-level-idx-out-of-range"]);
7224    }
7225
7226    /// AV1 §A.3's `seq_level_idx_0 == 31` "Maximum parameters" carve-out
7227    /// signals unconstrained sizing, which is outside either profile's
7228    /// reach — even though Main Profile (`seq_profile=0`) is compliant
7229    /// with §8.2's profile clause, the level clause is unmet.
7230    #[test]
7231    fn audit_profile_level_31_max_parameters_flagged_for_baseline() {
7232        let meta = Meta {
7233            items: vec![make_infe(1, b"av01", 0)],
7234            properties: vec![Property::Av1C(av1c_with_profile_level(0, 31))],
7235            associations: vec![ipa(1, &[0])],
7236            ..Meta::default()
7237        };
7238        let r = audit_avif_profile_compliance(&meta, &brands_with(true, false));
7239        assert!(!r[0].is_compliant());
7240        assert_eq!(r[0].missing(), vec!["seq-level-idx-out-of-range"]);
7241    }
7242
7243    /// Missing `av1C` surfaces the missing-av1c flag distinctly from
7244    /// a truncated `av1C`.
7245    #[test]
7246    fn audit_profile_missing_av1c_flagged() {
7247        let meta = Meta {
7248            items: vec![make_infe(1, b"av01", 0)],
7249            properties: vec![],
7250            associations: vec![],
7251            ..Meta::default()
7252        };
7253        let r = audit_avif_profile_compliance(&meta, &brands_with(true, false));
7254        assert_eq!(r.len(), 1);
7255        assert!(r[0].missing_av1c);
7256        assert_eq!(r[0].seq_profile, None);
7257        assert_eq!(r[0].seq_level_idx_0, None);
7258        assert!(!r[0].is_compliant());
7259        assert_eq!(r[0].missing(), vec!["item-missing-av1C"]);
7260    }
7261
7262    /// Truncated `av1C` (`< 2` bytes) surfaces as item-av1C-truncated,
7263    /// distinct from missing-av1c.
7264    #[test]
7265    fn audit_profile_truncated_av1c_flagged() {
7266        let meta = Meta {
7267            items: vec![make_infe(1, b"av01", 0)],
7268            properties: vec![Property::Av1C(vec![0x81])], // only 1 byte
7269            associations: vec![ipa(1, &[0])],
7270            ..Meta::default()
7271        };
7272        let r = audit_avif_profile_compliance(&meta, &brands_with(true, false));
7273        assert_eq!(r.len(), 1);
7274        assert!(!r[0].missing_av1c);
7275        assert_eq!(r[0].seq_profile, None);
7276        assert_eq!(r[0].seq_level_idx_0, None);
7277        assert!(!r[0].is_compliant());
7278        assert_eq!(r[0].missing(), vec!["item-av1C-truncated"]);
7279    }
7280
7281    /// File declaring both `MA1B` and `MA1A` emits one record per
7282    /// brand, Baseline before Advanced.
7283    #[test]
7284    fn audit_profile_both_brands_emit_two_records_per_item() {
7285        let meta = Meta {
7286            items: vec![make_infe(1, b"av01", 0)],
7287            // Main profile + level 5.1 — compliant with both brands.
7288            properties: vec![Property::Av1C(av1c_with_profile_level(0, 13))],
7289            associations: vec![ipa(1, &[0])],
7290            ..Meta::default()
7291        };
7292        let r = audit_avif_profile_compliance(&meta, &brands_with(true, true));
7293        assert_eq!(r.len(), 2);
7294        assert_eq!(r[0].profile, AvifProfile::Baseline);
7295        assert_eq!(r[1].profile, AvifProfile::Advanced);
7296        assert!(r[0].is_compliant());
7297        assert!(r[1].is_compliant());
7298    }
7299
7300    /// File declaring neither `MA1B` nor `MA1A` skips the audit
7301    /// entirely. The §8 constraints only apply to files claiming the
7302    /// brand; an unbranded AVIF has nothing to fail.
7303    #[test]
7304    fn audit_profile_no_brand_claim_returns_empty() {
7305        let meta = Meta {
7306            items: vec![make_infe(1, b"av01", 0)],
7307            properties: vec![Property::Av1C(av1c_with_profile_level(2, 31))], // would fail both
7308            associations: vec![ipa(1, &[0])],
7309            ..Meta::default()
7310        };
7311        let r = audit_avif_profile_compliance(&meta, &brands_with(false, false));
7312        assert!(r.is_empty());
7313    }
7314
7315    /// File with no AV1 Image Items returns an empty audit even when
7316    /// the brand is declared (degenerate case — `meta.item_ids_of_type`
7317    /// returns nothing).
7318    #[test]
7319    fn audit_profile_no_av01_items_returns_empty() {
7320        let meta = Meta {
7321            items: vec![make_infe(1, b"mime", 0)],
7322            ..Meta::default()
7323        };
7324        let r = audit_avif_profile_compliance(&meta, &brands_with(true, false));
7325        assert!(r.is_empty());
7326    }
7327
7328    /// Two AV1 Image Items in a file declaring both brands → 4
7329    /// records, paired (Baseline, Advanced) per item in iinf order.
7330    #[test]
7331    fn audit_profile_two_items_two_brands_four_records() {
7332        let meta = Meta {
7333            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
7334            properties: vec![
7335                Property::Av1C(av1c_with_profile_level(0, 13)), // item 1: compliant both
7336                Property::Av1C(av1c_with_profile_level(2, 16)), // item 2: Advanced-fails profile
7337            ],
7338            associations: vec![ipa(1, &[0]), ipa(2, &[1])],
7339            ..Meta::default()
7340        };
7341        let r = audit_avif_profile_compliance(&meta, &brands_with(true, true));
7342        assert_eq!(r.len(), 4);
7343        // Item 1 first (iinf order), then item 2.
7344        assert_eq!(r[0].item_id, 1);
7345        assert_eq!(r[0].profile, AvifProfile::Baseline);
7346        assert!(r[0].is_compliant());
7347        assert_eq!(r[1].item_id, 1);
7348        assert_eq!(r[1].profile, AvifProfile::Advanced);
7349        assert!(r[1].is_compliant());
7350        assert_eq!(r[2].item_id, 2);
7351        assert_eq!(r[2].profile, AvifProfile::Baseline);
7352        assert!(!r[2].is_compliant()); // seq_profile=2 fails Baseline
7353        assert_eq!(r[3].item_id, 2);
7354        assert_eq!(r[3].profile, AvifProfile::Advanced);
7355        assert!(!r[3].is_compliant()); // seq_profile=2 fails Advanced too
7356    }
7357
7358    // -----------------------------------------------------------------------
7359    // Derived-image geometry resolution (HEIF §6.3 / §6.6.2)
7360    // -----------------------------------------------------------------------
7361
7362    use crate::meta::{
7363        Clap as MClap, Imir as MImir, Irot as MIrot, Iscl as MIscl, ItemInfo as MII,
7364        ItemPropertyAssociation as MIpa, Meta as MMeta, Property as MProp,
7365        PropertyAssociation as MPa,
7366    };
7367
7368    fn geo_ii(id: u32, item_type: &[u8; 4]) -> MII {
7369        MII {
7370            id,
7371            item_type: *item_type,
7372            name: String::new(),
7373            content_type: None,
7374            content_encoding: None,
7375            item_uri_type: None,
7376            flags: 0,
7377        }
7378    }
7379
7380    fn geo_assoc(item_id: u32, indices: &[u16]) -> MIpa {
7381        MIpa {
7382            item_id,
7383            entries: indices
7384                .iter()
7385                .map(|&index| MPa {
7386                    index,
7387                    essential: true,
7388                })
7389                .collect(),
7390        }
7391    }
7392
7393    fn ispe_prop(w: u32, h: u32) -> MProp {
7394        MProp::Ispe(crate::meta::Ispe {
7395            width: w,
7396            height: h,
7397        })
7398    }
7399
7400    /// `DimTransform::apply_dims` — rotation by 90°/270° swaps W/H; 0°/180°
7401    /// preserve.
7402    #[test]
7403    fn dim_transform_rotate_swaps_at_odd_quarter_turns() {
7404        assert_eq!(
7405            DimTransform::Rotate { angle: 0 }.apply_dims(40, 30),
7406            (40, 30)
7407        );
7408        assert_eq!(
7409            DimTransform::Rotate { angle: 1 }.apply_dims(40, 30),
7410            (30, 40)
7411        );
7412        assert_eq!(
7413            DimTransform::Rotate { angle: 2 }.apply_dims(40, 30),
7414            (40, 30)
7415        );
7416        assert_eq!(
7417            DimTransform::Rotate { angle: 3 }.apply_dims(40, 30),
7418            (30, 40)
7419        );
7420    }
7421
7422    /// Mirror never changes dimensions (§6.5.12).
7423    #[test]
7424    fn dim_transform_mirror_preserves_dims() {
7425        assert_eq!(
7426            DimTransform::Mirror { axis: 0 }.apply_dims(40, 30),
7427            (40, 30)
7428        );
7429        assert_eq!(
7430            DimTransform::Mirror { axis: 1 }.apply_dims(40, 30),
7431            (40, 30)
7432        );
7433    }
7434
7435    /// Crop reduces dimensions to the clean-aperture width/height; a crop
7436    /// larger than the input is a no-op (defensive, matches `apply_clap`).
7437    #[test]
7438    fn dim_transform_crop_to_clean_aperture() {
7439        let c = DimTransform::Crop {
7440            width_n: 20,
7441            width_d: 1,
7442            height_n: 10,
7443            height_d: 1,
7444        };
7445        assert_eq!(c.apply_dims(40, 30), (20, 10));
7446        // Crop wider than input → unchanged.
7447        let big = DimTransform::Crop {
7448            width_n: 100,
7449            width_d: 1,
7450            height_n: 10,
7451            height_d: 1,
7452        };
7453        assert_eq!(big.apply_dims(40, 30), (40, 30));
7454        // Zero denominator → unchanged.
7455        let zero = DimTransform::Crop {
7456            width_n: 20,
7457            width_d: 0,
7458            height_n: 10,
7459            height_d: 1,
7460        };
7461        assert_eq!(zero.apply_dims(40, 30), (40, 30));
7462    }
7463
7464    /// Scale applies `ceil(input * num / den)` per §6.5.13.
7465    #[test]
7466    fn dim_transform_scale_ceil() {
7467        let s = DimTransform::Scale {
7468            target_width_numerator: 3,
7469            target_width_denominator: 2,
7470            target_height_numerator: 1,
7471            target_height_denominator: 2,
7472        };
7473        // 40 * 3 / 2 = 60; 30 * 1 / 2 = 15.
7474        assert_eq!(s.apply_dims(40, 30), (60, 15));
7475        // Ceiling: 41 * 1 / 2 = 20.5 → 21.
7476        assert_eq!(
7477            DimTransform::Scale {
7478                target_width_numerator: 1,
7479                target_width_denominator: 2,
7480                target_height_numerator: 1,
7481                target_height_denominator: 1,
7482            }
7483            .apply_dims(41, 9),
7484            (21, 9)
7485        );
7486    }
7487
7488    /// `transform_chain` collects only the dimension-affecting transformative
7489    /// properties, in `ipma` order, skipping descriptive properties.
7490    #[test]
7491    fn transform_chain_preserves_ipma_order_skips_descriptive() {
7492        let meta = MMeta {
7493            items: vec![geo_ii(1, b"av01")],
7494            properties: vec![
7495                ispe_prop(100, 50),              // 0: descriptive, skipped
7496                MProp::Irot(MIrot { angle: 1 }), // 1: rotate
7497                MProp::Clap(MClap {
7498                    // 2: crop
7499                    clean_aperture_width_n: 40,
7500                    clean_aperture_width_d: 1,
7501                    clean_aperture_height_n: 30,
7502                    clean_aperture_height_d: 1,
7503                    horiz_off_n: 0,
7504                    horiz_off_d: 1,
7505                    vert_off_n: 0,
7506                    vert_off_d: 1,
7507                }),
7508                MProp::Imir(MImir { axis: 1 }), // 3: mirror
7509            ],
7510            // ipma order: ispe, irot, clap, imir.
7511            associations: vec![geo_assoc(1, &[0, 1, 2, 3])],
7512            ..MMeta::default()
7513        };
7514        let chain = transform_chain(&meta, 1);
7515        assert_eq!(chain.len(), 3);
7516        assert_eq!(chain[0], DimTransform::Rotate { angle: 1 });
7517        assert!(matches!(chain[1], DimTransform::Crop { .. }));
7518        assert_eq!(chain[2], DimTransform::Mirror { axis: 1 });
7519    }
7520
7521    /// `output_dims_from_reconstructed` folds the chain in order: a coded
7522    /// 100×50 image rotated 90° → 50×100, then clap-cropped to 40×30.
7523    #[test]
7524    fn output_dims_folds_chain_in_order() {
7525        let meta = MMeta {
7526            items: vec![geo_ii(1, b"av01")],
7527            properties: vec![
7528                MProp::Irot(MIrot { angle: 1 }),
7529                MProp::Clap(MClap {
7530                    clean_aperture_width_n: 40,
7531                    clean_aperture_width_d: 1,
7532                    clean_aperture_height_n: 30,
7533                    clean_aperture_height_d: 1,
7534                    horiz_off_n: 0,
7535                    horiz_off_d: 1,
7536                    vert_off_n: 0,
7537                    vert_off_d: 1,
7538                }),
7539            ],
7540            associations: vec![geo_assoc(1, &[0, 1])],
7541            ..MMeta::default()
7542        };
7543        // Reconstructed 100×50, rotate → 50×100, crop → 40×30.
7544        assert_eq!(output_dims_from_reconstructed(&meta, 1, 100, 50), (40, 30));
7545        // Empty chain → identity.
7546        let bare = MMeta {
7547            items: vec![geo_ii(9, b"av01")],
7548            ..MMeta::default()
7549        };
7550        assert_eq!(output_dims_from_reconstructed(&bare, 9, 100, 50), (100, 50));
7551    }
7552
7553    /// `reconstructed_dims` for a coded item reads `ispe`.
7554    #[test]
7555    fn reconstructed_dims_coded_uses_ispe() {
7556        let meta = MMeta {
7557            items: vec![geo_ii(1, b"av01")],
7558            properties: vec![ispe_prop(640, 480)],
7559            associations: vec![geo_assoc(1, &[0])],
7560            ..MMeta::default()
7561        };
7562        assert_eq!(reconstructed_dims(&meta, 1, &[], None), Some((640, 480)));
7563        // No ispe → None.
7564        let bare = MMeta {
7565            items: vec![geo_ii(2, b"av01")],
7566            ..MMeta::default()
7567        };
7568        assert_eq!(reconstructed_dims(&bare, 2, &[], None), None);
7569    }
7570
7571    /// Build a 16-bit `iovl` descriptor with the given canvas + offsets.
7572    fn iovl_desc(out_w: u16, out_h: u16, offsets: &[(i16, i16)]) -> Vec<u8> {
7573        let mut buf = vec![0u8, 0u8]; // version 0, flags 0 (16-bit fields)
7574        for _ in 0..4 {
7575            buf.extend_from_slice(&0u16.to_be_bytes()); // canvas fill
7576        }
7577        buf.extend_from_slice(&out_w.to_be_bytes());
7578        buf.extend_from_slice(&out_h.to_be_bytes());
7579        for &(x, y) in offsets {
7580            buf.extend_from_slice(&x.to_be_bytes());
7581            buf.extend_from_slice(&y.to_be_bytes());
7582        }
7583        buf
7584    }
7585
7586    /// End-to-end `iovl` resolution against an `idat`-backed descriptor:
7587    /// two inputs placed on a 200×200 canvas, the second partially clipped
7588    /// at the right/bottom edge.
7589    #[test]
7590    fn resolve_overlays_idat_with_clipping() {
7591        // idat holds the iovl descriptor at offset 0.
7592        let desc = iovl_desc(200, 200, &[(10, 20), (160, 170)]);
7593        let idat = desc.clone();
7594        let meta = MMeta {
7595            items: vec![geo_ii(1, b"iovl"), geo_ii(2, b"av01"), geo_ii(3, b"av01")],
7596            properties: vec![ispe_prop(80, 60), ispe_prop(80, 60)],
7597            associations: vec![geo_assoc(2, &[0]), geo_assoc(3, &[1])],
7598            irefs: vec![IrefEntry {
7599                reference_type: *b"dimg",
7600                from_id: 1,
7601                to_ids: vec![2, 3],
7602            }],
7603            locations: vec![ItemLocation {
7604                id: 1,
7605                construction_method: 1, // idat
7606                data_reference_index: 0,
7607                base_offset: 0,
7608                extents: vec![IlocExtent {
7609                    offset: 0,
7610                    length: desc.len() as u64,
7611                    extent_index: 0,
7612                }],
7613            }],
7614            ..MMeta::default()
7615        };
7616        let res = resolve_overlays(&meta, &[], Some(&idat));
7617        assert_eq!(res.len(), 1);
7618        let r = &res[0];
7619        assert_eq!(r.iovl_item_id, 1);
7620        assert_eq!(r.canvas(), (200, 200));
7621        assert_eq!(r.placements.len(), 2);
7622
7623        // First input: fully visible at (10, 20), 80×60.
7624        let p0 = &r.placements[0];
7625        assert_eq!(p0.source_item_id, 2);
7626        assert_eq!((p0.input_width, p0.input_height), (80, 60));
7627        assert!(p0.fully_visible(200, 200));
7628        assert_eq!(p0.visible(200, 200), Some((10, 20, 80, 60)));
7629
7630        // Second input: at (160, 170), 80×60 → clipped to (160,170,40,30).
7631        let p1 = &r.placements[1];
7632        assert_eq!(p1.source_item_id, 3);
7633        assert!(!p1.fully_visible(200, 200));
7634        assert_eq!(p1.visible(200, 200), Some((160, 170, 40, 30)));
7635        // Not fully covered → fill colour shows through.
7636        assert!(r.canvas_partially_filled());
7637    }
7638
7639    /// An overlay input placed entirely off-canvas (negative beyond its own
7640    /// extent) is reported as off-canvas with no visible rectangle.
7641    #[test]
7642    fn overlay_placement_off_canvas() {
7643        let p = OverlayPlacement {
7644            source_item_id: 5,
7645            offset_x: -100,
7646            offset_y: 0,
7647            input_width: 50,
7648            input_height: 50,
7649        };
7650        assert!(p.off_canvas(200, 200));
7651        assert_eq!(p.visible(200, 200), None);
7652        // A negative offset that still overlaps → partial visible rect.
7653        let q = OverlayPlacement { offset_x: -10, ..p };
7654        assert_eq!(q.visible(200, 200), Some((0, 0, 40, 50)));
7655    }
7656
7657    /// End-to-end `iden` resolution: an identity derivation that crops its
7658    /// 100×80 source via a `clap` carried on the iden item itself
7659    /// (§6.6.2.1 NOTE 2).
7660    #[test]
7661    fn resolve_iden_applies_iden_transforms() {
7662        let meta = MMeta {
7663            items: vec![geo_ii(1, b"iden"), geo_ii(2, b"av01")],
7664            properties: vec![
7665                ispe_prop(100, 80), // 0: source ispe
7666                MProp::Clap(MClap {
7667                    // 1: clap on the iden item
7668                    clean_aperture_width_n: 50,
7669                    clean_aperture_width_d: 1,
7670                    clean_aperture_height_n: 40,
7671                    clean_aperture_height_d: 1,
7672                    horiz_off_n: 0,
7673                    horiz_off_d: 1,
7674                    vert_off_n: 0,
7675                    vert_off_d: 1,
7676                }),
7677            ],
7678            associations: vec![geo_assoc(2, &[0]), geo_assoc(1, &[1])],
7679            irefs: vec![IrefEntry {
7680                reference_type: *b"dimg",
7681                from_id: 1,
7682                to_ids: vec![2],
7683            }],
7684            ..MMeta::default()
7685        };
7686        let res = resolve_iden_derivations(&meta, &[], None);
7687        assert_eq!(res.len(), 1);
7688        let r = &res[0];
7689        assert_eq!(r.iden_item_id, 1);
7690        assert_eq!(r.source_item_id, Some(2));
7691        assert_eq!(r.source_dims, Some((100, 80)));
7692        assert_eq!(r.transforms.len(), 1);
7693        assert_eq!(r.output_dims, Some((50, 40)));
7694    }
7695
7696    /// An `iden` whose source is itself a grid resolves transitively: the
7697    /// grid's `output_width/height` become the iden's source dims, then the
7698    /// iden's `irot` swaps them.
7699    #[test]
7700    fn reconstructed_dims_iden_over_grid_with_rotation() {
7701        // grid descriptor: version 0, flags 0, rows=1, cols=1, 256×128.
7702        let mut grid = vec![0u8, 0u8, 0u8, 0u8];
7703        grid.extend_from_slice(&256u16.to_be_bytes());
7704        grid.extend_from_slice(&128u16.to_be_bytes());
7705        let idat = grid.clone();
7706        let meta = MMeta {
7707            items: vec![geo_ii(1, b"iden"), geo_ii(2, b"grid"), geo_ii(3, b"av01")],
7708            properties: vec![
7709                ispe_prop(256, 128),             // 0: tile ispe (unused for grid dims)
7710                MProp::Irot(MIrot { angle: 1 }), // 1: rotate on the iden
7711            ],
7712            associations: vec![geo_assoc(3, &[0]), geo_assoc(1, &[1])],
7713            irefs: vec![
7714                IrefEntry {
7715                    reference_type: *b"dimg",
7716                    from_id: 1,
7717                    to_ids: vec![2],
7718                },
7719                IrefEntry {
7720                    reference_type: *b"dimg",
7721                    from_id: 2,
7722                    to_ids: vec![3],
7723                },
7724            ],
7725            locations: vec![ItemLocation {
7726                id: 2,
7727                construction_method: 1,
7728                data_reference_index: 0,
7729                base_offset: 0,
7730                extents: vec![IlocExtent {
7731                    offset: 0,
7732                    length: grid.len() as u64,
7733                    extent_index: 0,
7734                }],
7735            }],
7736            ..MMeta::default()
7737        };
7738        // iden reconstructed dims = output image of its grid source = 256×128
7739        // (grid has no transforms), then the iden's irot swaps → 128×256.
7740        let res = resolve_iden_derivations(&meta, &[], Some(&idat));
7741        assert_eq!(res.len(), 1);
7742        assert_eq!(res[0].source_dims, Some((256, 128)));
7743        assert_eq!(res[0].output_dims, Some((128, 256)));
7744    }
7745
7746    /// A `dimg` cycle (iden → iden → …) is broken by the depth guard rather
7747    /// than recursing forever.
7748    #[test]
7749    fn reconstructed_dims_cycle_guard() {
7750        let meta = MMeta {
7751            items: vec![geo_ii(1, b"iden"), geo_ii(2, b"iden")],
7752            irefs: vec![
7753                IrefEntry {
7754                    reference_type: *b"dimg",
7755                    from_id: 1,
7756                    to_ids: vec![2],
7757                },
7758                IrefEntry {
7759                    reference_type: *b"dimg",
7760                    from_id: 2,
7761                    to_ids: vec![1],
7762                },
7763            ],
7764            ..MMeta::default()
7765        };
7766        // Must terminate and return None rather than overflow the stack.
7767        assert_eq!(reconstructed_dims(&meta, 1, &[], None), None);
7768    }
7769
7770    /// `iscl` participates in the chain — verify a coded item scaled 2×.
7771    #[test]
7772    fn output_dims_with_iscl() {
7773        let meta = MMeta {
7774            items: vec![geo_ii(1, b"av01")],
7775            properties: vec![MProp::Iscl(MIscl {
7776                target_width_numerator: 2,
7777                target_width_denominator: 1,
7778                target_height_numerator: 2,
7779                target_height_denominator: 1,
7780            })],
7781            associations: vec![geo_assoc(1, &[0])],
7782            ..MMeta::default()
7783        };
7784        assert_eq!(output_dims_from_reconstructed(&meta, 1, 64, 48), (128, 96));
7785    }
7786
7787    /// av1-avif §4.2.2: a `tmap` derived item's reconstructed dimensions are
7788    /// the *base* image input's (input 0) output dimensions, not the gain
7789    /// map's. Base = 200×150 coded, gain map = 100×75 coded; the tmap
7790    /// resolves to 200×150.
7791    #[test]
7792    fn reconstructed_dims_tmap_takes_base_extents() {
7793        let meta = MMeta {
7794            items: vec![
7795                geo_ii(1, b"tmap"),
7796                geo_ii(2, b"av01"), // base
7797                geo_ii(3, b"av01"), // gain map
7798            ],
7799            properties: vec![ispe_prop(200, 150), ispe_prop(100, 75)],
7800            associations: vec![geo_assoc(2, &[0]), geo_assoc(3, &[1])],
7801            irefs: vec![IrefEntry {
7802                reference_type: *b"dimg",
7803                from_id: 1,
7804                to_ids: vec![2, 3],
7805            }],
7806            ..MMeta::default()
7807        };
7808        assert_eq!(reconstructed_dims(&meta, 1, &[], None), Some((200, 150)));
7809    }
7810
7811    /// A `tmap` whose base input is itself a transformed coded item folds the
7812    /// base's transforms into the rendered extents. Base coded 200×150 +
7813    /// `irot` 90° → base output 150×200 → tmap reconstructs 150×200.
7814    #[test]
7815    fn reconstructed_dims_tmap_folds_base_transforms() {
7816        let meta = MMeta {
7817            items: vec![geo_ii(1, b"tmap"), geo_ii(2, b"av01"), geo_ii(3, b"av01")],
7818            properties: vec![
7819                ispe_prop(200, 150),             // 0: base ispe
7820                MProp::Irot(MIrot { angle: 1 }), // 1: base irot (swaps dims)
7821                ispe_prop(100, 75),              // 2: gain map ispe
7822            ],
7823            associations: vec![geo_assoc(2, &[0, 1]), geo_assoc(3, &[2])],
7824            irefs: vec![IrefEntry {
7825                reference_type: *b"dimg",
7826                from_id: 1,
7827                to_ids: vec![2, 3],
7828            }],
7829            ..MMeta::default()
7830        };
7831        assert_eq!(reconstructed_dims(&meta, 1, &[], None), Some((150, 200)));
7832    }
7833
7834    /// `resolve_tone_maps` surfaces the base id, gain-map ids, rendered
7835    /// extents, and per-gain-map coded dims, and detects gain-map
7836    /// up-sampling.
7837    #[test]
7838    fn resolve_tone_maps_reports_geometry_and_upsampling() {
7839        let meta = MMeta {
7840            items: vec![geo_ii(1, b"tmap"), geo_ii(2, b"av01"), geo_ii(3, b"av01")],
7841            properties: vec![ispe_prop(200, 150), ispe_prop(100, 75)],
7842            associations: vec![geo_assoc(2, &[0]), geo_assoc(3, &[1])],
7843            irefs: vec![IrefEntry {
7844                reference_type: *b"dimg",
7845                from_id: 1,
7846                to_ids: vec![2, 3],
7847            }],
7848            ..MMeta::default()
7849        };
7850        let res = resolve_tone_maps(&meta, &[], None);
7851        assert_eq!(res.len(), 1);
7852        let r = &res[0];
7853        assert_eq!(r.tmap_item_id, 1);
7854        assert_eq!(r.base_item_id, Some(2));
7855        assert_eq!(r.gain_map_item_ids, vec![3]);
7856        assert_eq!(r.rendered_dims, Some((200, 150)));
7857        assert_eq!(r.gain_map_dims, vec![(3, Some((100, 75)))]);
7858        // 100×75 gain map under a 200×150 render → up-sampled.
7859        assert!(r.upsamples_gain_map());
7860    }
7861
7862    /// A gain map coded at the same extents as the base is not up-sampled.
7863    #[test]
7864    fn resolve_tone_maps_no_upsampling_when_gain_map_matches_base() {
7865        let meta = MMeta {
7866            items: vec![geo_ii(1, b"tmap"), geo_ii(2, b"av01"), geo_ii(3, b"av01")],
7867            properties: vec![ispe_prop(200, 150)],
7868            associations: vec![geo_assoc(2, &[0]), geo_assoc(3, &[0])],
7869            irefs: vec![IrefEntry {
7870                reference_type: *b"dimg",
7871                from_id: 1,
7872                to_ids: vec![2, 3],
7873            }],
7874            ..MMeta::default()
7875        };
7876        let r = &resolve_tone_maps(&meta, &[], None)[0];
7877        assert_eq!(r.rendered_dims, Some((200, 150)));
7878        assert_eq!(r.gain_map_dims, vec![(3, Some((200, 150)))]);
7879        assert!(!r.upsamples_gain_map());
7880    }
7881
7882    /// av1-avif §4.2.3.1: a `sato` Sample-Transform derived item carries its
7883    /// own `ispe` (its inputs share those extents). `reconstructed_dims`
7884    /// reads that `ispe` directly.
7885    #[test]
7886    fn reconstructed_dims_sato_reads_own_ispe() {
7887        let meta = MMeta {
7888            items: vec![geo_ii(1, b"sato"), geo_ii(2, b"av01"), geo_ii(3, b"av01")],
7889            properties: vec![ispe_prop(320, 240)],
7890            associations: vec![geo_assoc(1, &[0])],
7891            irefs: vec![IrefEntry {
7892                reference_type: *b"dimg",
7893                from_id: 1,
7894                to_ids: vec![2, 3],
7895            }],
7896            ..MMeta::default()
7897        };
7898        assert_eq!(reconstructed_dims(&meta, 1, &[], None), Some((320, 240)));
7899    }
7900
7901    /// When a `sato` lacks its own `ispe` (malformed per §4.2.3.1), the
7902    /// resolver falls back to its first input's reconstructed dimensions —
7903    /// every input shares the sato's extents, so input 0 is representative.
7904    #[test]
7905    fn reconstructed_dims_sato_falls_back_to_input_extents() {
7906        let meta = MMeta {
7907            items: vec![geo_ii(1, b"sato"), geo_ii(2, b"av01"), geo_ii(3, b"av01")],
7908            // No ispe on the sato item itself; only on the inputs.
7909            properties: vec![ispe_prop(320, 240)],
7910            associations: vec![geo_assoc(2, &[0]), geo_assoc(3, &[0])],
7911            irefs: vec![IrefEntry {
7912                reference_type: *b"dimg",
7913                from_id: 1,
7914                to_ids: vec![2, 3],
7915            }],
7916            ..MMeta::default()
7917        };
7918        assert_eq!(reconstructed_dims(&meta, 1, &[], None), Some((320, 240)));
7919    }
7920
7921    /// Build a 16-bit `ImageGrid` descriptor payload (version 0, flags 0).
7922    fn build_grid_desc(rows: u8, cols: u8, out_w: u16, out_h: u16) -> Vec<u8> {
7923        let mut b = vec![0u8, 0u8, rows.saturating_sub(1), cols.saturating_sub(1)];
7924        b.extend_from_slice(&out_w.to_be_bytes());
7925        b.extend_from_slice(&out_h.to_be_bytes());
7926        b
7927    }
7928
7929    /// `resolve_grids` resolves a 2×2 grid of 100×100 tiles into a 180×180
7930    /// canvas: tiles placed row-major at (0,0)/(100,0)/(0,100)/(100,100),
7931    /// with the right column + bottom row trimmed to the 180 px output.
7932    #[test]
7933    fn resolve_grids_places_tiles_and_trims_edges() {
7934        let desc = build_grid_desc(2, 2, 180, 180);
7935        let idat = desc.clone();
7936        let meta = MMeta {
7937            items: vec![
7938                geo_ii(1, b"grid"),
7939                geo_ii(2, b"av01"),
7940                geo_ii(3, b"av01"),
7941                geo_ii(4, b"av01"),
7942                geo_ii(5, b"av01"),
7943            ],
7944            properties: vec![ispe_prop(100, 100)],
7945            // every tile shares the same 100×100 ispe (property index 0)
7946            associations: vec![
7947                geo_assoc(2, &[0]),
7948                geo_assoc(3, &[0]),
7949                geo_assoc(4, &[0]),
7950                geo_assoc(5, &[0]),
7951            ],
7952            irefs: vec![IrefEntry {
7953                reference_type: *b"dimg",
7954                from_id: 1,
7955                to_ids: vec![2, 3, 4, 5],
7956            }],
7957            locations: vec![ItemLocation {
7958                id: 1,
7959                construction_method: 1,
7960                data_reference_index: 0,
7961                base_offset: 0,
7962                extents: vec![IlocExtent {
7963                    offset: 0,
7964                    length: desc.len() as u64,
7965                    extent_index: 0,
7966                }],
7967            }],
7968            ..MMeta::default()
7969        };
7970        let res = resolve_grids(&meta, &[], Some(&idat));
7971        assert_eq!(res.len(), 1);
7972        let r = &res[0];
7973        assert_eq!(r.grid_item_id, 1);
7974        assert_eq!(r.canvas(), (180, 180));
7975        assert_eq!(r.tile_dims, Some((100, 100)));
7976        assert!(r.covers_canvas());
7977        assert_eq!(r.placements.len(), 4);
7978        // Row-major placement.
7979        assert_eq!(
7980            r.placements[0],
7981            GridTilePlacement {
7982                source_item_id: 2,
7983                row: 0,
7984                col: 0,
7985                origin_x: 0,
7986                origin_y: 0
7987            }
7988        );
7989        assert_eq!(
7990            r.placements[3],
7991            GridTilePlacement {
7992                source_item_id: 5,
7993                row: 1,
7994                col: 1,
7995                origin_x: 100,
7996                origin_y: 100
7997            }
7998        );
7999        // Top-left tile fully inside; bottom-right tile trimmed to 80×80.
8000        assert_eq!(
8001            r.placements[0].visible(100, 100, 180, 180),
8002            Some((0, 0, 100, 100))
8003        );
8004        assert_eq!(
8005            r.placements[3].visible(100, 100, 180, 180),
8006            Some((100, 100, 80, 80))
8007        );
8008        assert!(!r.placements[0].is_trimmed(100, 100, 180, 180));
8009        assert!(r.placements[3].is_trimmed(100, 100, 180, 180));
8010        // The right column (1) + bottom row (2) + corner = 3 trimmed tiles.
8011        assert_eq!(r.trimmed_tile_count(), 3);
8012    }
8013
8014    /// A grid whose output is a whole multiple of the tile size trims no
8015    /// tiles and covers the canvas exactly.
8016    #[test]
8017    fn resolve_grids_no_trim_on_exact_multiple() {
8018        let desc = build_grid_desc(1, 3, 192, 64);
8019        let idat = desc.clone();
8020        let meta = MMeta {
8021            items: vec![
8022                geo_ii(1, b"grid"),
8023                geo_ii(2, b"av01"),
8024                geo_ii(3, b"av01"),
8025                geo_ii(4, b"av01"),
8026            ],
8027            properties: vec![ispe_prop(64, 64)],
8028            associations: vec![geo_assoc(2, &[0]), geo_assoc(3, &[0]), geo_assoc(4, &[0])],
8029            irefs: vec![IrefEntry {
8030                reference_type: *b"dimg",
8031                from_id: 1,
8032                to_ids: vec![2, 3, 4],
8033            }],
8034            locations: vec![ItemLocation {
8035                id: 1,
8036                construction_method: 1,
8037                data_reference_index: 0,
8038                base_offset: 0,
8039                extents: vec![IlocExtent {
8040                    offset: 0,
8041                    length: desc.len() as u64,
8042                    extent_index: 0,
8043                }],
8044            }],
8045            ..MMeta::default()
8046        };
8047        let r = &resolve_grids(&meta, &[], Some(&idat))[0];
8048        assert_eq!(r.tile_dims, Some((64, 64)));
8049        assert!(r.covers_canvas());
8050        assert_eq!(r.trimmed_tile_count(), 0);
8051        assert_eq!(r.placements[2].origin_x, 128);
8052    }
8053
8054    /// A `grid` descriptor stored via `construction_method == 2` (item-offset)
8055    /// — its bytes are a range of another item's data, named through the
8056    /// `'iloc'` item reference (ISO/IEC 14496-12 §8.11.3.3). `resolve_grids`
8057    /// (through `resolve_descriptor_bytes`) now follows the iref instead of
8058    /// silently skipping the grid.
8059    #[test]
8060    fn resolve_grids_resolves_cm2_item_offset_descriptor() {
8061        // Item 10 holds the raw grid descriptor bytes at file offset (cm=0);
8062        // the grid item 1 is cm=2 and points at item 10 via an `'iloc'` iref,
8063        // taking the whole referenced item (extent_length 0).
8064        let desc = build_grid_desc(1, 2, 128, 64);
8065        // Lay the descriptor bytes into a synthetic file at offset 8.
8066        let mut file = vec![0u8; 8];
8067        file.extend_from_slice(&desc);
8068        let meta = MMeta {
8069            items: vec![
8070                geo_ii(1, b"grid"),
8071                geo_ii(2, b"av01"),
8072                geo_ii(3, b"av01"),
8073                geo_ii(10, b"av01"), // data-origin item (treated as bytes)
8074            ],
8075            properties: vec![ispe_prop(64, 64)],
8076            associations: vec![geo_assoc(2, &[0]), geo_assoc(3, &[0])],
8077            irefs: vec![
8078                IrefEntry {
8079                    reference_type: *b"dimg",
8080                    from_id: 1,
8081                    to_ids: vec![2, 3],
8082                },
8083                // The cm=2 data-origin reference: grid item 1 ← item 10.
8084                IrefEntry {
8085                    reference_type: *b"iloc",
8086                    from_id: 1,
8087                    to_ids: vec![10],
8088                },
8089            ],
8090            locations: vec![
8091                // grid item: cm=2, whole referenced item (length 0).
8092                ItemLocation {
8093                    id: 1,
8094                    construction_method: 2,
8095                    data_reference_index: 0,
8096                    base_offset: 0,
8097                    extents: vec![IlocExtent {
8098                        offset: 0,
8099                        length: 0,
8100                        extent_index: 1,
8101                    }],
8102                },
8103                // data-origin item 10: cm=0, points at the descriptor bytes
8104                // in the synthetic file.
8105                ItemLocation {
8106                    id: 10,
8107                    construction_method: 0,
8108                    data_reference_index: 0,
8109                    base_offset: 0,
8110                    extents: vec![IlocExtent {
8111                        offset: 8,
8112                        length: desc.len() as u64,
8113                        extent_index: 0,
8114                    }],
8115                },
8116            ],
8117            ..MMeta::default()
8118        };
8119        let res = resolve_grids(&meta, &file, None);
8120        assert_eq!(res.len(), 1, "cm=2 grid descriptor must resolve");
8121        let r = &res[0];
8122        assert_eq!(r.canvas(), (128, 64));
8123        assert_eq!(r.placements.len(), 2);
8124        // The same descriptor is reachable through reconstructed_dims.
8125        assert_eq!(reconstructed_dims(&meta, 1, &file, None), Some((128, 64)));
8126    }
8127
8128    // -----------------------------------------------------------------------
8129    // Unified derivation-graph walk (HEIF §6.6)
8130    // -----------------------------------------------------------------------
8131
8132    /// A plain coded primary is a single-node graph whose root is `Coded`
8133    /// and whose decode set is just that item.
8134    #[test]
8135    fn derivation_graph_coded_root_single_node() {
8136        let meta = MMeta {
8137            items: vec![geo_ii(1, b"av01")],
8138            properties: vec![ispe_prop(640, 480)],
8139            associations: vec![geo_assoc(1, &[0])],
8140            ..MMeta::default()
8141        };
8142        let g = build_derivation_graph(&meta, 1, &[], None);
8143        assert_eq!(g.root_item_id, 1);
8144        assert_eq!(g.nodes.len(), 1);
8145        assert!(g.root_is_coded());
8146        assert_eq!(g.nodes[0].kind, DerivationKind::Coded);
8147        assert_eq!(g.nodes[0].reconstructed_dims, Some((640, 480)));
8148        assert_eq!(g.nodes[0].output_dims, Some((640, 480)));
8149        assert_eq!(g.output_dims(), Some((640, 480)));
8150        assert_eq!(g.coded_leaf_ids, vec![1]);
8151        assert_eq!(g.derived_node_count(), 0);
8152        assert!(!g.truncated);
8153    }
8154
8155    /// A 2×2 grid primary: one `Grid` root + four `Coded` tiles. The decode
8156    /// set is the four tiles in row-major order; the canvas is the grid
8157    /// descriptor's output size.
8158    #[test]
8159    fn derivation_graph_grid_root_lists_tiles() {
8160        let desc = build_grid_desc(2, 2, 200, 200);
8161        let idat = desc.clone();
8162        let meta = MMeta {
8163            items: vec![
8164                geo_ii(1, b"grid"),
8165                geo_ii(2, b"av01"),
8166                geo_ii(3, b"av01"),
8167                geo_ii(4, b"av01"),
8168                geo_ii(5, b"av01"),
8169            ],
8170            properties: vec![ispe_prop(100, 100)],
8171            associations: vec![
8172                geo_assoc(2, &[0]),
8173                geo_assoc(3, &[0]),
8174                geo_assoc(4, &[0]),
8175                geo_assoc(5, &[0]),
8176            ],
8177            irefs: vec![IrefEntry {
8178                reference_type: *b"dimg",
8179                from_id: 1,
8180                to_ids: vec![2, 3, 4, 5],
8181            }],
8182            locations: vec![ItemLocation {
8183                id: 1,
8184                construction_method: 1,
8185                data_reference_index: 0,
8186                base_offset: 0,
8187                extents: vec![IlocExtent {
8188                    offset: 0,
8189                    length: desc.len() as u64,
8190                    extent_index: 0,
8191                }],
8192            }],
8193            ..MMeta::default()
8194        };
8195        let g = build_derivation_graph(&meta, 1, &[], Some(&idat));
8196        assert!(!g.root_is_coded());
8197        assert_eq!(g.nodes[0].kind, DerivationKind::Grid);
8198        assert_eq!(g.nodes[0].inputs, vec![2, 3, 4, 5]);
8199        assert_eq!(g.output_dims(), Some((200, 200)));
8200        assert_eq!(g.coded_leaf_ids, vec![2, 3, 4, 5]);
8201        assert_eq!(g.nodes.len(), 5);
8202        assert_eq!(g.derived_node_count(), 1);
8203        // The tiles sit at depth 1, the grid at depth 0.
8204        assert_eq!(g.node(1).unwrap().depth, 0);
8205        assert_eq!(g.node(2).unwrap().depth, 1);
8206    }
8207
8208    /// Nested derivation: an `iden` crop (via `clap`) of a 2×2 grid. The
8209    /// walk descends iden → grid → tiles, the root output folds the iden's
8210    /// own `clap`, and the decode set is still the four grid tiles.
8211    #[test]
8212    fn derivation_graph_iden_over_grid_nests() {
8213        let desc = build_grid_desc(2, 2, 200, 200);
8214        let idat = desc.clone();
8215        let meta = MMeta {
8216            items: vec![
8217                geo_ii(1, b"iden"),
8218                geo_ii(2, b"grid"),
8219                geo_ii(3, b"av01"),
8220                geo_ii(4, b"av01"),
8221                geo_ii(5, b"av01"),
8222                geo_ii(6, b"av01"),
8223            ],
8224            // property 0 = tile ispe; property 1 = iden's clap crop to 150×120.
8225            properties: vec![
8226                ispe_prop(100, 100),
8227                MProp::Clap(MClap {
8228                    clean_aperture_width_n: 150,
8229                    clean_aperture_width_d: 1,
8230                    clean_aperture_height_n: 120,
8231                    clean_aperture_height_d: 1,
8232                    horiz_off_n: 0,
8233                    horiz_off_d: 1,
8234                    vert_off_n: 0,
8235                    vert_off_d: 1,
8236                }),
8237            ],
8238            associations: vec![
8239                geo_assoc(1, &[1]), // iden carries the clap
8240                geo_assoc(3, &[0]),
8241                geo_assoc(4, &[0]),
8242                geo_assoc(5, &[0]),
8243                geo_assoc(6, &[0]),
8244            ],
8245            irefs: vec![
8246                IrefEntry {
8247                    reference_type: *b"dimg",
8248                    from_id: 1,
8249                    to_ids: vec![2],
8250                },
8251                IrefEntry {
8252                    reference_type: *b"dimg",
8253                    from_id: 2,
8254                    to_ids: vec![3, 4, 5, 6],
8255                },
8256            ],
8257            locations: vec![ItemLocation {
8258                id: 2,
8259                construction_method: 1,
8260                data_reference_index: 0,
8261                base_offset: 0,
8262                extents: vec![IlocExtent {
8263                    offset: 0,
8264                    length: desc.len() as u64,
8265                    extent_index: 0,
8266                }],
8267            }],
8268            ..MMeta::default()
8269        };
8270        let g = build_derivation_graph(&meta, 1, &[], Some(&idat));
8271        assert_eq!(g.nodes[0].kind, DerivationKind::Identity);
8272        // iden reconstructed = grid output (200×200); iden output = clap crop.
8273        assert_eq!(g.nodes[0].reconstructed_dims, Some((200, 200)));
8274        assert_eq!(g.nodes[0].output_dims, Some((150, 120)));
8275        assert_eq!(g.output_dims(), Some((150, 120)));
8276        // Decode set is the four tiles, in row-major order.
8277        assert_eq!(g.coded_leaf_ids, vec![3, 4, 5, 6]);
8278        // Depth ordering: iden(0) → grid(1) → tiles(2).
8279        assert_eq!(g.node(1).unwrap().depth, 0);
8280        assert_eq!(g.node(2).unwrap().depth, 1);
8281        assert_eq!(g.node(3).unwrap().depth, 2);
8282        assert_eq!(g.derived_node_count(), 2);
8283    }
8284
8285    /// A diamond graph — two `iden` derivations sharing one coded leaf via
8286    /// an `iovl` — lists the shared leaf exactly once in the decode set.
8287    #[test]
8288    fn derivation_graph_diamond_dedups_shared_leaf() {
8289        // overlay(1) → iden(2), iden(3); both iden → coded(4).
8290        let ovl = {
8291            let mut b = vec![0u8, 0u8]; // version, flags (16-bit)
8292            b.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0]); // canvas fill RGBA
8293            b.extend_from_slice(&100u16.to_be_bytes()); // output_width
8294            b.extend_from_slice(&100u16.to_be_bytes()); // output_height
8295            b.extend_from_slice(&0i16.to_be_bytes()); // h offset 0
8296            b.extend_from_slice(&0i16.to_be_bytes()); // v offset 0
8297            b.extend_from_slice(&0i16.to_be_bytes()); // h offset 1
8298            b.extend_from_slice(&0i16.to_be_bytes()); // v offset 1
8299            b
8300        };
8301        let idat = ovl.clone();
8302        let meta = MMeta {
8303            items: vec![
8304                geo_ii(1, b"iovl"),
8305                geo_ii(2, b"iden"),
8306                geo_ii(3, b"iden"),
8307                geo_ii(4, b"av01"),
8308            ],
8309            properties: vec![ispe_prop(50, 50)],
8310            associations: vec![geo_assoc(4, &[0])],
8311            irefs: vec![
8312                IrefEntry {
8313                    reference_type: *b"dimg",
8314                    from_id: 1,
8315                    to_ids: vec![2, 3],
8316                },
8317                IrefEntry {
8318                    reference_type: *b"dimg",
8319                    from_id: 2,
8320                    to_ids: vec![4],
8321                },
8322                IrefEntry {
8323                    reference_type: *b"dimg",
8324                    from_id: 3,
8325                    to_ids: vec![4],
8326                },
8327            ],
8328            locations: vec![ItemLocation {
8329                id: 1,
8330                construction_method: 1,
8331                data_reference_index: 0,
8332                base_offset: 0,
8333                extents: vec![IlocExtent {
8334                    offset: 0,
8335                    length: ovl.len() as u64,
8336                    extent_index: 0,
8337                }],
8338            }],
8339            ..MMeta::default()
8340        };
8341        let g = build_derivation_graph(&meta, 1, &[], Some(&idat));
8342        assert_eq!(g.nodes[0].kind, DerivationKind::Overlay);
8343        // Both idens reachable, shared leaf listed once.
8344        assert_eq!(g.coded_leaf_ids, vec![4]);
8345        // 4 distinct nodes (iovl, iden×2, coded) — the leaf is not duplicated.
8346        assert_eq!(g.nodes.len(), 4);
8347        assert_eq!(g.derived_node_count(), 3);
8348        assert!(!g.truncated);
8349        // Decode-buffer sizing: the single shared leaf is 50×50.
8350        assert_eq!(g.coded_leaf_dims(), vec![(4, Some((50, 50)))]);
8351        // Depth layers: overlay at 0, both idens at 1, leaf at 2.
8352        assert_eq!(g.nodes_at_depth(0), vec![1]);
8353        assert_eq!(g.nodes_at_depth(1), vec![2, 3]);
8354        assert_eq!(g.nodes_at_depth(2), vec![4]);
8355    }
8356
8357    /// A `dimg` cycle terminates the walk at the depth bound and sets the
8358    /// `truncated` flag rather than recursing forever.
8359    #[test]
8360    fn derivation_graph_cycle_terminates_and_flags_truncation() {
8361        // iden(1) → iden(2) → iden(1) — a 2-cycle.
8362        let meta = MMeta {
8363            items: vec![geo_ii(1, b"iden"), geo_ii(2, b"iden")],
8364            irefs: vec![
8365                IrefEntry {
8366                    reference_type: *b"dimg",
8367                    from_id: 1,
8368                    to_ids: vec![2],
8369                },
8370                IrefEntry {
8371                    reference_type: *b"dimg",
8372                    from_id: 2,
8373                    to_ids: vec![1],
8374                },
8375            ],
8376            ..MMeta::default()
8377        };
8378        let g = build_derivation_graph(&meta, 1, &[], None);
8379        // Each item visited once; the back-edge to an already-visited node
8380        // is dropped, so only two nodes and no coded leaves.
8381        assert_eq!(g.nodes.len(), 2);
8382        assert!(g.coded_leaf_ids.is_empty());
8383        // Geometry is unresolvable (no coded leaf) but the walk still
8384        // terminated cleanly.
8385        assert_eq!(g.output_dims(), None);
8386    }
8387
8388    /// A `tmap` with no `dimg` inputs at all resolves to no dimensions and
8389    /// an empty geometry record (defensive — malformed file).
8390    #[test]
8391    fn resolve_tone_maps_handles_inputless_tmap() {
8392        let meta = MMeta {
8393            items: vec![geo_ii(1, b"tmap")],
8394            ..MMeta::default()
8395        };
8396        let r = &resolve_tone_maps(&meta, &[], None)[0];
8397        assert_eq!(r.base_item_id, None);
8398        assert!(r.gain_map_item_ids.is_empty());
8399        assert_eq!(r.rendered_dims, None);
8400        assert!(!r.upsamples_gain_map());
8401        assert_eq!(reconstructed_dims(&meta, 1, &[], None), None);
8402    }
8403
8404    // -----------------------------------------------------------------------
8405    // Property / fuzz tests for the box-parsing surface
8406    // -----------------------------------------------------------------------
8407    //
8408    // These hammer the descriptor parsers and the geometry resolver with
8409    // pseudo-random and adversarially-shaped byte inputs. The contract under
8410    // test is **total**: a parser must return `Ok(_)` or `Err(_)` and never
8411    // panic (no slice-index OOB, no integer overflow in debug, no
8412    // unbounded recursion) for *any* input. The PRNG is a deterministic
8413    // splitmix64 so a failure reproduces from the printed seed.
8414
8415    /// Deterministic splitmix64 — no external crate, reproducible from seed.
8416    struct SplitMix64(u64);
8417
8418    impl SplitMix64 {
8419        fn next(&mut self) -> u64 {
8420            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
8421            let mut z = self.0;
8422            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
8423            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
8424            z ^ (z >> 31)
8425        }
8426
8427        /// A pseudo-random byte vector of length `0..=max_len`.
8428        fn bytes(&mut self, max_len: usize) -> Vec<u8> {
8429            let len = (self.next() as usize) % (max_len + 1);
8430            (0..len).map(|_| (self.next() & 0xff) as u8).collect()
8431        }
8432    }
8433
8434    /// `ImageOverlay::parse` is total over arbitrary bytes × arbitrary
8435    /// reference counts — it never panics, only returns `Ok`/`Err`.
8436    #[test]
8437    fn fuzz_image_overlay_parse_is_total() {
8438        let mut rng = SplitMix64(0x0AF1_2026_0341_0001);
8439        for _ in 0..20_000 {
8440            let buf = rng.bytes(48);
8441            let refs = (rng.next() as usize) % 8;
8442            // Must not panic regardless of input.
8443            let _ = ImageOverlay::parse(&buf, refs);
8444        }
8445    }
8446
8447    /// `GainMapMetadata::parse` (ISO 21496-1 Annex C.2) is total — random
8448    /// payloads must not panic on the flag byte, version bounds, or the
8449    /// per-channel rational reads.
8450    #[test]
8451    fn fuzz_gain_map_metadata_parse_is_total() {
8452        let mut rng = SplitMix64(0x0AF1_2026_0341_0002);
8453        for _ in 0..20_000 {
8454            let buf = rng.bytes(160);
8455            let _ = GainMapMetadata::parse(&buf);
8456        }
8457    }
8458
8459    /// `SampleTransform::parse` (av1-avif §4.2.3) is total over random
8460    /// descriptor bytes × reference counts — the postfix token stream
8461    /// validation must reject malformed input without panicking.
8462    #[test]
8463    fn fuzz_sample_transform_parse_is_total() {
8464        let mut rng = SplitMix64(0x0AF1_2026_0341_0003);
8465        for _ in 0..20_000 {
8466            let buf = rng.bytes(64);
8467            let refs = (rng.next() % 8) as u32;
8468            let _ = SampleTransform::parse(&buf, refs);
8469        }
8470    }
8471
8472    /// `parse_grpl` (HEIF §9.4 EntityToGroupBox container) is total over
8473    /// random `grpl` payloads.
8474    #[test]
8475    fn fuzz_parse_grpl_is_total() {
8476        let mut rng = SplitMix64(0x0AF1_2026_0341_0004);
8477        for _ in 0..20_000 {
8478            let buf = rng.bytes(80);
8479            let _ = parse_grpl(&buf);
8480        }
8481    }
8482
8483    /// The geometry resolver is total over a randomly-shaped item graph:
8484    /// `resolve_overlays`, `resolve_iden_derivations`, and
8485    /// `reconstructed_dims` must terminate (cycle guard) and never panic
8486    /// for any meta layout × backing buffer.
8487    #[test]
8488    fn fuzz_geometry_resolver_is_total() {
8489        let mut rng = SplitMix64(0x0AF1_2026_0341_0005);
8490        let types: [&[u8; 4]; 5] = [b"av01", b"grid", b"iovl", b"iden", b"sato"];
8491        for _ in 0..5_000 {
8492            let item_count = 1 + (rng.next() as usize % 6);
8493            let mut items = Vec::new();
8494            let mut properties = Vec::new();
8495            let mut associations = Vec::new();
8496            let mut irefs = Vec::new();
8497            let mut locations = Vec::new();
8498            for i in 0..item_count {
8499                let id = (i + 1) as u32;
8500                let t = types[(rng.next() as usize) % types.len()];
8501                items.push(geo_ii(id, t));
8502                // Random ispe property + association.
8503                let w = (rng.next() % 600) as u32;
8504                let h = (rng.next() % 600) as u32;
8505                properties.push(ispe_prop(w, h));
8506                associations.push(geo_assoc(id, &[(properties.len() - 1) as u16]));
8507                // A random dimg edge to another (possibly self → cycle).
8508                if rng.next() & 1 == 0 {
8509                    let to = 1 + (rng.next() as u32 % item_count as u32);
8510                    irefs.push(IrefEntry {
8511                        reference_type: *b"dimg",
8512                        from_id: id,
8513                        to_ids: vec![to],
8514                    });
8515                }
8516                // A random idat-backed location.
8517                if rng.next() & 1 == 0 {
8518                    locations.push(ItemLocation {
8519                        id,
8520                        construction_method: (rng.next() % 3) as u8,
8521                        data_reference_index: 0,
8522                        base_offset: rng.next() % 64,
8523                        extents: vec![IlocExtent {
8524                            offset: rng.next() % 64,
8525                            length: rng.next() % 64,
8526                            extent_index: 0,
8527                        }],
8528                    });
8529                }
8530            }
8531            let meta = MMeta {
8532                items,
8533                properties,
8534                associations,
8535                irefs,
8536                locations,
8537                ..MMeta::default()
8538            };
8539            let file = rng.bytes(128);
8540            let idat = rng.bytes(128);
8541            // None of these may panic or fail to terminate.
8542            let _ = resolve_overlays(&meta, &file, Some(&idat));
8543            let _ = resolve_iden_derivations(&meta, &file, Some(&idat));
8544            let _ = resolve_grids(&meta, &file, Some(&idat));
8545            let _ = resolve_tone_maps(&meta, &file, Some(&idat));
8546            for id in 1..=item_count as u32 {
8547                let _ = reconstructed_dims(&meta, id, &file, Some(&idat));
8548                // The unified graph walk must also be total: it terminates on
8549                // any cyclic / adversarial dimg shape, visits each item once
8550                // (no duplicate nodes), and never lists a coded leaf twice.
8551                let g = build_derivation_graph(&meta, id, &file, Some(&idat));
8552                let mut seen = std::collections::BTreeSet::new();
8553                for n in &g.nodes {
8554                    assert!(seen.insert(n.item_id), "duplicate node {}", n.item_id);
8555                    assert!(n.depth <= MAX_DERIVATION_DEPTH);
8556                }
8557                let mut leaves = std::collections::BTreeSet::new();
8558                for &leaf in &g.coded_leaf_ids {
8559                    assert!(leaves.insert(leaf), "duplicate coded leaf {leaf}");
8560                }
8561            }
8562        }
8563    }
8564
8565    // -----------------------------------------------------------------
8566    // 'pred' brand file-constraint audit (HEIF §10.2.4)
8567    // -----------------------------------------------------------------
8568
8569    /// No `'pred'` claim ⇒ trivially compliant regardless of content.
8570    #[test]
8571    fn pred_audit_no_claim_is_compliant() {
8572        let meta = Meta {
8573            items: vec![make_infe(1, b"av01", 0)],
8574            ..Meta::default()
8575        };
8576        let c = audit_pred_brand(&meta, &[b(b"avif"), b(b"mif1")]);
8577        assert!(!c.claims_pred);
8578        assert!(c.is_compliant());
8579        assert!(c.missing().is_empty());
8580    }
8581
8582    /// A predictively coded item whose `'pred'` dependencies are all
8583    /// present passes the §10.2.4.2 closure `shall`.
8584    #[test]
8585    fn pred_audit_closure_satisfied() {
8586        let meta = Meta {
8587            primary_item_id: Some(1),
8588            items: vec![
8589                make_infe(1, b"av01", 0), // primary, independently coded
8590                make_infe(2, b"av01", 0), // P-frame item
8591                make_infe(3, b"av01", 0), // its dependency
8592            ],
8593            irefs: vec![IrefEntry {
8594                reference_type: *b"pred",
8595                from_id: 2,
8596                to_ids: vec![1, 3],
8597            }],
8598            ..Meta::default()
8599        };
8600        let c = audit_pred_brand(&meta, &[b(b"avif"), b(b"pred")]);
8601        assert!(c.claims_pred);
8602        assert_eq!(c.predictive_item_count, 1);
8603        assert!(c.missing_dependency_ids.is_empty());
8604        assert!(c.is_compliant());
8605    }
8606
8607    /// A `'pred'` dependency naming an item absent from `iinf` is a
8608    /// closure violation.
8609    #[test]
8610    fn pred_audit_missing_dependency_flagged() {
8611        let meta = Meta {
8612            primary_item_id: Some(1),
8613            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
8614            irefs: vec![IrefEntry {
8615                reference_type: *b"pred",
8616                from_id: 2,
8617                to_ids: vec![1, 99], // 99 not present
8618            }],
8619            ..Meta::default()
8620        };
8621        let c = audit_pred_brand(&meta, &[b(b"pred")]);
8622        assert_eq!(c.missing_dependency_ids, vec![99]);
8623        assert!(!c.is_compliant());
8624        assert!(c.missing().contains(&"pred-dependency-closure"));
8625    }
8626
8627    /// When `'mif1'` is also claimed, a primary item carrying a `'pred'`
8628    /// reference (not independently coded) violates §10.2.4.2.
8629    #[test]
8630    fn pred_audit_mif1_primary_must_be_independent() {
8631        let meta = Meta {
8632            primary_item_id: Some(1),
8633            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
8634            irefs: vec![IrefEntry {
8635                reference_type: *b"pred",
8636                from_id: 1, // primary is itself predictively coded
8637                to_ids: vec![2],
8638            }],
8639            ..Meta::default()
8640        };
8641        let c = audit_pred_brand(&meta, &[b(b"mif1"), b(b"pred")]);
8642        assert!(c.claims_mif1 && c.claims_pred);
8643        assert!(c.primary_not_independent);
8644        assert!(!c.is_compliant());
8645        assert!(c.missing().contains(&"mif1-primary-independent"));
8646    }
8647
8648    /// Without the `'mif1'` claim a predictively coded primary is allowed
8649    /// (§10.2.4.2 NOTE), so only the closure `shall` applies.
8650    #[test]
8651    fn pred_audit_no_mif1_allows_predictive_primary() {
8652        let meta = Meta {
8653            primary_item_id: Some(1),
8654            items: vec![make_infe(1, b"av01", 0), make_infe(2, b"av01", 0)],
8655            irefs: vec![IrefEntry {
8656                reference_type: *b"pred",
8657                from_id: 1,
8658                to_ids: vec![2],
8659            }],
8660            ..Meta::default()
8661        };
8662        let c = audit_pred_brand(&meta, &[b(b"pred")]);
8663        assert!(!c.claims_mif1);
8664        assert!(!c.primary_not_independent);
8665        assert!(c.is_compliant());
8666    }
8667}