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}