Skip to main content

zenavif_serialize/
lib.rs

1//! # AVIF image serializer (muxer)
2//!
3//! ## Usage
4//!
5//! 1. Compress pixels using an AV1 encoder, such as [rav1e](https://lib.rs/rav1e). [libaom](https://lib.rs/libaom-sys) works too.
6//!
7//! 2. Call `avif_serialize::serialize_to_vec(av1_data, None, width, height, 8)`
8//!
9//! See [cavif](https://github.com/kornelski/cavif-rs) for a complete implementation.
10
11pub mod animated;
12mod boxes;
13pub mod constants;
14pub mod grid;
15mod writer;
16
17use crate::boxes::*;
18use arrayvec::ArrayVec;
19use std::io;
20
21// Re-export box types needed by the public API
22pub use crate::boxes::{Av1CBox, ClapBox, ClliBox, ColrBox, ColrIccBox, IrotBox, ImirBox, MdcvBox, PaspBox};
23
24/// Chroma subsampling configuration for AV1 encoding.
25///
26/// `(false, false)` = 4:4:4 (no subsampling).
27/// `(true, true)` = 4:2:0 (both axes subsampled).
28/// `(true, false)` = 4:2:2 (horizontal only).
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct ChromaSubsampling {
31    /// Whether the horizontal (X) axis is subsampled.
32    pub horizontal: bool,
33    /// Whether the vertical (Y) axis is subsampled.
34    pub vertical: bool,
35}
36
37impl ChromaSubsampling {
38    /// 4:4:4 — no chroma subsampling.
39    pub const NONE: Self = Self { horizontal: false, vertical: false };
40    /// 4:2:0 — both axes subsampled.
41    pub const YUV420: Self = Self { horizontal: true, vertical: true };
42    /// 4:2:2 — horizontal subsampling only.
43    pub const YUV422: Self = Self { horizontal: true, vertical: false };
44}
45
46impl From<(bool, bool)> for ChromaSubsampling {
47    fn from((h, v): (bool, bool)) -> Self {
48        Self { horizontal: h, vertical: v }
49    }
50}
51
52impl From<ChromaSubsampling> for (bool, bool) {
53    fn from(cs: ChromaSubsampling) -> Self {
54        (cs.horizontal, cs.vertical)
55    }
56}
57
58/// Config for the serialization (allows setting advanced image properties).
59///
60/// See [`Aviffy::new`].
61pub struct Aviffy {
62    premultiplied_alpha: bool,
63    colr: ColrBox,
64    clli: Option<ClliBox>,
65    mdcv: Option<MdcvBox>,
66    irot: Option<IrotBox>,
67    imir: Option<ImirBox>,
68    clap: Option<ClapBox>,
69    pasp: Option<PaspBox>,
70    icc_profile: Option<Vec<u8>>,
71    min_seq_profile: u8,
72    chroma_subsampling: ChromaSubsampling,
73    monochrome: bool,
74    width: u32,
75    height: u32,
76    bit_depth: u8,
77    exif: Option<Vec<u8>>,
78    xmp: Option<Vec<u8>>,
79    gain_map: Option<GainMapConfig>,
80}
81
82/// Configuration for an ISO 21496-1 gain map embedded in the AVIF container.
83///
84/// The gain map enables adaptive HDR rendering: an SDR base image combined
85/// with a gain map and tone-map metadata allows reconstructing an HDR rendition.
86struct GainMapConfig {
87    /// AV1-encoded gain map image data.
88    av1_data: Vec<u8>,
89    /// Width of the gain map image in pixels.
90    width: u32,
91    /// Height of the gain map image in pixels.
92    height: u32,
93    /// Bit depth of the gain map image (8, 10, or 12).
94    bit_depth: u8,
95    /// ISO 21496-1 binary metadata blob (the `tmap` item payload).
96    metadata: Vec<u8>,
97    /// CICP color information for the alternate (typically HDR) rendition.
98    /// Stored as a `colr` property on the `tmap` item.
99    alt_colr: Option<ColrBox>,
100    /// Chroma subsampling of the gain map AV1 data.
101    chroma_subsampling: ChromaSubsampling,
102    /// Whether the gain map is monochrome.
103    monochrome: bool,
104}
105
106/// Makes an AVIF file given encoded AV1 data (create the data with [`rav1e`](https://lib.rs/rav1e))
107///
108/// `color_av1_data` is already-encoded AV1 image data for the color channels (YUV, RGB, etc.).
109/// [You can parse this information out of AV1 payload with `avif-parse`](https://docs.rs/zenavif-parse/latest/zenavif_parse/struct.AV1Metadata.html).
110///
111/// The color image should have been encoded without chroma subsampling AKA YUV444 (`Cs444` in `rav1e`)
112/// AV1 handles full-res color so effortlessly, you should never need chroma subsampling ever again.
113///
114/// Optional `alpha_av1_data` is a monochrome image (`rav1e` calls it "YUV400"/`Cs400`) representing transparency.
115/// Alpha adds a lot of header bloat, so don't specify it unless it's necessary.
116///
117/// `width`/`height` is image size in pixels. It must of course match the size of encoded image data.
118/// `depth_bits` should be 8, 10 or 12, depending on how the image was encoded.
119///
120/// Color and alpha must have the same dimensions and depth.
121///
122/// Data is written (streamed) to `into_output`.
123pub fn serialize<W: io::Write>(into_output: W, color_av1_data: &[u8], alpha_av1_data: Option<&[u8]>, width: u32, height: u32, depth_bits: u8) -> io::Result<()> {
124    Aviffy::new()
125        .set_width(width)
126        .set_height(height)
127        .set_bit_depth(depth_bits)
128        .write_slice(into_output, color_av1_data, alpha_av1_data)
129}
130
131impl Default for Aviffy {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl Aviffy {
138    /// You will have to set image properties to match the AV1 bitstream.
139    ///
140    /// [You can get this information out of the AV1 payload with `avif-parse`](https://docs.rs/zenavif-parse/latest/zenavif_parse/struct.AV1Metadata.html).
141    #[inline]
142    #[must_use]
143    pub fn new() -> Self {
144        Self {
145            premultiplied_alpha: false,
146            min_seq_profile: 1,
147            chroma_subsampling: ChromaSubsampling::NONE,
148            monochrome: false,
149            width: 0,
150            height: 0,
151            bit_depth: 0,
152            colr: ColrBox::default(),
153            clli: None,
154            mdcv: None,
155            irot: None,
156            imir: None,
157            clap: None,
158            pasp: None,
159            icc_profile: None,
160            exif: None,
161            xmp: None,
162            gain_map: None,
163        }
164    }
165
166    /// If set, must match the AV1 color payload, and will result in `colr` box added to AVIF.
167    /// Defaults to BT.601, because that's what Safari assumes when `colr` is missing.
168    /// Other browsers are smart enough to read this from the AV1 payload instead.
169    #[inline]
170    pub fn set_matrix_coefficients(&mut self, matrix_coefficients: constants::MatrixCoefficients) -> &mut Self {
171        self.colr.matrix_coefficients = matrix_coefficients;
172        self
173    }
174
175    #[doc(hidden)]
176    pub fn matrix_coefficients(&mut self, matrix_coefficients: constants::MatrixCoefficients) -> &mut Self {
177        self.set_matrix_coefficients(matrix_coefficients)
178    }
179
180    /// If set, must match the AV1 color payload, and will result in `colr` box added to AVIF.
181    /// Defaults to sRGB.
182    #[inline]
183    pub fn set_transfer_characteristics(&mut self, transfer_characteristics: constants::TransferCharacteristics) -> &mut Self {
184        self.colr.transfer_characteristics = transfer_characteristics;
185        self
186    }
187
188    #[doc(hidden)]
189    pub fn transfer_characteristics(&mut self, transfer_characteristics: constants::TransferCharacteristics) -> &mut Self {
190        self.set_transfer_characteristics(transfer_characteristics)
191    }
192
193    /// If set, must match the AV1 color payload, and will result in `colr` box added to AVIF.
194    /// Defaults to sRGB/Rec.709.
195    #[inline]
196    pub fn set_color_primaries(&mut self, color_primaries: constants::ColorPrimaries) -> &mut Self {
197        self.colr.color_primaries = color_primaries;
198        self
199    }
200
201    #[doc(hidden)]
202    pub fn color_primaries(&mut self, color_primaries: constants::ColorPrimaries) -> &mut Self {
203        self.set_color_primaries(color_primaries)
204    }
205
206    /// If set, must match the AV1 color payload, and will result in `colr` box added to AVIF.
207    /// Defaults to full.
208    #[inline]
209    pub fn set_full_color_range(&mut self, full_range: bool) -> &mut Self {
210        self.colr.full_range_flag = full_range;
211        self
212    }
213
214    #[doc(hidden)]
215    pub fn full_color_range(&mut self, full_range: bool) -> &mut Self {
216        self.set_full_color_range(full_range)
217    }
218
219    /// Set Content Light Level Information for HDR (CEA-861.3).
220    ///
221    /// `max_content_light_level` (MaxCLL) is the maximum light level of any single pixel in cd/m².
222    /// `max_pic_average_light_level` (MaxFALL) is the maximum frame-average light level in cd/m².
223    ///
224    /// Adds a `clli` property box to the AVIF container.
225    #[inline]
226    pub fn set_content_light_level(&mut self, max_content_light_level: u16, max_pic_average_light_level: u16) -> &mut Self {
227        self.clli = Some(ClliBox {
228            max_content_light_level,
229            max_pic_average_light_level,
230        });
231        self
232    }
233
234    /// Set Mastering Display Colour Volume for HDR (SMPTE ST 2086).
235    ///
236    /// `primaries` are the display primaries in CIE 1931 xy × 50000.
237    /// Order: \[green, blue, red\] per SMPTE ST 2086.
238    /// `white_point` uses the same encoding (e.g. D65 = (15635, 16450)).
239    ///
240    /// `max_luminance` and `min_luminance` are in cd/m² × 10000
241    /// (e.g. 1000 cd/m² = 10_000_000, 0.005 cd/m² = 50).
242    ///
243    /// Adds an `mdcv` property box to the AVIF container.
244    #[inline]
245    pub fn set_mastering_display(&mut self, primaries: [(u16, u16); 3], white_point: (u16, u16), max_luminance: u32, min_luminance: u32) -> &mut Self {
246        self.mdcv = Some(MdcvBox {
247            primaries,
248            white_point,
249            max_luminance,
250            min_luminance,
251        });
252        self
253    }
254
255    /// Set image rotation (counter-clockwise).
256    ///
257    /// `angle` is a raw code: 0=0°, 1=90°, 2=180°, 3=270°.
258    /// Adds an `irot` property box.
259    #[inline]
260    pub fn set_rotation(&mut self, angle: u8) -> &mut Self {
261        self.irot = Some(IrotBox { angle: angle & 0x03 });
262        self
263    }
264
265    /// Set image mirror axis.
266    ///
267    /// `axis` = 0: vertical axis (left-right flip).
268    /// `axis` = 1: horizontal axis (top-bottom flip).
269    /// Adds an `imir` property box.
270    #[inline]
271    pub fn set_mirror(&mut self, axis: u8) -> &mut Self {
272        self.imir = Some(ImirBox { axis: axis & 0x01 });
273        self
274    }
275
276    /// Set clean aperture (crop rectangle).
277    ///
278    /// All values are rational numbers. Defines a centered crop region.
279    /// Adds a `clap` property box.
280    #[inline]
281    pub fn set_clean_aperture(&mut self, clap: ClapBox) -> &mut Self {
282        self.clap = Some(clap);
283        self
284    }
285
286    /// Set pixel aspect ratio.
287    ///
288    /// `h_spacing` / `v_spacing` defines the ratio. For square pixels use (1, 1).
289    /// Adds a `pasp` property box.
290    #[inline]
291    pub fn set_pixel_aspect_ratio(&mut self, h_spacing: u32, v_spacing: u32) -> &mut Self {
292        self.pasp = Some(PaspBox { h_spacing, v_spacing });
293        self
294    }
295
296    /// Set XMP metadata to be included in the AVIF file as a separate item.
297    ///
298    /// The data should be a valid XMP/RDF XML document.
299    #[inline]
300    pub fn set_xmp(&mut self, xmp: Vec<u8>) -> &mut Self {
301        self.xmp = Some(xmp);
302        self
303    }
304
305    /// Set ICC color profile to embed in the AVIF file.
306    ///
307    /// This adds a `colr` box with colour_type='prof'. When set, this
308    /// replaces the nclx `colr` box (they are mutually exclusive per spec,
309    /// though some files include both).
310    #[inline]
311    pub fn set_icc_profile(&mut self, icc_data: Vec<u8>) -> &mut Self {
312        self.icc_profile = Some(icc_data);
313        self
314    }
315
316    /// Set gain map data for ISO 21496-1 tone mapping.
317    ///
318    /// Creates three items in the AVIF container:
319    /// - A gain map image item (`av01`) containing the AV1-encoded gain map
320    /// - A `tmap` derived image item referencing both the primary image and gain map
321    /// - The `tmap` item's payload containing the ISO 21496-1 metadata
322    ///
323    /// The `metadata` blob is the raw ISO 21496-1 binary format (version byte,
324    /// minimum_version, writer_version, flags, headroom, per-channel parameters).
325    ///
326    /// The gain map image is typically a lower-resolution, monochrome or RGB image
327    /// encoding the per-pixel gain needed to reconstruct the HDR rendition from
328    /// the SDR base.
329    #[inline]
330    pub fn set_gain_map(&mut self, av1_data: Vec<u8>, width: u32, height: u32, bit_depth: u8, metadata: Vec<u8>) -> &mut Self {
331        self.gain_map = Some(GainMapConfig {
332            av1_data,
333            width,
334            height,
335            bit_depth,
336            metadata,
337            alt_colr: None,
338            chroma_subsampling: ChromaSubsampling::YUV420,
339            monochrome: false,
340        });
341        self
342    }
343
344    /// Set the color information for the alternate (typically HDR) rendition.
345    ///
346    /// This CICP colr box is attached as a property of the `tmap` item,
347    /// telling decoders what colour space the tone-mapped output targets.
348    /// Only meaningful if [`set_gain_map`](Self::set_gain_map) has been called.
349    #[inline]
350    pub fn set_gain_map_alt_colr(&mut self, colr: ColrBox) -> &mut Self {
351        if let Some(ref mut gm) = self.gain_map {
352            gm.alt_colr = Some(colr);
353        }
354        self
355    }
356
357    /// Set chroma subsampling for the gain map AV1 data.
358    ///
359    /// Defaults to 4:2:0 if not called. Only meaningful if
360    /// [`set_gain_map`](Self::set_gain_map) has been called.
361    #[inline]
362    pub fn set_gain_map_chroma_subsampling(&mut self, subsampling: impl Into<ChromaSubsampling>) -> &mut Self {
363        if let Some(ref mut gm) = self.gain_map {
364            gm.chroma_subsampling = subsampling.into();
365        }
366        self
367    }
368
369    /// Set whether the gain map image is monochrome.
370    ///
371    /// Defaults to false. Only meaningful if
372    /// [`set_gain_map`](Self::set_gain_map) has been called.
373    #[inline]
374    pub fn set_gain_map_monochrome(&mut self, monochrome: bool) -> &mut Self {
375        if let Some(ref mut gm) = self.gain_map {
376            gm.monochrome = monochrome;
377        }
378        self
379    }
380
381    /// Makes an AVIF file given encoded AV1 data (create the data with [`rav1e`](https://lib.rs/rav1e))
382    ///
383    /// `color_av1_data` is already-encoded AV1 image data for the color channels (YUV, RGB, etc.).
384    /// The color image should have been encoded without chroma subsampling AKA YUV444 (`Cs444` in `rav1e`)
385    /// AV1 handles full-res color so effortlessly, you should never need chroma subsampling ever again.
386    ///
387    /// Optional `alpha_av1_data` is a monochrome image (`rav1e` calls it "YUV400"/`Cs400`) representing transparency.
388    /// Alpha adds a lot of header bloat, so don't specify it unless it's necessary.
389    ///
390    /// `width`/`height` is image size in pixels. It must of course match the size of encoded image data.
391    /// `depth_bits` should be 8, 10 or 12, depending on how the image has been encoded in AV1.
392    ///
393    /// Color and alpha must have the same dimensions and depth.
394    ///
395    /// Data is written (streamed) to `into_output`.
396    #[inline]
397    pub fn write<W: io::Write>(&self, into_output: W, color_av1_data: &[u8], alpha_av1_data: Option<&[u8]>, width: u32, height: u32, depth_bits: u8) -> io::Result<()> {
398        self.make_boxes(color_av1_data, alpha_av1_data, width, height, depth_bits)?.write(into_output)
399    }
400
401    /// See [`Self::write`]
402    #[inline]
403    pub fn write_slice<W: io::Write>(&self, into_output: W, color_av1_data: &[u8], alpha_av1_data: Option<&[u8]>) -> io::Result<()> {
404        self.make_boxes(color_av1_data, alpha_av1_data, self.width, self.height, self.bit_depth)?.write(into_output)
405    }
406
407    fn make_boxes<'data>(&'data self, color_av1_data: &'data [u8], alpha_av1_data: Option<&'data [u8]>, width: u32, height: u32, depth_bits: u8) -> io::Result<AvifFile<'data>> {
408        if ![8, 10, 12].contains(&depth_bits) {
409            return Err(io::Error::new(io::ErrorKind::InvalidInput, "depth must be 8/10/12"));
410        }
411
412        let mut image_items = ArrayVec::new();
413        let mut iloc_items = ArrayVec::new();
414        let mut ipma_entries = ArrayVec::new();
415        let mut irefs = ArrayVec::new();
416        let mut multi_irefs: ArrayVec<IrefMultiEntryBox, 2> = ArrayVec::new();
417        let mut ipco = IpcoBox::new();
418        let color_image_id: u16 = 1;
419        let alpha_image_id: u16 = 2;
420        let mut next_item_id: u16 = 3;
421        const ESSENTIAL_BIT: u8 = 0x80;
422        let color_depth_bits = depth_bits;
423        let alpha_depth_bits = depth_bits; // Sadly, the spec requires these to match.
424
425        image_items.push(InfeBox {
426            id: color_image_id,
427            typ: FourCC(*b"av01"),
428            name: "",
429            content_type: "",
430        });
431
432        let ispe_prop = ipco.push(IpcoProp::Ispe(IspeBox { width, height })).ok_or(io::ErrorKind::InvalidInput)?;
433
434        // This is redundant, but Chrome wants it, and checks that it matches :(
435        let av1c_color_prop = ipco.push(IpcoProp::Av1C(Av1CBox {
436            seq_profile: self.min_seq_profile.max(if color_depth_bits >= 12 { 2 } else { 0 }),
437            seq_level_idx_0: 31,
438            seq_tier_0: false,
439            high_bitdepth: color_depth_bits >= 10,
440            twelve_bit: color_depth_bits >= 12,
441            monochrome: self.monochrome,
442            chroma_subsampling_x: self.chroma_subsampling.horizontal,
443            chroma_subsampling_y: self.chroma_subsampling.vertical,
444            chroma_sample_position: 0,
445        })).ok_or(io::ErrorKind::InvalidInput)?;
446
447        // Useless bloat
448        let pixi_3 = ipco.push(IpcoProp::Pixi(PixiBox {
449            channels: 3,
450            depth: color_depth_bits,
451        })).ok_or(io::ErrorKind::InvalidInput)?;
452
453        let mut ipma = IpmaEntry {
454            item_id: color_image_id,
455            prop_ids: from_array([ispe_prop, av1c_color_prop | ESSENTIAL_BIT, pixi_3]),
456        };
457
458        // ICC profile takes precedence over nclx if both set
459        if let Some(ref icc_data) = self.icc_profile {
460            let colr_icc_prop = ipco.push(IpcoProp::ColrIcc(ColrIccBox {
461                icc_data: icc_data.clone(),
462            })).ok_or(io::ErrorKind::InvalidInput)?;
463            ipma.prop_ids.push(colr_icc_prop);
464        } else if self.colr != ColrBox::default() {
465            // Redundant info, already in AV1
466            let colr_color_prop = ipco.push(IpcoProp::Colr(self.colr)).ok_or(io::ErrorKind::InvalidInput)?;
467            ipma.prop_ids.push(colr_color_prop);
468        }
469
470        if let Some(clli) = self.clli {
471            let clli_prop = ipco.push(IpcoProp::Clli(clli)).ok_or(io::ErrorKind::InvalidInput)?;
472            ipma.prop_ids.push(clli_prop);
473        }
474
475        if let Some(mdcv) = self.mdcv {
476            let mdcv_prop = ipco.push(IpcoProp::Mdcv(mdcv)).ok_or(io::ErrorKind::InvalidInput)?;
477            ipma.prop_ids.push(mdcv_prop);
478        }
479
480        if let Some(irot) = self.irot {
481            let irot_prop = ipco.push(IpcoProp::Irot(irot)).ok_or(io::ErrorKind::InvalidInput)?;
482            ipma.prop_ids.push(irot_prop | ESSENTIAL_BIT);
483        }
484
485        if let Some(imir) = self.imir {
486            let imir_prop = ipco.push(IpcoProp::Imir(imir)).ok_or(io::ErrorKind::InvalidInput)?;
487            ipma.prop_ids.push(imir_prop | ESSENTIAL_BIT);
488        }
489
490        if let Some(clap) = self.clap {
491            let clap_prop = ipco.push(IpcoProp::Clap(clap)).ok_or(io::ErrorKind::InvalidInput)?;
492            ipma.prop_ids.push(clap_prop | ESSENTIAL_BIT);
493        }
494
495        if let Some(pasp) = self.pasp {
496            let pasp_prop = ipco.push(IpcoProp::Pasp(pasp)).ok_or(io::ErrorKind::InvalidInput)?;
497            ipma.prop_ids.push(pasp_prop);
498        }
499
500        ipma_entries.push(ipma);
501
502        if let Some(exif_data) = self.exif.as_deref() {
503            let exif_id = next_item_id;
504            next_item_id += 1;
505
506            image_items.push(InfeBox {
507                id: exif_id,
508                typ: FourCC(*b"Exif"),
509                name: "",
510                content_type: "",
511            });
512
513            iloc_items.push(IlocItem {
514                id: exif_id,
515                extents: [IlocExtent { data: exif_data }],
516            });
517
518            irefs.push(IrefEntryBox {
519                from_id: exif_id,
520                to_id: color_image_id,
521                typ: FourCC(*b"cdsc"),
522            });
523        }
524
525        if let Some(xmp_data) = self.xmp.as_deref() {
526            let xmp_id = next_item_id;
527            next_item_id += 1;
528
529            image_items.push(InfeBox {
530                id: xmp_id,
531                typ: FourCC(*b"mime"),
532                name: "",
533                content_type: "application/rdf+xml",
534            });
535
536            iloc_items.push(IlocItem {
537                id: xmp_id,
538                extents: [IlocExtent { data: xmp_data }],
539            });
540
541            irefs.push(IrefEntryBox {
542                from_id: xmp_id,
543                to_id: color_image_id,
544                typ: FourCC(*b"cdsc"),
545            });
546        }
547
548        if let Some(alpha_data) = alpha_av1_data {
549            image_items.push(InfeBox {
550                id: alpha_image_id,
551                typ: FourCC(*b"av01"),
552                name: "",
553                content_type: "",
554            });
555
556            irefs.push(IrefEntryBox {
557                from_id: alpha_image_id,
558                to_id: color_image_id,
559                typ: FourCC(*b"auxl"),
560            });
561
562            if self.premultiplied_alpha {
563                irefs.push(IrefEntryBox {
564                    from_id: color_image_id,
565                    to_id: alpha_image_id,
566                    typ: FourCC(*b"prem"),
567                });
568            }
569
570            let av1c_alpha_prop = ipco.push(boxes::IpcoProp::Av1C(Av1CBox {
571                seq_profile: if alpha_depth_bits >= 12 { 2 } else { 0 },
572                seq_level_idx_0: 31,
573                seq_tier_0: false,
574                high_bitdepth: alpha_depth_bits >= 10,
575                twelve_bit: alpha_depth_bits >= 12,
576                monochrome: true,
577                chroma_subsampling_x: true,
578                chroma_subsampling_y: true,
579                chroma_sample_position: 0,
580            })).ok_or(io::ErrorKind::InvalidInput)?;
581
582            // So pointless
583            let pixi_1 = ipco.push(IpcoProp::Pixi(PixiBox {
584                channels: 1,
585                depth: alpha_depth_bits,
586            })).ok_or(io::ErrorKind::InvalidInput)?;
587
588            // that's a silly way to add 1 bit of information, isn't it?
589            let auxc_prop = ipco.push(IpcoProp::AuxC(AuxCBox {
590                urn: "urn:mpeg:mpegB:cicp:systems:auxiliary:alpha",
591            })).ok_or(io::ErrorKind::InvalidInput)?;
592
593            ipma_entries.push(IpmaEntry {
594                item_id: alpha_image_id,
595                prop_ids: from_array([ispe_prop, av1c_alpha_prop | ESSENTIAL_BIT, auxc_prop, pixi_1]),
596            });
597
598            // Use interleaved color and alpha, with alpha first.
599            // Makes it possible to display partial image.
600            iloc_items.push(IlocItem {
601                id: alpha_image_id,
602                extents: [IlocExtent { data: alpha_data }],
603            });
604        }
605        // Gain map: gain map image item (av01) + tmap derived image
606        if let Some(ref gm) = self.gain_map {
607            let gm_depth = gm.bit_depth;
608            let gain_map_id = next_item_id;
609            next_item_id += 1;
610            let tmap_id = next_item_id;
611            next_item_id += 1;
612            let _ = next_item_id;
613
614            // Gain map image item (av01)
615            image_items.push(InfeBox {
616                id: gain_map_id,
617                typ: FourCC(*b"av01"),
618                name: "",
619                content_type: "",
620            });
621
622            // Gain map ispe (may differ from primary dimensions)
623            let gm_ispe = ipco.push(IpcoProp::Ispe(IspeBox {
624                width: gm.width,
625                height: gm.height,
626            })).ok_or(io::ErrorKind::InvalidInput)?;
627
628            // Gain map av1C
629            let gm_av1c = ipco.push(IpcoProp::Av1C(Av1CBox {
630                seq_profile: if gm_depth >= 12 { 2 } else { 0 },
631                seq_level_idx_0: 31,
632                seq_tier_0: false,
633                high_bitdepth: gm_depth >= 10,
634                twelve_bit: gm_depth >= 12,
635                monochrome: gm.monochrome,
636                chroma_subsampling_x: gm.chroma_subsampling.horizontal,
637                chroma_subsampling_y: gm.chroma_subsampling.vertical,
638                chroma_sample_position: 0,
639            })).ok_or(io::ErrorKind::InvalidInput)?;
640
641            ipma_entries.push(IpmaEntry {
642                item_id: gain_map_id,
643                prop_ids: from_array([gm_ispe, gm_av1c | ESSENTIAL_BIT]),
644            });
645
646            // Gain map image data in iloc
647            iloc_items.push(IlocItem {
648                id: gain_map_id,
649                extents: [IlocExtent { data: &gm.av1_data }],
650            });
651
652            // tmap derived image item
653            image_items.push(InfeBox {
654                id: tmap_id,
655                typ: FourCC(*b"tmap"),
656                name: "",
657                content_type: "",
658            });
659
660            // tmap item properties: optional colr for alternate rendition
661            if let Some(alt_colr) = gm.alt_colr {
662                let tmap_colr = ipco.push(IpcoProp::Colr(alt_colr)).ok_or(io::ErrorKind::InvalidInput)?;
663                ipma_entries.push(IpmaEntry {
664                    item_id: tmap_id,
665                    prop_ids: from_array([tmap_colr]),
666                });
667            }
668
669            // tmap payload (ISO 21496-1 metadata) in iloc
670            iloc_items.push(IlocItem {
671                id: tmap_id,
672                extents: [IlocExtent { data: &gm.metadata }],
673            });
674
675            // dimg reference: tmap -> [primary, gain_map]
676            // Must be a single iref entry with reference_count=2 so the parser
677            // assigns reference_index 0 to primary and 1 to gain_map.
678            let mut to_ids = ArrayVec::new();
679            to_ids.push(color_image_id);
680            to_ids.push(gain_map_id);
681            multi_irefs.push(IrefMultiEntryBox {
682                from_id: tmap_id,
683                to_ids,
684                typ: FourCC(*b"dimg"),
685            });
686        }
687
688        iloc_items.push(IlocItem {
689            id: color_image_id,
690            extents: [IlocExtent { data: color_av1_data }],
691        });
692
693        Ok(AvifFile {
694            ftyp: FtypBox {
695                major_brand: FourCC(*b"avif"),
696                minor_version: 0,
697                compatible_brands: [FourCC(*b"mif1"), FourCC(*b"miaf")].into(),
698            },
699            meta: MetaBox {
700                hdlr: HdlrBox {},
701                iinf: IinfBox { items: image_items },
702                pitm: PitmBox(color_image_id),
703                iloc: IlocBox {
704                    absolute_offset_start: None,
705                    items: iloc_items,
706                },
707                iprp: IprpBox {
708                    ipco,
709                    // It's not enough to define these properties,
710                    // they must be assigned to the image
711                    ipma: IpmaBox { entries: ipma_entries },
712                },
713                iref: IrefBox { entries: irefs, multi_entries: multi_irefs },
714            },
715            // Here's the actual data. If HEIF wasn't such a kitchen sink, this
716            // would have been the only data this file needs.
717            mdat: MdatBox,
718        })
719    }
720
721    /// Panics if the input arguments were invalid. Use [`Self::write`] to handle the errors.
722    #[must_use]
723    #[track_caller]
724    pub fn to_vec(&self, color_av1_data: &[u8], alpha_av1_data: Option<&[u8]>, width: u32, height: u32, depth_bits: u8) -> Vec<u8> {
725        let mut file = self.make_boxes(color_av1_data, alpha_av1_data, width, height, depth_bits).unwrap();
726        let mut out = Vec::new();
727        file.write_to_vec(&mut out).unwrap();
728        out
729    }
730
731    /// Set chroma subsampling. Use [`ChromaSubsampling::NONE`] for 4:4:4,
732    /// [`ChromaSubsampling::YUV420`] for 4:2:0, etc.
733    ///
734    /// Also accepts `(bool, bool)` tuples for backward compatibility.
735    ///
736    /// `chroma_sample_position` is always 0. Don't use chroma subsampling with AVIF.
737    #[inline]
738    pub fn set_chroma_subsampling(&mut self, subsampling: impl Into<ChromaSubsampling>) -> &mut Self {
739        self.chroma_subsampling = subsampling.into();
740        self
741    }
742
743    /// Set whether the image is monochrome (grayscale).
744    /// This is used to set the `monochrome` flag in the AV1 sequence header.
745    #[inline]
746    pub fn set_monochrome(&mut self, monochrome: bool) -> &mut Self {
747        self.monochrome = monochrome;
748        self
749    }
750
751    /// Set exif metadata to be included in the AVIF file as a separate item.
752    #[inline]
753    pub fn set_exif(&mut self, exif: Vec<u8>) -> &mut Self {
754        self.exif = Some(exif);
755        self
756    }
757
758    /// Sets minimum required
759    ///
760    /// Higher bit depth may increase this
761    #[inline]
762    pub fn set_seq_profile(&mut self, seq_profile: u8) -> &mut Self {
763        self.min_seq_profile = seq_profile;
764        self
765    }
766
767    #[inline]
768    pub fn set_width(&mut self, width: u32) -> &mut Self {
769        self.width = width;
770        self
771    }
772
773    #[inline]
774    pub fn set_height(&mut self, height: u32) -> &mut Self {
775        self.height = height;
776        self
777    }
778
779    /// 8, 10 or 12.
780    #[inline]
781    pub fn set_bit_depth(&mut self, bit_depth: u8) -> &mut Self {
782        self.bit_depth = bit_depth;
783        self
784    }
785
786    /// Set whether image's colorspace uses premultiplied alpha, i.e. RGB channels were multiplied by their alpha value,
787    /// so that transparent areas are all black. Image decoders will be instructed to undo the premultiplication.
788    ///
789    /// Premultiplied alpha images usually compress better and tolerate heavier compression, but
790    /// may not be supported correctly by less capable AVIF decoders.
791    ///
792    /// This just sets the configuration property. The pixel data must have already been processed before compression.
793    /// If a decoder displays semitransparent colors too dark, it doesn't support premultiplied alpha.
794    /// If a decoder displays semitransparent colors too bright, you didn't premultiply the colors before encoding.
795    ///
796    /// If you're not using premultiplied alpha, consider bleeding RGB colors into transparent areas,
797    /// otherwise there may be unwanted outlines around edges of transparency.
798    #[inline]
799    pub fn set_premultiplied_alpha(&mut self, is_premultiplied: bool) -> &mut Self {
800        self.premultiplied_alpha = is_premultiplied;
801        self
802    }
803
804    #[doc(hidden)]
805    pub fn premultiplied_alpha(&mut self, is_premultiplied: bool) -> &mut Self {
806        self.set_premultiplied_alpha(is_premultiplied)
807    }
808}
809
810#[inline(always)]
811fn from_array<const L1: usize, const L2: usize, T: Copy>(array: [T; L1]) -> ArrayVec<T, L2> {
812    assert!(L1 <= L2);
813    let mut tmp = ArrayVec::new_const();
814    let _ = tmp.try_extend_from_slice(&array);
815    tmp
816}
817
818/// See [`serialize`] for description. This one makes a `Vec` instead of using `io::Write`.
819#[must_use]
820#[track_caller]
821pub fn serialize_to_vec(color_av1_data: &[u8], alpha_av1_data: Option<&[u8]>, width: u32, height: u32, depth_bits: u8) -> Vec<u8> {
822    Aviffy::new().to_vec(color_av1_data, alpha_av1_data, width, height, depth_bits)
823}
824
825#[test]
826fn test_roundtrip_parse_mp4() {
827    let test_img = b"av12356abc";
828    let avif = serialize_to_vec(test_img, None, 10, 20, 8);
829
830    let ctx = mp4parse::read_avif(&mut avif.as_slice(), mp4parse::ParseStrictness::Normal).unwrap();
831
832    assert_eq!(&test_img[..], ctx.primary_item_coded_data().unwrap());
833}
834
835#[test]
836fn test_roundtrip_parse_mp4_alpha() {
837    let test_img = b"av12356abc";
838    let test_a = b"alpha";
839    let avif = serialize_to_vec(test_img, Some(test_a), 10, 20, 8);
840
841    let ctx = mp4parse::read_avif(&mut avif.as_slice(), mp4parse::ParseStrictness::Normal).unwrap();
842
843    assert_eq!(&test_img[..], ctx.primary_item_coded_data().unwrap());
844    assert_eq!(&test_a[..], ctx.alpha_item_coded_data().unwrap());
845}
846
847#[test]
848fn test_roundtrip_parse_exif() {
849    let test_img = b"av12356abc";
850    let test_a = b"alpha";
851    let avif = Aviffy::new()
852        .set_exif(b"lol".to_vec())
853        .to_vec(test_img, Some(test_a), 10, 20, 8);
854
855    let ctx = mp4parse::read_avif(&mut avif.as_slice(), mp4parse::ParseStrictness::Normal).unwrap();
856
857    assert_eq!(&test_img[..], ctx.primary_item_coded_data().unwrap());
858    assert_eq!(&test_a[..], ctx.alpha_item_coded_data().unwrap());
859}
860
861#[test]
862fn test_roundtrip_parse_avif() {
863    let test_img = [1, 2, 3, 4, 5, 6];
864    let test_alpha = [77, 88, 99];
865    let avif = serialize_to_vec(&test_img, Some(&test_alpha), 10, 20, 8);
866
867    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
868
869    assert_eq!(&test_img[..], parser.primary_data().unwrap().as_ref());
870    assert_eq!(&test_alpha[..], parser.alpha_data().unwrap().unwrap().as_ref());
871}
872
873#[test]
874fn test_roundtrip_parse_avif_colr() {
875    let test_img = [1, 2, 3, 4, 5, 6];
876    let test_alpha = [77, 88, 99];
877    let avif = Aviffy::new()
878        .matrix_coefficients(constants::MatrixCoefficients::Bt709)
879        .to_vec(&test_img, Some(&test_alpha), 10, 20, 8);
880
881    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
882
883    assert_eq!(&test_img[..], parser.primary_data().unwrap().as_ref());
884    assert_eq!(&test_alpha[..], parser.alpha_data().unwrap().unwrap().as_ref());
885}
886
887#[test]
888fn premultiplied_flag() {
889    let test_img = [1,2,3,4];
890    let test_alpha = [55,66,77,88,99];
891    let avif = Aviffy::new().premultiplied_alpha(true).to_vec(&test_img, Some(&test_alpha), 5, 5, 8);
892
893    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
894
895    assert!(parser.premultiplied_alpha());
896    assert_eq!(&test_img[..], parser.primary_data().unwrap().as_ref());
897    assert_eq!(&test_alpha[..], parser.alpha_data().unwrap().unwrap().as_ref());
898}
899
900#[test]
901fn size_required() {
902    assert!(Aviffy::new().set_bit_depth(10).write_slice(&mut vec![], &[], None).is_err());
903}
904
905#[test]
906fn depth_required() {
907    assert!(Aviffy::new().set_width(1).set_height(1).write_slice(&mut vec![], &[], None).is_err());
908}
909
910#[test]
911fn clli_roundtrip() {
912    let test_img = [1, 2, 3, 4, 5, 6];
913    let avif = Aviffy::new()
914        .set_content_light_level(1000, 400)
915        .to_vec(&test_img, None, 10, 20, 8);
916
917    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
918    let cll = parser.content_light_level().expect("clli box should be present");
919    assert_eq!(cll.max_content_light_level, 1000);
920    assert_eq!(cll.max_pic_average_light_level, 400);
921}
922
923#[test]
924fn mdcv_roundtrip() {
925    let test_img = [1, 2, 3, 4, 5, 6];
926    // BT.2020 primaries (standard encoding: CIE xy × 50000)
927    let primaries = [
928        (8500, 39850),   // green
929        (6550, 2300),    // blue
930        (35400, 14600),  // red
931    ];
932    let white_point = (15635, 16450); // D65
933    let max_luminance = 10_000_000; // 1000 cd/m²
934    let min_luminance = 1;          // 0.0001 cd/m²
935
936    let avif = Aviffy::new()
937        .set_mastering_display(primaries, white_point, max_luminance, min_luminance)
938        .to_vec(&test_img, None, 10, 20, 8);
939
940    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
941    let mdcv = parser.mastering_display().expect("mdcv box should be present");
942    assert_eq!(mdcv.primaries, primaries);
943    assert_eq!(mdcv.white_point, white_point);
944    assert_eq!(mdcv.max_luminance, max_luminance);
945    assert_eq!(mdcv.min_luminance, min_luminance);
946}
947
948#[test]
949fn hdr10_full_metadata() {
950    let test_img = [1, 2, 3, 4, 5, 6];
951    let test_alpha = [77, 88, 99];
952    let primaries = [
953        (8500, 39850),
954        (6550, 2300),
955        (35400, 14600),
956    ];
957    let white_point = (15635, 16450);
958
959    let avif = Aviffy::new()
960        .set_transfer_characteristics(constants::TransferCharacteristics::Smpte2084)
961        .set_color_primaries(constants::ColorPrimaries::Bt2020)
962        .set_matrix_coefficients(constants::MatrixCoefficients::Bt2020Ncl)
963        .set_content_light_level(4000, 1000)
964        .set_mastering_display(primaries, white_point, 40_000_000, 50)
965        .to_vec(&test_img, Some(&test_alpha), 10, 20, 10);
966
967    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
968
969    // Verify CLLI
970    let cll = parser.content_light_level().expect("clli box should be present");
971    assert_eq!(cll.max_content_light_level, 4000);
972    assert_eq!(cll.max_pic_average_light_level, 1000);
973
974    // Verify MDCV
975    let mdcv = parser.mastering_display().expect("mdcv box should be present");
976    assert_eq!(mdcv.primaries, primaries);
977    assert_eq!(mdcv.white_point, white_point);
978    assert_eq!(mdcv.max_luminance, 40_000_000);
979    assert_eq!(mdcv.min_luminance, 50);
980
981    // Verify data integrity
982    assert_eq!(parser.primary_data().unwrap().as_ref(), &test_img[..]);
983    assert_eq!(parser.alpha_data().unwrap().unwrap().as_ref(), &test_alpha[..]);
984}
985
986#[test]
987fn no_hdr_metadata_by_default() {
988    let test_img = [1, 2, 3, 4, 5, 6];
989    let avif = serialize_to_vec(&test_img, None, 10, 20, 8);
990
991    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
992    assert!(parser.content_light_level().is_none());
993    assert!(parser.mastering_display().is_none());
994}
995
996#[test]
997fn rotation_roundtrip() {
998    let test_img = [1, 2, 3, 4, 5, 6];
999    for angle in 0..4u8 {
1000        let avif = Aviffy::new()
1001            .set_rotation(angle)
1002            .to_vec(&test_img, None, 10, 20, 8);
1003
1004        let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1005        let rot = parser.rotation().expect("irot box should be present");
1006        let expected_angle = match angle {
1007            0 => 0,
1008            1 => 90,
1009            2 => 180,
1010            3 => 270,
1011            _ => unreachable!(),
1012        };
1013        assert_eq!(rot.angle, expected_angle, "angle code {angle}");
1014
1015        // Verify data still parses
1016        assert_eq!(parser.primary_data().unwrap().as_ref(), &test_img[..]);
1017    }
1018}
1019
1020#[test]
1021fn mirror_roundtrip() {
1022    let test_img = [1, 2, 3, 4, 5, 6];
1023    for axis in 0..2u8 {
1024        let avif = Aviffy::new()
1025            .set_mirror(axis)
1026            .to_vec(&test_img, None, 10, 20, 8);
1027
1028        let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1029        let mir = parser.mirror().expect("imir box should be present");
1030        assert_eq!(mir.axis, axis);
1031    }
1032}
1033
1034#[test]
1035fn clap_roundtrip() {
1036    let test_img = [1, 2, 3, 4, 5, 6];
1037    let avif = Aviffy::new()
1038        .set_clean_aperture(ClapBox {
1039            width_n: 800, width_d: 1,
1040            height_n: 600, height_d: 1,
1041            horiz_off_n: 0, horiz_off_d: 1,
1042            vert_off_n: 0, vert_off_d: 1,
1043        })
1044        .to_vec(&test_img, None, 10, 20, 8);
1045
1046    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1047    let clap = parser.clean_aperture().expect("clap box should be present");
1048    assert_eq!(clap.width_n, 800);
1049    assert_eq!(clap.width_d, 1);
1050    assert_eq!(clap.height_n, 600);
1051    assert_eq!(clap.height_d, 1);
1052    assert_eq!(clap.horiz_off_n, 0);
1053    assert_eq!(clap.horiz_off_d, 1);
1054    assert_eq!(clap.vert_off_n, 0);
1055    assert_eq!(clap.vert_off_d, 1);
1056}
1057
1058#[test]
1059fn clap_with_negative_offset() {
1060    let test_img = [1, 2, 3, 4, 5, 6];
1061    let avif = Aviffy::new()
1062        .set_clean_aperture(ClapBox {
1063            width_n: 640, width_d: 1,
1064            height_n: 480, height_d: 1,
1065            horiz_off_n: -10, horiz_off_d: 1,
1066            vert_off_n: -20, vert_off_d: 1,
1067        })
1068        .to_vec(&test_img, None, 10, 20, 8);
1069
1070    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1071    let clap = parser.clean_aperture().expect("clap box should be present");
1072    assert_eq!(clap.horiz_off_n, -10);
1073    assert_eq!(clap.vert_off_n, -20);
1074}
1075
1076#[test]
1077fn pasp_roundtrip() {
1078    let test_img = [1, 2, 3, 4, 5, 6];
1079    let avif = Aviffy::new()
1080        .set_pixel_aspect_ratio(2, 1)
1081        .to_vec(&test_img, None, 10, 20, 8);
1082
1083    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1084    let pasp = parser.pixel_aspect_ratio().expect("pasp box should be present");
1085    assert_eq!(pasp.h_spacing, 2);
1086    assert_eq!(pasp.v_spacing, 1);
1087}
1088
1089#[test]
1090fn icc_profile_roundtrip() {
1091    let test_img = [1, 2, 3, 4, 5, 6];
1092    // Fake ICC profile data (real profiles are much larger, but any bytes work for roundtrip)
1093    let fake_icc = vec![0x00, 0x00, 0x00, 0x18, b'a', b'c', b's', b'p',
1094                        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
1095    let avif = Aviffy::new()
1096        .set_icc_profile(fake_icc.clone())
1097        .to_vec(&test_img, None, 10, 20, 8);
1098
1099    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1100    let color_info = parser.color_info().expect("colr box should be present");
1101    match color_info {
1102        zenavif_parse::ColorInformation::IccProfile(data) => {
1103            assert_eq!(data.as_slice(), &fake_icc[..]);
1104        }
1105        _ => panic!("expected ICC profile color info, got {:?}", color_info),
1106    }
1107}
1108
1109#[test]
1110fn icc_overrides_nclx() {
1111    let test_img = [1, 2, 3, 4, 5, 6];
1112    let fake_icc = vec![1, 2, 3, 4];
1113    // Setting both ICC and nclx: ICC should win
1114    let avif = Aviffy::new()
1115        .set_color_primaries(constants::ColorPrimaries::Bt2020)
1116        .set_icc_profile(fake_icc.clone())
1117        .to_vec(&test_img, None, 10, 20, 8);
1118
1119    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1120    match parser.color_info() {
1121        Some(zenavif_parse::ColorInformation::IccProfile(data)) => {
1122            assert_eq!(data.as_slice(), &fake_icc[..]);
1123        }
1124        other => panic!("expected ICC profile, got {:?}", other),
1125    }
1126}
1127
1128#[test]
1129fn xmp_roundtrip() {
1130    let test_img = [1, 2, 3, 4, 5, 6];
1131    let xmp_data = b"<x:xmpmeta xmlns:x='adobe:ns:meta/'><test/></x:xmpmeta>".to_vec();
1132
1133    let avif = Aviffy::new()
1134        .set_xmp(xmp_data.clone())
1135        .to_vec(&test_img, None, 10, 20, 8);
1136
1137    // Verify the primary data is intact
1138    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1139    assert_eq!(parser.primary_data().unwrap().as_ref(), &test_img[..]);
1140
1141    // Verify XMP data is somewhere in the file
1142    let xmp_str = b"<x:xmpmeta";
1143    assert!(avif.windows(xmp_str.len()).any(|w| w == xmp_str),
1144        "XMP data should be present in AVIF file");
1145}
1146
1147#[test]
1148fn rotation_and_mirror_combined() {
1149    let test_img = [1, 2, 3, 4, 5, 6];
1150    let avif = Aviffy::new()
1151        .set_rotation(1)  // 90° CCW
1152        .set_mirror(0)    // vertical axis
1153        .to_vec(&test_img, None, 10, 20, 8);
1154
1155    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1156    let rot = parser.rotation().expect("irot should be present");
1157    let mir = parser.mirror().expect("imir should be present");
1158    assert_eq!(rot.angle, 90);
1159    assert_eq!(mir.axis, 0);
1160}
1161
1162#[test]
1163fn all_properties_combined() {
1164    let test_img = [1, 2, 3, 4, 5, 6];
1165    let test_alpha = [77, 88, 99];
1166    let avif = Aviffy::new()
1167        .set_rotation(2)
1168        .set_mirror(1)
1169        .set_clean_aperture(ClapBox {
1170            width_n: 8, width_d: 1,
1171            height_n: 18, height_d: 1,
1172            horiz_off_n: 0, horiz_off_d: 1,
1173            vert_off_n: 0, vert_off_d: 1,
1174        })
1175        .set_pixel_aspect_ratio(1, 1)
1176        .set_content_light_level(1000, 400)
1177        .set_mastering_display(
1178            [(8500, 39850), (6550, 2300), (35400, 14600)],
1179            (15635, 16450), 10_000_000, 50,
1180        )
1181        .to_vec(&test_img, Some(&test_alpha), 10, 20, 8);
1182
1183    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1184    assert_eq!(parser.rotation().unwrap().angle, 180);
1185    assert_eq!(parser.mirror().unwrap().axis, 1);
1186    assert!(parser.clean_aperture().is_some());
1187    assert!(parser.pixel_aspect_ratio().is_some());
1188    assert!(parser.content_light_level().is_some());
1189    assert!(parser.mastering_display().is_some());
1190
1191    assert_eq!(parser.primary_data().unwrap().as_ref(), &test_img[..]);
1192    assert_eq!(parser.alpha_data().unwrap().unwrap().as_ref(), &test_alpha[..]);
1193}
1194
1195// Tests using avif-parse (upstream parser) to verify broad compatibility.
1196// avif-parse v2.0 only exposes primary_item, alpha_item, premultiplied_alpha,
1197// clli, and mdcv. For other features we verify it at least parses without panic.
1198
1199/// Helper: parse with avif-parse, return AvifData. Panics on parse failure.
1200#[cfg(test)]
1201fn parse_with_avif_parse(avif: &[u8]) -> avif_parse::AvifData {
1202    avif_parse::read_avif(&mut &*avif)
1203        .unwrap_or_else(|e| panic!("avif-parse failed to parse: {e:?}"))
1204}
1205
1206#[test]
1207fn avif_parse_basic_roundtrip() {
1208    let color = b"av1colordata";
1209    let avif = serialize_to_vec(color, None, 10, 20, 8);
1210    let parsed = parse_with_avif_parse(&avif);
1211    assert_eq!(parsed.primary_item.as_slice(), &color[..]);
1212    assert!(parsed.alpha_item.is_none());
1213}
1214
1215#[test]
1216fn avif_parse_alpha_roundtrip() {
1217    let color = b"av1colordata";
1218    let alpha = b"alphadata";
1219    let avif = serialize_to_vec(color, Some(alpha), 10, 20, 8);
1220    let parsed = parse_with_avif_parse(&avif);
1221    assert_eq!(parsed.primary_item.as_slice(), &color[..]);
1222    assert_eq!(parsed.alpha_item.unwrap().as_slice(), &alpha[..]);
1223}
1224
1225#[test]
1226fn avif_parse_premultiplied_alpha() {
1227    let color = [1, 2, 3, 4];
1228    let alpha = [55, 66, 77, 88];
1229    let avif = Aviffy::new().premultiplied_alpha(true)
1230        .to_vec(&color, Some(&alpha), 5, 5, 8);
1231    let parsed = parse_with_avif_parse(&avif);
1232    assert!(parsed.premultiplied_alpha);
1233    assert_eq!(parsed.primary_item.as_slice(), &color[..]);
1234    assert_eq!(parsed.alpha_item.unwrap().as_slice(), &alpha[..]);
1235}
1236
1237#[test]
1238fn avif_parse_clli_roundtrip() {
1239    let img = [1, 2, 3, 4, 5, 6];
1240    let avif = Aviffy::new()
1241        .set_content_light_level(1000, 400)
1242        .to_vec(&img, None, 10, 20, 8);
1243    let parsed = parse_with_avif_parse(&avif);
1244    let cll = parsed.content_light_level.expect("clli should be present");
1245    assert_eq!(cll.max_content_light_level, 1000);
1246    assert_eq!(cll.max_pic_average_light_level, 400);
1247}
1248
1249#[test]
1250fn avif_parse_mdcv_roundtrip() {
1251    let img = [1, 2, 3, 4, 5, 6];
1252    let primaries = [(8500, 39850), (6550, 2300), (35400, 14600)];
1253    let avif = Aviffy::new()
1254        .set_mastering_display(primaries, (15635, 16450), 10_000_000, 50)
1255        .to_vec(&img, None, 10, 20, 8);
1256    let parsed = parse_with_avif_parse(&avif);
1257    let mdcv = parsed.mastering_display.expect("mdcv should be present");
1258    assert_eq!(mdcv.primaries, primaries);
1259    assert_eq!(mdcv.white_point, (15635, 16450));
1260    assert_eq!(mdcv.max_luminance, 10_000_000);
1261    assert_eq!(mdcv.min_luminance, 50);
1262}
1263
1264#[test]
1265fn avif_parse_hdr10_full() {
1266    let img = [1, 2, 3, 4, 5, 6];
1267    let alpha = [77, 88, 99];
1268    let avif = Aviffy::new()
1269        .set_transfer_characteristics(constants::TransferCharacteristics::Smpte2084)
1270        .set_color_primaries(constants::ColorPrimaries::Bt2020)
1271        .set_matrix_coefficients(constants::MatrixCoefficients::Bt2020Ncl)
1272        .set_content_light_level(4000, 1000)
1273        .set_mastering_display(
1274            [(8500, 39850), (6550, 2300), (35400, 14600)],
1275            (15635, 16450), 40_000_000, 50,
1276        )
1277        .to_vec(&img, Some(&alpha), 10, 20, 10);
1278    let parsed = parse_with_avif_parse(&avif);
1279    assert_eq!(parsed.primary_item.as_slice(), &img[..]);
1280    assert_eq!(parsed.alpha_item.unwrap().as_slice(), &alpha[..]);
1281    assert!(parsed.content_light_level.is_some());
1282    assert!(parsed.mastering_display.is_some());
1283}
1284
1285// The following tests verify avif-parse doesn't crash on features it
1286// doesn't expose fields for (transforms, metadata, animation, grid).
1287
1288#[test]
1289fn avif_parse_survives_rotation() {
1290    let img = [1, 2, 3, 4, 5, 6];
1291    for angle in 0..4u8 {
1292        let avif = Aviffy::new().set_rotation(angle).to_vec(&img, None, 10, 20, 8);
1293        let parsed = parse_with_avif_parse(&avif);
1294        assert_eq!(parsed.primary_item.as_slice(), &img[..]);
1295    }
1296}
1297
1298#[test]
1299fn avif_parse_survives_mirror() {
1300    let img = [1, 2, 3, 4, 5, 6];
1301    for axis in 0..2u8 {
1302        let avif = Aviffy::new().set_mirror(axis).to_vec(&img, None, 10, 20, 8);
1303        let parsed = parse_with_avif_parse(&avif);
1304        assert_eq!(parsed.primary_item.as_slice(), &img[..]);
1305    }
1306}
1307
1308#[test]
1309fn avif_parse_survives_clap() {
1310    let img = [1, 2, 3, 4, 5, 6];
1311    let avif = Aviffy::new()
1312        .set_clean_aperture(ClapBox {
1313            width_n: 800, width_d: 1,
1314            height_n: 600, height_d: 1,
1315            horiz_off_n: 0, horiz_off_d: 1,
1316            vert_off_n: 0, vert_off_d: 1,
1317        })
1318        .to_vec(&img, None, 10, 20, 8);
1319    let parsed = parse_with_avif_parse(&avif);
1320    assert_eq!(parsed.primary_item.as_slice(), &img[..]);
1321}
1322
1323#[test]
1324fn avif_parse_survives_pasp() {
1325    let img = [1, 2, 3, 4, 5, 6];
1326    let avif = Aviffy::new().set_pixel_aspect_ratio(2, 1).to_vec(&img, None, 10, 20, 8);
1327    let parsed = parse_with_avif_parse(&avif);
1328    assert_eq!(parsed.primary_item.as_slice(), &img[..]);
1329}
1330
1331#[test]
1332fn avif_parse_survives_icc_profile() {
1333    let img = [1, 2, 3, 4, 5, 6];
1334    let icc = vec![0, 0, 0, 24, b'a', b'c', b's', b'p', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
1335    let avif = Aviffy::new().set_icc_profile(icc).to_vec(&img, None, 10, 20, 8);
1336    let parsed = parse_with_avif_parse(&avif);
1337    assert_eq!(parsed.primary_item.as_slice(), &img[..]);
1338}
1339
1340#[test]
1341fn avif_parse_survives_exif() {
1342    let img = b"av1colordata";
1343    let avif = Aviffy::new()
1344        .set_exif(b"exifdata".to_vec())
1345        .to_vec(img, None, 10, 20, 8);
1346    let parsed = parse_with_avif_parse(&avif);
1347    assert_eq!(parsed.primary_item.as_slice(), &img[..]);
1348}
1349
1350#[test]
1351fn avif_parse_survives_xmp() {
1352    let img = [1, 2, 3, 4, 5, 6];
1353    let avif = Aviffy::new()
1354        .set_xmp(b"<x:xmpmeta/>".to_vec())
1355        .to_vec(&img, None, 10, 20, 8);
1356    let parsed = parse_with_avif_parse(&avif);
1357    assert_eq!(parsed.primary_item.as_slice(), &img[..]);
1358}
1359
1360#[test]
1361fn avif_parse_survives_all_properties() {
1362    let img = [1, 2, 3, 4, 5, 6];
1363    let alpha = [77, 88, 99];
1364    let avif = Aviffy::new()
1365        .set_rotation(2)
1366        .set_mirror(1)
1367        .set_clean_aperture(ClapBox {
1368            width_n: 8, width_d: 1,
1369            height_n: 18, height_d: 1,
1370            horiz_off_n: 0, horiz_off_d: 1,
1371            vert_off_n: 0, vert_off_d: 1,
1372        })
1373        .set_pixel_aspect_ratio(1, 1)
1374        .set_content_light_level(1000, 400)
1375        .set_mastering_display(
1376            [(8500, 39850), (6550, 2300), (35400, 14600)],
1377            (15635, 16450), 10_000_000, 50,
1378        )
1379        .set_exif(b"exif".to_vec())
1380        .set_xmp(b"<xmp/>".to_vec())
1381        .to_vec(&img, Some(&alpha), 10, 20, 8);
1382    let parsed = parse_with_avif_parse(&avif);
1383    assert_eq!(parsed.primary_item.as_slice(), &img[..]);
1384    assert_eq!(parsed.alpha_item.unwrap().as_slice(), &alpha[..]);
1385    assert!(parsed.content_light_level.is_some());
1386    assert!(parsed.mastering_display.is_some());
1387}
1388
1389#[test]
1390fn avif_parse_survives_animated() {
1391    use crate::animated::{AnimatedImage, AnimFrame};
1392    let mut anim = AnimatedImage::new();
1393    anim.set_timescale(1000);
1394    anim.set_color_config(boxes::Av1CBox {
1395        seq_profile: 0, seq_level_idx_0: 4, seq_tier_0: false,
1396        high_bitdepth: false, twelve_bit: false, monochrome: false,
1397        chroma_subsampling_x: true, chroma_subsampling_y: true,
1398        chroma_sample_position: 0,
1399    });
1400    let frames = [
1401        AnimFrame::new(b"frame0data", 33).with_sync(true),
1402        AnimFrame::new(b"frame1data", 33),
1403    ];
1404    let avif = anim.serialize(320, 240, &frames, b"seqhdr", None);
1405    // avif-parse may or may not support avis brand, but must not crash
1406    let _ = avif_parse::read_avif(&mut avif.as_slice());
1407}
1408
1409#[test]
1410fn avif_parse_survives_grid() {
1411    use crate::grid::GridImage;
1412    let tiles: Vec<Vec<u8>> = (0..4).map(|i| vec![i as u8; 100]).collect();
1413    let tile_refs: Vec<&[u8]> = tiles.iter().map(|t| t.as_slice()).collect();
1414
1415    let mut grid = GridImage::new();
1416    grid.set_color_config(boxes::Av1CBox {
1417        seq_profile: 0, seq_level_idx_0: 4, seq_tier_0: false,
1418        high_bitdepth: false, twelve_bit: false, monochrome: false,
1419        chroma_subsampling_x: true, chroma_subsampling_y: true,
1420        chroma_sample_position: 0,
1421    });
1422    let avif = grid.serialize(2, 2, 200, 200, 100, 100, &tile_refs, None).unwrap();
1423    // avif-parse may or may not support grid, but must not crash
1424    let _ = avif_parse::read_avif(&mut avif.as_slice());
1425}
1426
1427// ─── Gain map / tmap tests ───────────────────────────────────────────
1428
1429/// Build a minimal ISO 21496-1 metadata blob (single-channel).
1430///
1431/// Format: version(u8) + minimum_version(u16) + writer_version(u16) + flags(u8)
1432///   + base_hdr_headroom(u32×2) + alternate_hdr_headroom(u32×2)
1433///   + per-channel: gain_map_min(i32+u32) + gain_map_max(i32+u32)
1434///     + gamma(u32×2) + base_offset(i32+u32) + alternate_offset(i32+u32)
1435#[cfg(test)]
1436fn make_test_tmap_metadata(
1437    is_multichannel: bool,
1438    use_base_colour_space: bool,
1439    base_headroom_n: u32,
1440    base_headroom_d: u32,
1441    alt_headroom_n: u32,
1442    alt_headroom_d: u32,
1443) -> Vec<u8> {
1444    let mut buf = Vec::new();
1445    buf.push(0); // version
1446    buf.extend_from_slice(&0u16.to_be_bytes()); // minimum_version
1447    buf.extend_from_slice(&0u16.to_be_bytes()); // writer_version
1448    let flags = (u8::from(is_multichannel) << 7) | (u8::from(use_base_colour_space) << 6);
1449    buf.push(flags);
1450
1451    buf.extend_from_slice(&base_headroom_n.to_be_bytes());
1452    buf.extend_from_slice(&base_headroom_d.to_be_bytes());
1453    buf.extend_from_slice(&alt_headroom_n.to_be_bytes());
1454    buf.extend_from_slice(&alt_headroom_d.to_be_bytes());
1455
1456    let channel_count = if is_multichannel { 3 } else { 1 };
1457    for _ in 0..channel_count {
1458        // gain_map_min = 0/1
1459        buf.extend_from_slice(&0i32.to_be_bytes());
1460        buf.extend_from_slice(&1u32.to_be_bytes());
1461        // gain_map_max = 1/1
1462        buf.extend_from_slice(&1i32.to_be_bytes());
1463        buf.extend_from_slice(&1u32.to_be_bytes());
1464        // gamma = 1/1
1465        buf.extend_from_slice(&1u32.to_be_bytes());
1466        buf.extend_from_slice(&1u32.to_be_bytes());
1467        // base_offset = 0/1
1468        buf.extend_from_slice(&0i32.to_be_bytes());
1469        buf.extend_from_slice(&1u32.to_be_bytes());
1470        // alternate_offset = 0/1
1471        buf.extend_from_slice(&0i32.to_be_bytes());
1472        buf.extend_from_slice(&1u32.to_be_bytes());
1473    }
1474    buf
1475}
1476
1477#[test]
1478fn gain_map_roundtrip() {
1479    let primary_data = b"primary_av1_data";
1480    let gain_map_data = b"gain_map_av1_data";
1481    let metadata = make_test_tmap_metadata(false, true, 0, 1, 1, 1);
1482
1483    let avif = Aviffy::new()
1484        .set_gain_map(gain_map_data.to_vec(), 4, 4, 8, metadata.clone())
1485        .to_vec(primary_data, None, 10, 20, 8);
1486
1487    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1488
1489    // Primary data intact
1490    assert_eq!(parser.primary_data().unwrap().as_ref(), &primary_data[..]);
1491
1492    // Gain map metadata should be detected
1493    let gm_meta = parser.gain_map_metadata().expect("gain map metadata should be present");
1494    assert!(!gm_meta.is_multichannel);
1495    assert!(gm_meta.use_base_colour_space);
1496    assert_eq!(gm_meta.base_hdr_headroom_n, 0);
1497    assert_eq!(gm_meta.base_hdr_headroom_d, 1);
1498    assert_eq!(gm_meta.alternate_hdr_headroom_n, 1);
1499    assert_eq!(gm_meta.alternate_hdr_headroom_d, 1);
1500
1501    // Gain map image data should be extractable
1502    let gm_data = parser.gain_map_data().expect("gain map data should be present").unwrap();
1503    assert_eq!(gm_data.as_ref(), &gain_map_data[..]);
1504}
1505
1506#[test]
1507fn gain_map_with_alpha() {
1508    let primary_data = b"primary_av1";
1509    let alpha_data = b"alpha_av1";
1510    let gain_map_data = b"gm_av1_data";
1511    let metadata = make_test_tmap_metadata(false, true, 0, 1, 1, 1);
1512
1513    let avif = Aviffy::new()
1514        .set_gain_map(gain_map_data.to_vec(), 4, 4, 8, metadata)
1515        .to_vec(primary_data, Some(alpha_data), 10, 20, 8);
1516
1517    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1518
1519    // Primary + alpha data intact
1520    assert_eq!(parser.primary_data().unwrap().as_ref(), &primary_data[..]);
1521    assert_eq!(parser.alpha_data().unwrap().unwrap().as_ref(), &alpha_data[..]);
1522
1523    // Gain map detected
1524    let gm_data = parser.gain_map_data().expect("gain map data present").unwrap();
1525    assert_eq!(gm_data.as_ref(), &gain_map_data[..]);
1526    assert!(parser.gain_map_metadata().is_some());
1527}
1528
1529#[test]
1530fn gain_map_multichannel_metadata() {
1531    let primary_data = [1, 2, 3, 4, 5, 6];
1532    let gain_map_data = [10, 20, 30, 40];
1533    let metadata = make_test_tmap_metadata(true, false, 0, 1, 3, 1);
1534
1535    let avif = Aviffy::new()
1536        .set_gain_map(gain_map_data.to_vec(), 2, 2, 8, metadata.clone())
1537        .to_vec(&primary_data, None, 10, 20, 8);
1538
1539    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1540    let gm_meta = parser.gain_map_metadata().expect("gain map metadata present");
1541    assert!(gm_meta.is_multichannel);
1542    assert!(!gm_meta.use_base_colour_space);
1543    assert_eq!(gm_meta.alternate_hdr_headroom_n, 3);
1544}
1545
1546#[test]
1547fn gain_map_metadata_field_exact() {
1548    // Known metadata blob -> embed -> extract -> verify field-by-field
1549    let primary_data = [1, 2, 3, 4];
1550    let gain_map_data = [99, 88, 77];
1551    let metadata = make_test_tmap_metadata(false, true, 0, 1, 6, 1);
1552
1553    let avif = Aviffy::new()
1554        .set_gain_map(gain_map_data.to_vec(), 1, 1, 8, metadata.clone())
1555        .to_vec(&primary_data, None, 10, 20, 8);
1556
1557    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1558    let gm_meta = parser.gain_map_metadata().expect("gain map metadata present");
1559
1560    assert_eq!(gm_meta.alternate_hdr_headroom_n, 6);
1561    assert_eq!(gm_meta.alternate_hdr_headroom_d, 1);
1562    assert_eq!(gm_meta.channels[0].gain_map_min_n, 0);
1563    assert_eq!(gm_meta.channels[0].gain_map_min_d, 1);
1564    assert_eq!(gm_meta.channels[0].gain_map_max_n, 1);
1565    assert_eq!(gm_meta.channels[0].gain_map_max_d, 1);
1566    assert_eq!(gm_meta.channels[0].gamma_n, 1);
1567    assert_eq!(gm_meta.channels[0].gamma_d, 1);
1568    assert_eq!(gm_meta.channels[0].base_offset_n, 0);
1569    assert_eq!(gm_meta.channels[0].base_offset_d, 1);
1570    assert_eq!(gm_meta.channels[0].alternate_offset_n, 0);
1571    assert_eq!(gm_meta.channels[0].alternate_offset_d, 1);
1572}
1573
1574#[test]
1575fn gain_map_alt_colr_roundtrip() {
1576    let primary_data = [1, 2, 3, 4, 5, 6];
1577    let gain_map_data = [10, 20, 30];
1578    let metadata = make_test_tmap_metadata(false, true, 0, 1, 1, 1);
1579
1580    let alt_colr = ColrBox {
1581        color_primaries: constants::ColorPrimaries::Bt2020,
1582        transfer_characteristics: constants::TransferCharacteristics::Smpte2084,
1583        matrix_coefficients: constants::MatrixCoefficients::Bt2020Ncl,
1584        full_range_flag: false,
1585    };
1586
1587    let avif = Aviffy::new()
1588        .set_gain_map(gain_map_data.to_vec(), 2, 2, 8, metadata)
1589        .set_gain_map_alt_colr(alt_colr)
1590        .to_vec(&primary_data, None, 10, 20, 8);
1591
1592    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1593
1594    // Gain map data intact
1595    let gm_data = parser.gain_map_data().expect("gain map data present").unwrap();
1596    assert_eq!(gm_data.as_ref(), &gain_map_data[..]);
1597
1598    // Verify alternate color info
1599    let alt = parser.gain_map_color_info().expect("alt color info should be present");
1600    match alt {
1601        zenavif_parse::ColorInformation::Nclx {
1602            color_primaries,
1603            transfer_characteristics,
1604            matrix_coefficients,
1605            full_range,
1606        } => {
1607            assert_eq!(*color_primaries, 9); // BT.2020
1608            assert_eq!(*transfer_characteristics, 16); // PQ
1609            assert_eq!(*matrix_coefficients, 9); // BT.2020 NCL
1610            assert!(!full_range);
1611        }
1612        other => panic!("expected NCLX color info, got: {:?}", other),
1613    }
1614}
1615
1616#[test]
1617fn no_gain_map_by_default() {
1618    let test_img = [1, 2, 3, 4, 5, 6];
1619    let avif = serialize_to_vec(&test_img, None, 10, 20, 8);
1620    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1621    assert!(parser.gain_map_metadata().is_none(), "no gain map metadata by default");
1622    assert!(parser.gain_map_data().is_none(), "no gain map data by default");
1623}
1624
1625#[test]
1626fn avif_parse_survives_gain_map() {
1627    let primary_data = b"primary_color";
1628    let gain_map_data = b"gain_map_pixels";
1629    let metadata = make_test_tmap_metadata(false, true, 0, 1, 1, 1);
1630
1631    let avif = Aviffy::new()
1632        .set_gain_map(gain_map_data.to_vec(), 4, 4, 8, metadata)
1633        .to_vec(primary_data, None, 10, 20, 8);
1634
1635    // avif-parse (older parser) must not crash
1636    let _ = avif_parse::read_avif(&mut avif.as_slice());
1637}
1638
1639#[test]
1640fn gain_map_metadata_bytes_exact_roundtrip() {
1641    // The raw metadata bytes we embed must come back byte-for-byte via to_bytes().
1642    // This verifies that serialize → parse → to_bytes is lossless.
1643    let primary_data = [1u8, 2, 3, 4];
1644    let gain_map_data = [10u8, 20, 30];
1645    let original_meta = make_test_tmap_metadata(false, true, 0, 1, 13, 10);
1646
1647    let avif = Aviffy::new()
1648        .set_gain_map(gain_map_data.to_vec(), 1, 1, 8, original_meta.clone())
1649        .to_vec(&primary_data, None, 10, 20, 8);
1650
1651    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1652    let parsed = parser.gain_map_metadata().expect("gain map metadata present");
1653    let roundtripped = parsed.to_bytes();
1654
1655    assert_eq!(original_meta, roundtripped,
1656        "tmap payload bytes must survive serialize → parse → to_bytes unchanged");
1657}
1658
1659#[test]
1660fn gain_map_backward_direction_flag_roundtrip() {
1661    // Build metadata with backward_direction=true (bit 2 of flags).
1662    // Verify it survives serialize → parse → struct → to_bytes.
1663    let primary_data = [1u8, 2, 3, 4];
1664    let gain_map_data = [55u8, 66];
1665
1666    let mut meta_bytes = make_test_tmap_metadata(false, false, 0, 1, 4, 1);
1667    // flags byte is at offset 5: set bit 2 (backward_direction)
1668    meta_bytes[5] |= 0x04;
1669
1670    let avif = Aviffy::new()
1671        .set_gain_map(gain_map_data.to_vec(), 1, 1, 8, meta_bytes.clone())
1672        .to_vec(&primary_data, None, 10, 20, 8);
1673
1674    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1675    let parsed = parser.gain_map_metadata().expect("gain map metadata");
1676
1677    assert!(parsed.backward_direction, "backward_direction must be parsed as true");
1678    assert!(!parsed.use_base_colour_space);
1679    assert!(!parsed.is_multichannel);
1680
1681    let roundtripped = parsed.to_bytes();
1682    assert_eq!(meta_bytes, roundtripped, "backward_direction flag must survive full roundtrip");
1683    assert_eq!(roundtripped[5] & 0x04, 0x04, "bit 2 must be set in roundtripped flags byte");
1684}
1685
1686#[test]
1687fn gain_map_multichannel_metadata_bytes_roundtrip() {
1688    let primary_data = [1u8, 2, 3, 4, 5, 6];
1689    let gain_map_data = [10u8, 20, 30, 40];
1690    let original_meta = make_test_tmap_metadata(true, true, 0, 1, 3, 1);
1691
1692    let avif = Aviffy::new()
1693        .set_gain_map(gain_map_data.to_vec(), 2, 2, 8, original_meta.clone())
1694        .to_vec(&primary_data, None, 10, 20, 8);
1695
1696    let parser = zenavif_parse::AvifParser::from_bytes(&avif).unwrap();
1697    let parsed = parser.gain_map_metadata().expect("gain map metadata");
1698    assert!(parsed.is_multichannel);
1699
1700    let roundtripped = parsed.to_bytes();
1701    assert_eq!(original_meta, roundtripped,
1702        "multichannel tmap payload bytes must survive roundtrip");
1703}