Skip to main content

container/
mux.rs

1use anyhow::{Context, Result};
2use bytes::Bytes;
3use codec::encode::EncodedPacket;
4use codec::frame::ColorMetadata;
5use std::fs::File;
6use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
7use std::path::Path;
8use tempfile::NamedTempFile;
9
10use crate::AudioInfo;
11
12/// Streams mdat payload bytes to a tempfile while keeping only small
13/// per-packet metadata vectors in RAM. At 15 min 1080p60 and ~500 kB/sample
14/// average the metadata Vecs are ~700 KB total; the packet payload (~500 MB
15/// per variant at AV1 CQ 32) stays on disk.
16///
17/// Faststart is preserved: `finalize_to_file` writes ftyp + moov first,
18/// then streams the tempfile's mdat bytes into the final output.
19///
20/// API:
21/// - `new(w, h, fps)` — constructs a spooled muxer, creating the tempfile
22///   immediately. Fails if tempdir is unwritable.
23/// - `add_packet(packet)` — appends packet payload to the tempfile and
24///   records size/sync metadata.
25/// - `with_audio(info)` — registers an optional audio track. Codec dispatch
26///   happens here on `info.codec` (`"aac"` / `"opus"` / `"ac3"` / `"eac3"`).
27///   Must be called before `add_audio_sample`. Bails on unsupported codecs
28///   or channel counts — no silent degradation.
29/// - `add_audio_sample(sample, pts_ticks, duration_ticks)` — appends one
30///   audio access unit plus per-sample metadata. Requires `with_audio`
31///   first.
32/// - `finalize_to_file(&Path)` — writes ftyp + moov + mdat payload to the
33///   target path. Consumes self.
34/// - `finalize()` — backward-compat shim that reads the finalized file into
35///   a `Bytes`. Useful for small tests; callers hitting the RAM ceiling
36///   should use `finalize_to_file` + `ObjectStore::upload_file`.
37pub struct Av1Mp4Muxer {
38    width: u32,
39    height: u32,
40    frame_rate: f64,
41    mdat_tmp: NamedTempFile,
42    mdat_writer: BufWriter<File>,
43    sample_sizes: Vec<u32>,
44    keyframe_indices: Vec<u32>,
45    first_packet_header: Option<Vec<u8>>,
46    packet_count: u32,
47    mdat_payload_bytes: u64,
48    audio: Option<AudioTrackState>,
49    /// Color metadata copied from the source `StreamInfo` so the visual
50    /// sample entry can carry an Apple-compliant `colr nclx` box. Defaults
51    /// to BT.709 SDR limited-range — Apple silently assumes that when
52    /// `colr` is absent, so the default is correct for SDR sources but
53    /// breaks BT.2020 / HDR clips. Real values arrive via `with_color`.
54    color_metadata: ColorMetadata,
55    /// Test-only override forcing the muxer to emit the 64-bit `largesize`
56    /// mdat header even when the payload would fit in the 32-bit `size`
57    /// field. Pre-existing payload size computation otherwise leaves the
58    /// largesize branch untestable without producing a 4 GiB tempfile.
59    /// Production callers leave this `false`; tests flip it on to assert
60    /// the bit-layout of the largesize header is correct.
61    ///
62    /// Must be a regular field (not `#[cfg(test)]`-gated) so integration
63    /// tests in `tests/` — which compile against the release library
64    /// without `cfg(test)` — can flip it via `force_largesize_mdat_for_test`.
65    #[doc(hidden)]
66    force_largesize_mdat: bool,
67}
68
69/// Per-muxer audio track state: info + spooling tempfile + per-sample
70/// metadata. Kept internal; populated via `with_audio` + `add_audio_sample`.
71struct AudioTrackState {
72    info: AudioInfo,
73    audio_tmp: NamedTempFile,
74    audio_writer: BufWriter<File>,
75    sample_sizes: Vec<u32>,
76    durations: Vec<u32>,
77    total_duration_ticks: u64,
78    mdat_payload_bytes: u64,
79}
80
81/// Internal discriminator chosen at `with_audio` time. Saves us re-parsing
82/// the codec string at every builder call site (build_audio_stsd, etc.) and
83/// keeps the AAC / Opus / AC-3 / E-AC-3 dispatch in one place.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum AudioCodecKind {
86    Aac,
87    Opus,
88    Ac3,
89    Eac3,
90}
91
92impl AudioCodecKind {
93    fn from_codec_tag(codec: &str) -> Option<Self> {
94        if codec.eq_ignore_ascii_case("aac") {
95            Some(Self::Aac)
96        } else if codec.eq_ignore_ascii_case("opus") {
97            Some(Self::Opus)
98        } else if codec.eq_ignore_ascii_case("ac3") || codec.eq_ignore_ascii_case("ac-3") {
99            Some(Self::Ac3)
100        } else if codec.eq_ignore_ascii_case("eac3") || codec.eq_ignore_ascii_case("e-ac-3") {
101            Some(Self::Eac3)
102        } else {
103            None
104        }
105    }
106}
107
108impl Av1Mp4Muxer {
109    pub fn new(width: u32, height: u32, frame_rate: f64) -> Result<Self> {
110        let mdat_tmp = NamedTempFile::new().context("creating mdat tempfile")?;
111        let handle = mdat_tmp
112            .reopen()
113            .context("reopening mdat tempfile for write")?;
114        let mdat_writer = BufWriter::new(handle);
115        Ok(Self {
116            width,
117            height,
118            frame_rate,
119            mdat_tmp,
120            mdat_writer,
121            sample_sizes: Vec::new(),
122            keyframe_indices: Vec::new(),
123            first_packet_header: None,
124            packet_count: 0,
125            mdat_payload_bytes: 0,
126            audio: None,
127            color_metadata: ColorMetadata::default(),
128            force_largesize_mdat: false,
129        })
130    }
131
132    /// Test-only knob to exercise the 64-bit mdat largesize header without
133    /// crafting a multi-GiB payload. Production callers do not touch this —
134    /// the natural threshold (`mdat_payload + 8 > u32::MAX`) selects
135    /// largesize when the file genuinely needs it.
136    #[doc(hidden)]
137    pub fn force_largesize_mdat_for_test(&mut self) -> &mut Self {
138        self.force_largesize_mdat = true;
139        self
140    }
141
142    /// Carry the source's color metadata into the visual sample entry's
143    /// `colr nclx` box. Apple QuickTime / iOS Safari silently assume
144    /// BT.709 limited-range when `colr` is missing, which corrupts
145    /// BT.2020 HDR / wide-gamut clips. Pipeline calls this once after
146    /// demux but before any `add_packet` — though calling order is
147    /// not load-bearing because the metadata is only consumed by the
148    /// finalize-time `build_av01` builder.
149    pub fn set_color_metadata(&mut self, color_metadata: ColorMetadata) -> &mut Self {
150        self.color_metadata = color_metadata;
151        self
152    }
153
154    pub fn add_packet(&mut self, packet: EncodedPacket) -> Result<()> {
155        // The first packet carries the sequence header OBU we embed in
156        // av1C; stash enough of it to recover the header at finalize.
157        if self.first_packet_header.is_none() {
158            self.first_packet_header = Some(packet.data.to_vec());
159        }
160        let size = packet.data.len() as u32;
161        self.mdat_writer
162            .write_all(&packet.data)
163            .context("writing packet to mdat tempfile")?;
164        self.sample_sizes.push(size);
165        self.packet_count = self
166            .packet_count
167            .checked_add(1)
168            .context("packet count overflow")?;
169        if packet.is_keyframe {
170            self.keyframe_indices.push(self.packet_count);
171        }
172        self.mdat_payload_bytes = self
173            .mdat_payload_bytes
174            .checked_add(size as u64)
175            .context("mdat payload overflow")?;
176        Ok(())
177    }
178
179    /// before `add_audio_sample`. Validates codec ∈ {AAC family, Opus,
180    /// AC-3, E-AC-3} with codec-appropriate channel-count gates —
181    /// anything outside the supported envelope must fail loudly (no
182    /// silent degradation, no stubs).
183    ///
184    /// AAC family path (Squad-18 + Squad-25): emits `mp4a` sample entry +
185    /// `esds` descriptor tree carrying the AudioSpecificConfig verbatim,
186    /// plus an Apple `chan` (Channel Layout) box for ≥3-channel streams
187    /// so iOS Safari / QuickTime / AVFoundation render the correct layout
188    /// instead of defaulting to L+R. Accepts:
189    ///   - AAC-LC (AOT=2), mono / stereo / 5.1 / 7.1
190    ///   - HE-AAC v1 (explicit-signaled SBR; ASC starts AOT=5)
191    ///   - HE-AAC v2 (explicit-signaled PS; ASC starts AOT=29)
192    ///
193    /// Implicit-signaled HE-AAC (AOT=2 leading byte at low core rate ≤24 kHz)
194    /// is rejected — the caller (`pipeline::transcode::route_audio`) is
195    /// responsible for upgrading the ASC via
196    /// `aac_asc::upgrade_to_explicit_signaling` before reaching the mux.
197    ///
198    /// Opus path (Squad-23 + Squad-28, RFC 7845): emits `Opus` sample entry
199    /// + `dOps` (Opus-Specific Box) carrying the OpusHead body verbatim.
200    /// Mono / stereo via ChannelMappingFamily=0 (Squad-23) or 3..=8
201    /// channels via ChannelMappingFamily=1 surround layouts (Squad-28).
202    /// Requires `info.codec_private` populated with the appropriate-form
203    /// OpusHead body. The mdhd timescale is pinned to 48000 per RFC 7845
204    /// §3 — the `info.timescale` is validated equal.
205    ///
206    /// AC-3 path (Squad-26, ETSI TS 102 366 §F.2): emits `ac-3` sample
207    /// entry + `dac3` config box carrying the 3-byte body verbatim. Up
208    /// to 5.1 channels. Sample rates 32 / 44.1 / 48 kHz only.
209    ///
210    /// E-AC-3 path (Squad-26, ETSI TS 102 366 §F.5): emits `ec-3` sample
211    /// entry + `dec3` config box. Up to 5.1 channels in v1 scope (single
212    /// independent substream). Sample rates 16 / 22.05 / 24 / 32 / 44.1 /
213    /// 48 kHz.
214    ///
215    /// Returns `&mut Self` for builder-style chaining. The audio tempfile
216    /// is created eagerly so tempdir failures surface here rather than at
217    /// `add_audio_sample` time.
218    pub fn with_audio(&mut self, info: AudioInfo) -> Result<&mut Self> {
219        // Codec dispatch: AAC, Opus, AC-3, E-AC-3 are the supported
220        // families. Other codec tags (mp3, vorbis, ...) are intentionally
221        // rejected here so the pipeline fall-back path in `transcode.rs` can
222        // surface a clean warn and emit video-only.
223        let codec_kind = AudioCodecKind::from_codec_tag(&info.codec).ok_or_else(|| {
224            anyhow::anyhow!(
225                "audio mux: only AAC-LC, Opus, AC-3, E-AC-3 are supported; got codec '{}'",
226                info.codec
227            )
228        })?;
229        // Per-codec channel-count gates.
230        // - AAC: standard MPEG channelConfiguration values 1 (mono) /
231        //   2 (stereo) / 6 (5.1) / 7 (7.1). Multichannel adds an Apple
232        //   `chan` box (Squad-25) for QuickTime / AVFoundation rendering.
233        // - Opus: 1..=8. Mono/stereo via ChannelMappingFamily=0 (Squad-23);
234        //   3..=8 ride the dOps family-1 surround trailer per RFC 7845
235        //   §5.1.1.2 (Squad-28 multistream).
236        // - AC-3 / E-AC-3: up to 6 channels (5.1). The real layout lives
237        //   in `acmod`+`lfeon` inside the dac3/dec3 body; the
238        //   AudioSampleEntry channelcount is informational. v1 scope keeps
239        //   things tight at 5.1.
240        match codec_kind {
241            AudioCodecKind::Aac => {
242                if !matches!(info.channels, 1 | 2 | 6 | 7) {
243                    anyhow::bail!(
244                        "audio mux: AAC supports mono/stereo/5.1(channels=6)/7.1(channels=7) layouts; \
245                         got {} channels — extended Atmos / object layouts are not supported",
246                        info.channels
247                    );
248                }
249            }
250            AudioCodecKind::Opus => {
251                if info.channels < 1 || info.channels > 8 {
252                    anyhow::bail!(
253                        "audio mux: Opus supports 1..=8 channels; got {}",
254                        info.channels
255                    );
256                }
257            }
258            AudioCodecKind::Ac3 | AudioCodecKind::Eac3 => {
259                if !(1..=6).contains(&info.channels) {
260                    anyhow::bail!(
261                        "audio mux: AC-3 / E-AC-3 channel count must be 1..=6 (mono..5.1); got {}",
262                        info.channels
263                    );
264                }
265            }
266        }
267        if info.sample_rate == 0 {
268            anyhow::bail!("audio mux: sample_rate must be > 0");
269        }
270        if info.timescale == 0 {
271            anyhow::bail!("audio mux: timescale must be > 0");
272        }
273        match codec_kind {
274            AudioCodecKind::Aac => {
275                if info.asc_bytes.is_empty() {
276                    anyhow::bail!("audio mux: AudioSpecificConfig bytes missing");
277                }
278                // Parse the ASC's leading AOT (with the 5-bit raw + 6-bit
279                // extension escape per ISO 14496-3 §1.6.2.1) so HE-AAC
280                // explicit signaling isn't rejected by a naive `>>3 & 0x1F`
281                // peek. Squad-25 lifts the prior AAC-LC-only gate.
282                let parsed = crate::aac_asc::parse_aac_asc(&info.asc_bytes)
283                    .with_context(|| "audio mux: failed to parse AudioSpecificConfig")?;
284                use crate::aac_asc::AscSignaling;
285                match parsed.signaling {
286                    AscSignaling::ImplicitMaybe => {
287                        anyhow::bail!(
288                            "audio mux: ASC uses implicit HE-AAC signaling (AOT=2 core at \
289                             {} Hz with no SBR/PS layer in the ASC). Apple players silently \
290                             downgrade to mono 22.05 kHz core. Caller must upgrade with \
291                             aac_asc::upgrade_to_explicit_signaling before muxing.",
292                            parsed.sample_rate
293                        );
294                    }
295                    AscSignaling::NoExtension
296                    | AscSignaling::ExplicitSbr
297                    | AscSignaling::ExplicitPs => {
298                        // AOT=2 (LC), AOT=5 (SBR-wrapped LC), AOT=29 (PS-wrapped LC),
299                        // and AOT=42 (xHE-AAC USAC) are all accepted at the mux
300                        // level. The `esds` writer emits the ASC verbatim so the
301                        // decoder receives whatever signaling the ASC carries.
302                        let core_aot = parsed.aot;
303                        if !matches!(core_aot, 2 | 42) {
304                            anyhow::bail!(
305                                "audio mux: only AAC-LC (AOT=2) and xHE-AAC USAC (AOT=42) \
306                                 cores are supported; ASC core AOT={}",
307                                core_aot
308                            );
309                        }
310                    }
311                }
312            }
313            AudioCodecKind::Opus => {
314                // OpusHead body without the 8-byte 'OpusHead' magic is 11
315                // bytes minimum for ChannelMappingFamily=0 (RFC 7845 §5.1).
316                // Reject anything shorter — the dOps writer can't synthesize
317                // a missing field and producing an empty box would silently
318                // break every player.
319                if info.codec_private.len() < 11 {
320                    anyhow::bail!(
321                        "audio mux: Opus codec_private must be ≥11 bytes (RFC 7845 §5.1 \
322                         minimum body for ChannelMappingFamily=0); got {} bytes",
323                        info.codec_private.len()
324                    );
325                }
326                // RFC 7845 §3: the audio mdhd timescale MUST be 48000 for
327                // Opus. The CALLER pins this in `AudioInfo::opus(...)`; if
328                // they hand-built an `AudioInfo` with a different timescale
329                // we reject loudly so a downstream stts mismatch can't
330                // silently shift PTS by a small fraction.
331                if info.timescale != 48_000 {
332                    anyhow::bail!(
333                        "audio mux: Opus mdhd timescale must be 48000 (RFC 7845 §3); \
334                         got timescale={}",
335                        info.timescale
336                    );
337                }
338                // ChannelMappingFamily byte (offset 10 in the OpusHead body
339                // we emit into dOps). Family 0 is mono/stereo (1..=2
340                // channels). Family 1 (Squad-28) is surround for 1..=8
341                // channels; requires a 2 + N byte trailer
342                // (StreamCount + CoupledCount + ChannelMapping[N]) per
343                // RFC 7845 §5.1.1. Family 255 (arbitrary mappings) and
344                // any other unknown family are rejected.
345                let cmf = info.codec_private[10];
346                match cmf {
347                    0 => {
348                        // RFC 7845 §5.1.1: family 0 is defined for
349                        // 1..=2 channels only.
350                        if info.channels > 2 {
351                            anyhow::bail!(
352                                "audio mux: Opus ChannelMappingFamily=0 only supports 1..=2 channels; got {}",
353                                info.channels
354                            );
355                        }
356                    }
357                    1 => {
358                        // Family 1 needs StreamCount + CoupledCount +
359                        // ChannelMapping[channels] after the 11-byte
360                        // preamble. Total dOps body = 11 + 2 + N.
361                        let n = info.channels as usize;
362                        let needed = 11 + 2 + n;
363                        if info.codec_private.len() < needed {
364                            anyhow::bail!(
365                                "audio mux: Opus family=1 codec_private must be ≥{needed} bytes \
366                                 (11 preamble + 2 stream/coupled + {n} mapping); got {}",
367                                info.codec_private.len()
368                            );
369                        }
370                        let stream_count = info.codec_private[11];
371                        let coupled_count = info.codec_private[12];
372                        // libopus invariants (RFC 7845 §5.1.1):
373                        //   - StreamCount >= 1
374                        //   - CoupledCount <= StreamCount
375                        //   - StreamCount + CoupledCount <= 255 (always
376                        //     true at our scale)
377                        //   - StreamCount + CoupledCount <= channels
378                        //     (every encoder stream covers >=1 channel)
379                        if stream_count < 1 {
380                            anyhow::bail!(
381                                "audio mux: Opus family=1 StreamCount must be >= 1; got {stream_count}"
382                            );
383                        }
384                        if coupled_count > stream_count {
385                            anyhow::bail!(
386                                "audio mux: Opus family=1 CoupledCount ({coupled_count}) > StreamCount ({stream_count})"
387                            );
388                        }
389                        if (stream_count as u16) + (coupled_count as u16) > info.channels {
390                            anyhow::bail!(
391                                "audio mux: Opus family=1 StreamCount ({stream_count}) + CoupledCount ({coupled_count}) > channels ({})",
392                                info.channels
393                            );
394                        }
395                        // ChannelMapping[i] must be < streams +
396                        // coupled (i.e. a valid encoder-stream index).
397                        let mapping_max = stream_count + coupled_count;
398                        for i in 0..n {
399                            let m = info.codec_private[13 + i];
400                            if m >= mapping_max {
401                                anyhow::bail!(
402                                    "audio mux: Opus family=1 ChannelMapping[{i}]={m} \
403                                     exceeds streams+coupled ({mapping_max})"
404                                );
405                            }
406                        }
407                    }
408                    other => {
409                        anyhow::bail!(
410                            "audio mux: only Opus ChannelMappingFamily 0 (mono/stereo) and 1 (surround 1..=8) supported; \
411                             got family={other}"
412                        );
413                    }
414                }
415            }
416            AudioCodecKind::Ac3 => {
417                // dac3 body is exactly 3 bytes per ETSI TS 102 366 §F.4
418                // (fscod 2b | bsid 5b | bsmod 3b | acmod 3b | lfeon 1b |
419                //  bit_rate_code 5b | reserved 5b => 24 bits total).
420                if info.codec_private.len() != 3 {
421                    anyhow::bail!(
422                        "audio mux: AC-3 codec_private (dac3 body) must be exactly 3 bytes \
423                         per ETSI TS 102 366 §F.4; got {} bytes",
424                        info.codec_private.len()
425                    );
426                }
427                // Sample rate sanity per ETSI TS 102 366 Table F.5.
428                match info.sample_rate {
429                    32_000 | 44_100 | 48_000 => {}
430                    other => anyhow::bail!(
431                        "audio mux: AC-3 sample_rate must be 32000 / 44100 / 48000; got {}",
432                        other
433                    ),
434                }
435            }
436            AudioCodecKind::Eac3 => {
437                // dec3 body is variable-size; minimum body is 5 bytes for a
438                // single independent substream with no dependent substreams
439                // (data_rate 13b + num_ind_sub 3b = 2B + per-indep-substream
440                //  fscod/bsid/asvc/bsmod/acmod/lfeon/num_dep_sub fields
441                //  packed into the next 3 bytes). Reject anything shorter.
442                if info.codec_private.len() < 5 {
443                    anyhow::bail!(
444                        "audio mux: E-AC-3 codec_private (dec3 body) must be ≥5 bytes \
445                         per ETSI TS 102 366 §F.6; got {} bytes",
446                        info.codec_private.len()
447                    );
448                }
449                // E-AC-3 sample rates: 32 / 44.1 / 48 kHz at "full" rate
450                // plus 16 / 22.05 / 24 kHz "reduced rate" (fscod==3 path).
451                match info.sample_rate {
452                    16_000 | 22_050 | 24_000 | 32_000 | 44_100 | 48_000 => {}
453                    other => anyhow::bail!(
454                        "audio mux: E-AC-3 sample_rate must be 16000 / 22050 / 24000 / 32000 / \
455                         44100 / 48000; got {}",
456                        other
457                    ),
458                }
459            }
460        }
461        if self.audio.is_some() {
462            anyhow::bail!("audio mux: with_audio called twice");
463        }
464        let audio_tmp = NamedTempFile::new().context("creating audio mdat tempfile")?;
465        let handle = audio_tmp
466            .reopen()
467            .context("reopening audio tempfile for write")?;
468        let audio_writer = BufWriter::new(handle);
469        self.audio = Some(AudioTrackState {
470            info,
471            audio_tmp,
472            audio_writer,
473            sample_sizes: Vec::new(),
474            durations: Vec::new(),
475            total_duration_ticks: 0,
476            mdat_payload_bytes: 0,
477        });
478        Ok(self)
479    }
480
481    /// Append one audio access unit (AAC AU / Opus packet / AC-3 syncframe /
482    /// E-AC-3 syncframe). `pts_ticks` is currently informational only —
483    /// ISOBMFF doesn't store per-sample PTS directly; stts durations imply
484    /// a running clock starting at 0. We accept it in the API to keep the
485    /// signature extensible (edit-lists / ctts for offset signalling can
486    /// land here later).
487    pub fn add_audio_sample(
488        &mut self,
489        sample: &[u8],
490        _pts_ticks: u64,
491        duration_ticks: u32,
492    ) -> Result<()> {
493        let audio = self
494            .audio
495            .as_mut()
496            .context("audio mux: add_audio_sample called before with_audio")?;
497        if sample.is_empty() {
498            anyhow::bail!("audio mux: refusing to add empty audio access unit");
499        }
500        audio
501            .audio_writer
502            .write_all(sample)
503            .context("writing audio sample to tempfile")?;
504        audio.sample_sizes.push(sample.len() as u32);
505        let dur = if duration_ticks == 0 {
506            // Codec-aware default frame duration. AAC: 1024 samples (the
507            // natural transform length); Opus: 960 ticks @ 48 kHz = 20 ms
508            // (the standard libopus encoder frame size); AC-3: 1536 samples
509            // per syncframe (6 blocks × 256 samples per ETSI TS 102 366);
510            // E-AC-3: 1536 samples for the dominant numblkscod=3 / 6-block
511            // case (other numblkscod values would be 256/512/768 — caller
512            // should override). Most common defaults; callers can override
513            // with an explicit non-zero `duration_ticks`.
514            match AudioCodecKind::from_codec_tag(&audio.info.codec) {
515                Some(AudioCodecKind::Aac) => 1024,
516                Some(AudioCodecKind::Opus) => 960,
517                Some(AudioCodecKind::Ac3) | Some(AudioCodecKind::Eac3) => 1536,
518                None => 1024, // unreachable: with_audio gates the codec tag
519            }
520        } else {
521            duration_ticks
522        };
523        audio.durations.push(dur);
524        audio.total_duration_ticks = audio
525            .total_duration_ticks
526            .checked_add(dur as u64)
527            .context("audio total duration overflow")?;
528        audio.mdat_payload_bytes = audio
529            .mdat_payload_bytes
530            .checked_add(sample.len() as u64)
531            .context("audio mdat payload overflow")?;
532        Ok(())
533    }
534
535    /// Write ftyp + moov + mdat into `output_path`. Faststart preserved.
536    ///
537    /// When audio is present (via `with_audio`), writes an interleaved mdat
538    /// with chunk-alternation: one ~1s video chunk then one ~1s audio chunk,
539    /// repeated until both tracks are drained. stco/co64 entries in each
540    /// trak's stbl point at the first sample of that trak's chunk inside
541    /// the shared mdat.
542    pub fn finalize_to_file(mut self, output_path: &Path) -> Result<()> {
543        if self.packet_count == 0 {
544            anyhow::bail!("cannot finalize MP4 with zero packets");
545        }
546        self.mdat_writer.flush().context("flushing mdat tempfile")?;
547        if let Some(ref mut audio) = self.audio {
548            audio
549                .audio_writer
550                .flush()
551                .context("flushing audio mdat tempfile")?;
552            if audio.sample_sizes.is_empty() {
553                // Caller called with_audio but never pushed a sample. Safer
554                // to drop the audio track than emit an empty audio trak
555                // that confuses players.
556                tracing::warn!(
557                    "audio mux: with_audio called but no samples pushed; dropping audio"
558                );
559                self.audio = None;
560            }
561        }
562
563        // 90 kHz matches ffmpeg/x264/x265 and divides evenly for 23.976 /
564        // 29.97 / 59.94 fps.
565        let video_timescale: u32 = 90_000;
566        let frame_duration: u32 = ((video_timescale as f64) / self.frame_rate)
567            .round()
568            .max(1.0) as u32;
569        let total_video_duration: u64 = frame_duration as u64 * self.packet_count as u64;
570
571        let first_packet = self
572            .first_packet_header
573            .as_ref()
574            .context("first packet header missing; add_packet never called?")?;
575        let config_obus = extract_sequence_header(first_packet)
576            .context("extracting AV1 sequence header OBU from first packet")?;
577
578        let ftyp = build_ftyp();
579
580        // Chunking policy: one second per chunk, capped at 120 for video
581        // and 200 for audio. Matching ~1 s per chunk on both sides keeps
582        // seek granularity consistent and bounds stsc/stco table sizes.
583        let video_spc: u32 = (self.frame_rate.round() as u32).max(1).min(120);
584
585        // Pre-compute audio chunking + per-track totals so the movie header
586        // can report `max(video_duration, audio_duration)` in movie timescale.
587        // Choose movie timescale = max(video, audio) timescales so both
588        // durations convert integer-cleanly (we use video's 90 kHz which is
589        // already a multiple of all common audio rates' divisors in the
590        // chosen target — but we do the conversion explicitly either way
591        // since 48000 ∤ 90000; we round-to-nearest which is what ISOBMFF
592        // players expect for track duration display).
593        let movie_timescale: u32 = video_timescale;
594
595        let audio_plan: Option<AudioBuildPlan> = self.audio.as_ref().map(|a| {
596            // Chunking policy: aim for ~1 second of audio per chunk.
597            // Frame size differs by codec — AAC = 1024 samples / frame,
598            // Opus = 960 samples / frame at 48 kHz (the standard encoder
599            // frame size; callers using 2.5 / 5 / 10 / 40 / 60 ms frames
600            // would diverge but the chunk-size cap and the 1-second
601            // target both still apply, so the worst case is a slightly
602            // suboptimal chunk granularity rather than a structurally
603            // broken file). The mdhd timescale is `a.info.timescale`
604            // (sample_rate for AAC, 48000 for Opus).
605            let frames_per_sec = match AudioCodecKind::from_codec_tag(&a.info.codec) {
606                Some(AudioCodecKind::Opus) => (a.info.timescale as f64) / 960.0,
607                // AC-3 / E-AC-3: 1536 samples per syncframe (6 blocks × 256).
608                Some(AudioCodecKind::Ac3) | Some(AudioCodecKind::Eac3) => {
609                    (a.info.timescale as f64) / 1536.0
610                }
611                Some(AudioCodecKind::Aac) | None => (a.info.timescale as f64) / 1024.0,
612            };
613            let audio_spc = (frames_per_sec.round() as u32).max(1).min(200);
614            let audio_duration_movie: u64 =
615                ((a.total_duration_ticks as u128) * movie_timescale as u128
616                    / a.info.timescale.max(1) as u128) as u64;
617            AudioBuildPlan {
618                info: a.info.clone(),
619                sample_sizes: a.sample_sizes.clone(),
620                durations: a.durations.clone(),
621                total_duration_in_own_ts: a.total_duration_ticks,
622                total_duration_in_movie_ts: audio_duration_movie,
623                samples_per_chunk: audio_spc,
624            }
625        });
626
627        let video_duration_movie: u64 = total_video_duration; // video uses 90 kHz == movie
628        let movie_duration: u64 = match audio_plan.as_ref() {
629            Some(p) => video_duration_movie.max(p.total_duration_in_movie_ts),
630            None => video_duration_movie,
631        };
632
633        // Video-side mdat byte total stays in self; audio side is in plan.
634        let video_payload_bytes = self.mdat_payload_bytes;
635        let audio_payload_bytes = audio_plan
636            .as_ref()
637            .map(|p| p.sample_sizes.iter().map(|&s| s as u64).sum::<u64>())
638            .unwrap_or(0);
639        let mdat_payload_total = video_payload_bytes
640            .checked_add(audio_payload_bytes)
641            .context("combined mdat payload overflow")?;
642
643        // mdat box-size policy. The 32-bit `size` field maxes at
644        // u32::MAX; the box header is 8 bytes (size + type). When the box
645        // body alone would push the total past u32::MAX - 8, we switch to
646        // the ISOBMFF 14496-12 §4.2 largesize form: `size = 1` (32 bits),
647        // `type = 'mdat'`, then a 64-bit `largesize` field carrying the
648        // total box length (header + payload). Header grows from 8 → 16
649        // bytes which means stco/co64 offsets must reflect the post-header
650        // start.
651        let mdat_payload_plus_short_header = 8u64
652            .checked_add(mdat_payload_total)
653            .context("mdat short-header size overflow")?;
654        // Production: pick largesize iff the payload + short header
655        // exceeds u32. Tests can force largesize on to exercise the
656        // bit-layout without crafting a 4 GiB tempfile.
657        let use_largesize_mdat =
658            mdat_payload_plus_short_header > u32::MAX as u64 || self.force_largesize_mdat;
659        let mdat_header_len: u64 = if use_largesize_mdat { 16 } else { 8 };
660        let mdat_box_size: u64 = mdat_header_len
661            .checked_add(mdat_payload_total)
662            .context("mdat box size overflow")?;
663
664        // Two-pass moov construction. On pass 1 we need placeholder offsets
665        // of consistent widths to size the moov; on pass 2 we use the real
666        // offsets computed against the planned mdat layout.
667        let video_chunk_count = chunk_count_of(self.sample_sizes.len(), video_spc);
668        let audio_chunk_count = audio_plan
669            .as_ref()
670            .map(|p| chunk_count_of(p.sample_sizes.len(), p.samples_per_chunk))
671            .unwrap_or(0);
672        let video_zero_offsets: Vec<u64> = vec![0; video_chunk_count];
673        let audio_zero_offsets: Vec<u64> = vec![0; audio_chunk_count];
674
675        let moov_co64_size = build_moov_any(
676            self.width,
677            self.height,
678            video_timescale,
679            movie_timescale,
680            movie_duration,
681            total_video_duration,
682            frame_duration,
683            &self.sample_sizes,
684            &self.keyframe_indices,
685            &config_obus,
686            &video_zero_offsets,
687            video_spc,
688            audio_plan.as_ref(),
689            &audio_zero_offsets,
690            true,
691            &self.color_metadata,
692        )
693        .len() as u64;
694
695        let upper_bound: u64 = (ftyp.len() as u64)
696            .checked_add(moov_co64_size)
697            .context("moov size overflow")?
698            .checked_add(mdat_header_len)
699            .context("mdat header overflow")?
700            .checked_add(mdat_payload_total)
701            .context("mdat payload overflow")?;
702        let use_co64 = upper_bound > u32::MAX as u64;
703
704        let moov_without_offsets = build_moov_any(
705            self.width,
706            self.height,
707            video_timescale,
708            movie_timescale,
709            movie_duration,
710            total_video_duration,
711            frame_duration,
712            &self.sample_sizes,
713            &self.keyframe_indices,
714            &config_obus,
715            &video_zero_offsets,
716            video_spc,
717            audio_plan.as_ref(),
718            &audio_zero_offsets,
719            use_co64,
720            &self.color_metadata,
721        );
722
723        let mdat_offset_in_file = (ftyp.len() + moov_without_offsets.len()) as u64;
724        let first_sample_file_offset = mdat_offset_in_file + mdat_header_len;
725        if !use_co64 && first_sample_file_offset > u32::MAX as u64 {
726            anyhow::bail!(
727                "internal: chose stco but first_sample_file_offset {} exceeds u32",
728                first_sample_file_offset
729            );
730        }
731
732        // Compute interleaved chunk offsets. No audio → contiguous video
733        // chunks (unchanged behaviour). Audio present → alternating video,
734        // audio, video, audio, ..., tail is whichever side has samples left.
735        let (video_chunk_offsets, audio_chunk_offsets, interleave_plan) = plan_interleaved_layout(
736            first_sample_file_offset,
737            &self.sample_sizes,
738            video_spc,
739            audio_plan.as_ref(),
740        );
741        debug_assert_eq!(video_chunk_offsets.len(), video_chunk_count);
742        debug_assert_eq!(audio_chunk_offsets.len(), audio_chunk_count);
743
744        let moov = build_moov_any(
745            self.width,
746            self.height,
747            video_timescale,
748            movie_timescale,
749            movie_duration,
750            total_video_duration,
751            frame_duration,
752            &self.sample_sizes,
753            &self.keyframe_indices,
754            &config_obus,
755            &video_chunk_offsets,
756            video_spc,
757            audio_plan.as_ref(),
758            &audio_chunk_offsets,
759            use_co64,
760            &self.color_metadata,
761        );
762
763        assert_eq!(
764            moov.len(),
765            moov_without_offsets.len(),
766            "moov size must be stable across rebuild"
767        );
768
769        // Stream final layout: ftyp + moov + mdat-header + mdat-payload.
770        let out_file = File::create(output_path)
771            .with_context(|| format!("creating output file {}", output_path.display()))?;
772        let mut out = BufWriter::new(out_file);
773        out.write_all(&ftyp).context("writing ftyp")?;
774        out.write_all(&moov).context("writing moov")?;
775        if use_largesize_mdat {
776            // size=1 sentinel, then 'mdat', then 64-bit largesize.
777            out.write_all(&1u32.to_be_bytes())
778                .context("writing mdat largesize sentinel")?;
779            out.write_all(b"mdat").context("writing mdat type")?;
780            out.write_all(&mdat_box_size.to_be_bytes())
781                .context("writing mdat largesize")?;
782        } else {
783            let mdat_size_u32 = mdat_box_size as u32;
784            out.write_all(&mdat_size_u32.to_be_bytes())
785                .context("writing mdat size")?;
786            out.write_all(b"mdat").context("writing mdat type")?;
787        }
788
789        // Stream mdat bytes per the interleave plan. Each InterleaveStep
790        // records which track and how many bytes to copy from that track's
791        // tempfile. We reopen both tempfiles once and copy by range so we
792        // never buffer the full payload.
793        let video_payload_handle = self
794            .mdat_tmp
795            .reopen()
796            .context("reopening mdat tempfile for read")?;
797        let mut video_payload = BufReader::new(video_payload_handle);
798        video_payload
799            .seek(SeekFrom::Start(0))
800            .context("rewinding mdat tempfile")?;
801
802        let mut audio_payload: Option<BufReader<File>> = match self.audio.as_ref() {
803            Some(a) => {
804                let h = a
805                    .audio_tmp
806                    .reopen()
807                    .context("reopening audio mdat tempfile for read")?;
808                let mut r = BufReader::new(h);
809                r.seek(SeekFrom::Start(0))
810                    .context("rewinding audio mdat tempfile")?;
811                Some(r)
812            }
813            None => None,
814        };
815
816        let mut video_copied: u64 = 0;
817        let mut audio_copied: u64 = 0;
818        for step in &interleave_plan {
819            match step.track {
820                InterleaveTrack::Video => {
821                    let copied =
822                        std::io::copy(&mut (&mut video_payload).take(step.bytes), &mut out)
823                            .context("copying video chunk into mdat")?;
824                    if copied != step.bytes {
825                        anyhow::bail!(
826                            "video chunk short read: wanted {}, got {}",
827                            step.bytes,
828                            copied
829                        );
830                    }
831                    video_copied += copied;
832                }
833                InterleaveTrack::Audio => {
834                    let audio_r = audio_payload.as_mut().context(
835                        "internal: interleave plan has audio step but no audio tempfile",
836                    )?;
837                    let copied = std::io::copy(&mut audio_r.take(step.bytes), &mut out)
838                        .context("copying audio chunk into mdat")?;
839                    if copied != step.bytes {
840                        anyhow::bail!(
841                            "audio chunk short read: wanted {}, got {}",
842                            step.bytes,
843                            copied
844                        );
845                    }
846                    audio_copied += copied;
847                }
848            }
849        }
850        if video_copied != video_payload_bytes {
851            anyhow::bail!(
852                "video mdat payload length mismatch: expected {}, copied {}",
853                video_payload_bytes,
854                video_copied
855            );
856        }
857        if audio_copied != audio_payload_bytes {
858            anyhow::bail!(
859                "audio mdat payload length mismatch: expected {}, copied {}",
860                audio_payload_bytes,
861                audio_copied
862            );
863        }
864        out.flush().context("flushing output")?;
865
866        Ok(())
867    }
868
869    /// Back-compat: finalize into memory. Writes to a second tempfile then
870    /// reads it back. Callers hitting the 4 GB ceiling should use
871    /// `finalize_to_file` instead.
872    pub fn finalize(self) -> Result<Bytes> {
873        let tmp = NamedTempFile::new().context("creating finalize buffer tempfile")?;
874        let path = tmp.path().to_path_buf();
875        self.finalize_to_file(&path)?;
876        let mut f = File::open(&path).context("reopening finalize buffer tempfile")?;
877        let mut buf = Vec::new();
878        f.read_to_end(&mut buf).context("reading finalize buffer")?;
879        Ok(Bytes::from(buf))
880    }
881}
882
883/// Audio build plan shared between sizing passes and the final moov emit.
884/// Holds the post-flush AAC metadata plus the derived chunking policy.
885struct AudioBuildPlan {
886    info: AudioInfo,
887    sample_sizes: Vec<u32>,
888    durations: Vec<u32>,
889    total_duration_in_own_ts: u64,
890    total_duration_in_movie_ts: u64,
891    samples_per_chunk: u32,
892}
893
894/// One contiguous copy from one source tempfile to the output. The finalize
895/// loop walks a Vec<InterleaveStep> and copies `bytes` from the chosen
896/// track's tempfile into the output stream, which keeps peak RAM bounded.
897#[derive(Debug, Clone, Copy)]
898struct InterleaveStep {
899    track: InterleaveTrack,
900    bytes: u64,
901}
902
903#[derive(Debug, Clone, Copy, PartialEq, Eq)]
904enum InterleaveTrack {
905    Video,
906    Audio,
907}
908
909fn chunk_count_of(sample_count: usize, spc: u32) -> usize {
910    if sample_count == 0 {
911        return 0;
912    }
913    let spc = spc.max(1) as usize;
914    sample_count.div_ceil(spc)
915}
916
917/// Compute chunk byte size arrays — one entry per chunk, summing sample
918/// sizes inside each chunk.
919fn chunk_byte_sizes(sample_sizes: &[u32], spc: u32) -> Vec<u64> {
920    let spc = spc.max(1) as usize;
921    let mut out = Vec::new();
922    let mut i = 0usize;
923    while i < sample_sizes.len() {
924        let end = (i + spc).min(sample_sizes.len());
925        let mut total: u64 = 0;
926        for &s in &sample_sizes[i..end] {
927            total += s as u64;
928        }
929        out.push(total);
930        i = end;
931    }
932    out
933}
934
935/// Plan the interleaved mdat layout + assign per-track chunk offsets.
936/// Chunk-alternation: emit one video chunk then one audio chunk, repeating
937/// until both are drained; tail chunks for whichever track has more chunks.
938/// This gives ~1 s interleave granularity on both sides which matches the
939/// spc policy (video: frame_rate fps / 1 chunk; audio: ~46 chunks/s worth).
940fn plan_interleaved_layout(
941    first_sample_file_offset: u64,
942    video_sample_sizes: &[u32],
943    video_spc: u32,
944    audio_plan: Option<&AudioBuildPlan>,
945) -> (Vec<u64>, Vec<u64>, Vec<InterleaveStep>) {
946    let video_chunks = chunk_byte_sizes(video_sample_sizes, video_spc);
947    let audio_chunks = match audio_plan {
948        Some(p) => chunk_byte_sizes(&p.sample_sizes, p.samples_per_chunk),
949        None => Vec::new(),
950    };
951
952    let mut video_offsets: Vec<u64> = Vec::with_capacity(video_chunks.len());
953    let mut audio_offsets: Vec<u64> = Vec::with_capacity(audio_chunks.len());
954    let mut plan: Vec<InterleaveStep> = Vec::with_capacity(video_chunks.len() + audio_chunks.len());
955
956    let mut cursor = first_sample_file_offset;
957    let mut vi = 0usize;
958    let mut ai = 0usize;
959    loop {
960        if vi < video_chunks.len() {
961            video_offsets.push(cursor);
962            let size = video_chunks[vi];
963            plan.push(InterleaveStep {
964                track: InterleaveTrack::Video,
965                bytes: size,
966            });
967            cursor = cursor.saturating_add(size);
968            vi += 1;
969        }
970        if ai < audio_chunks.len() {
971            audio_offsets.push(cursor);
972            let size = audio_chunks[ai];
973            plan.push(InterleaveStep {
974                track: InterleaveTrack::Audio,
975                bytes: size,
976            });
977            cursor = cursor.saturating_add(size);
978            ai += 1;
979        }
980        if vi >= video_chunks.len() && ai >= audio_chunks.len() {
981            break;
982        }
983    }
984
985    (video_offsets, audio_offsets, plan)
986}
987
988/// Build `ftyp` for AV1-in-MP4 with Apple-device compatibility.
989///
990/// Per AV1-ISOBMFF v1.3.0 §2.1, an AV1-bearing ISOBMFF file SHALL list
991/// `av01` in its `compatible_brands`. Apple's QuickTime / iOS Safari
992/// stack additionally requires a structural ISOBMFF brand: `iso6`
993/// (ISO/IEC 14496-12 sixth edition — covers `co64`, `mehd` v1, etc.)
994/// is the right choice here because the muxer's co64 / large-mdat
995/// extensions need the v6 spec scope to be conformant. `mp42`
996/// (ISO/IEC 14496-14 second edition) is the conventional brand
997/// downstream players key off when deciding AAC / mp4a parsing rules,
998/// so we list it as well.
999///
1000/// `major_brand` is set to `iso6` so a strict parser that rejects an
1001/// `isom`/`mp41`-major file with a co64 box (mp41 predates the v6
1002/// definition) accepts the output.
1003fn build_ftyp() -> Vec<u8> {
1004    let mut b = BoxBuilder::new(b"ftyp");
1005    b.extend(b"iso6"); // major_brand (v6 of 14496-12; covers co64/largesize)
1006    b.u32(512); // minor_version (matches FFmpeg / mp4box convention)
1007    b.extend(b"iso6"); // compatible: structural baseline
1008    b.extend(b"iso2"); // compatible: 14496-12 second edition (legacy parsers)
1009    b.extend(b"av01"); // compatible: AV1-ISOBMFF (REQUIRED per §2.1)
1010    b.extend(b"mp41"); // compatible: classic 14496-14 (older players)
1011    b.extend(b"mp42"); // compatible: 14496-14 second edition (AAC parsing rules)
1012    b.finish()
1013}
1014
1015/// Video-only back-compat wrapper, used by existing tests. New code flows
1016/// through `build_moov_any` which handles the 1-trak / 2-trak case
1017/// uniformly.
1018#[cfg(test)]
1019fn build_moov(
1020    width: u32,
1021    height: u32,
1022    timescale: u32,
1023    duration: u64,
1024    frame_duration: u32,
1025    sample_sizes: &[u32],
1026    keyframe_indices: &[u32],
1027    config_obus: &[u8],
1028    chunk_offsets: &[u64],
1029    samples_per_chunk: u32,
1030    use_co64: bool,
1031) -> Vec<u8> {
1032    build_moov_any(
1033        width,
1034        height,
1035        timescale,
1036        timescale,
1037        duration,
1038        duration,
1039        frame_duration,
1040        sample_sizes,
1041        keyframe_indices,
1042        config_obus,
1043        chunk_offsets,
1044        samples_per_chunk,
1045        None,
1046        &[],
1047        use_co64,
1048        &ColorMetadata::default(),
1049    )
1050}
1051
1052/// Build moov with video trak plus optional audio trak. `movie_timescale`
1053/// governs mvhd; `video_timescale` is video mdhd's own clock. When audio is
1054/// present we pin both movie and video to the same 90 kHz reference so
1055/// durations don't need a per-trak rate rescale.
1056fn build_moov_any(
1057    width: u32,
1058    height: u32,
1059    video_timescale: u32,
1060    movie_timescale: u32,
1061    movie_duration: u64,
1062    video_duration_in_video_ts: u64,
1063    frame_duration: u32,
1064    sample_sizes: &[u32],
1065    keyframe_indices: &[u32],
1066    config_obus: &[u8],
1067    video_chunk_offsets: &[u64],
1068    video_spc: u32,
1069    audio_plan: Option<&AudioBuildPlan>,
1070    audio_chunk_offsets: &[u64],
1071    use_co64: bool,
1072    color_metadata: &ColorMetadata,
1073) -> Vec<u8> {
1074    // next_track_ID starts at 3 when audio is present (video=1, audio=2).
1075    let next_track_id: u32 = if audio_plan.is_some() { 3 } else { 2 };
1076    let mvhd = build_mvhd_v2(movie_timescale, movie_duration, next_track_id);
1077    // Video track duration expressed in movie timescale.
1078    let video_duration_movie: u64 = if video_timescale == movie_timescale {
1079        video_duration_in_video_ts
1080    } else {
1081        ((video_duration_in_video_ts as u128) * movie_timescale as u128
1082            / video_timescale.max(1) as u128) as u64
1083    };
1084    let video_trak = build_video_trak(
1085        width,
1086        height,
1087        video_timescale,
1088        video_duration_movie,
1089        video_duration_in_video_ts,
1090        frame_duration,
1091        sample_sizes,
1092        keyframe_indices,
1093        config_obus,
1094        video_chunk_offsets,
1095        video_spc,
1096        use_co64,
1097        color_metadata,
1098    );
1099
1100    let mut b = BoxBuilder::new(b"moov");
1101    b.extend(&mvhd);
1102    b.extend(&video_trak);
1103    if let Some(plan) = audio_plan {
1104        let audio_trak = build_audio_trak(
1105            plan,
1106            plan.total_duration_in_movie_ts,
1107            audio_chunk_offsets,
1108            use_co64,
1109        );
1110        b.extend(&audio_trak);
1111    }
1112    b.finish()
1113}
1114
1115/// mvhd v2: takes `next_track_ID`. When audio is present we increment past
1116/// the audio track ID, otherwise past the video track ID (existing
1117/// behaviour: next_track_ID=2). Original `build_mvhd` fed 2 hard-coded.
1118fn build_mvhd_v2(timescale: u32, duration: u64, next_track_id: u32) -> Vec<u8> {
1119    let mut b = BoxBuilder::new(b"mvhd");
1120    b.u8(0); // version
1121    b.extend(&[0, 0, 0]); // flags
1122    b.u32(0); // creation_time
1123    b.u32(0); // modification_time
1124    b.u32(timescale);
1125    b.u32(duration as u32);
1126    b.u32(0x00010000); // rate 1.0
1127    b.u16(0x0100); // volume 1.0
1128    b.u16(0); // reserved
1129    b.u32(0); // reserved
1130    b.u32(0);
1131    write_unity_matrix(&mut b);
1132    for _ in 0..6 {
1133        b.u32(0);
1134    } // pre_defined
1135    b.u32(next_track_id);
1136    b.finish()
1137}
1138
1139/// Video trak builder. `duration_in_movie_ts` goes into tkhd (the movie
1140/// header's clock); `duration_in_mdhd_ts` goes into mdhd (the track's own
1141/// clock). For video the two timescales are currently pinned equal at
1142/// 90 kHz, but the split is kept so the audio path, which has a distinct
1143/// mdhd timescale (= sample_rate), uses the same builder pattern.
1144fn build_video_trak(
1145    width: u32,
1146    height: u32,
1147    mdhd_timescale: u32,
1148    duration_in_movie_ts: u64,
1149    duration_in_mdhd_ts: u64,
1150    frame_duration: u32,
1151    sample_sizes: &[u32],
1152    keyframe_indices: &[u32],
1153    config_obus: &[u8],
1154    chunk_offsets: &[u64],
1155    samples_per_chunk: u32,
1156    use_co64: bool,
1157    color_metadata: &ColorMetadata,
1158) -> Vec<u8> {
1159    let tkhd = build_video_tkhd(width, height, duration_in_movie_ts);
1160    let mdia = build_video_mdia(
1161        width,
1162        height,
1163        mdhd_timescale,
1164        duration_in_mdhd_ts,
1165        frame_duration,
1166        sample_sizes,
1167        keyframe_indices,
1168        config_obus,
1169        chunk_offsets,
1170        samples_per_chunk,
1171        use_co64,
1172        color_metadata,
1173    );
1174
1175    let mut b = BoxBuilder::new(b"trak");
1176    b.extend(&tkhd);
1177    b.extend(&mdia);
1178    b.finish()
1179}
1180
1181fn build_video_tkhd(width: u32, height: u32, duration: u64) -> Vec<u8> {
1182    let mut b = BoxBuilder::new(b"tkhd");
1183    b.u8(0); // version
1184    b.extend(&[0, 0, 0x03]); // flags: track_enabled | track_in_movie
1185    b.u32(0); // creation_time
1186    b.u32(0); // modification_time
1187    b.u32(1); // track_ID
1188    b.u32(0); // reserved
1189    b.u32(duration as u32);
1190    b.u32(0); // reserved
1191    b.u32(0);
1192    b.u16(0); // layer
1193    b.u16(0); // alternate_group
1194    b.u16(0); // volume (0 for video)
1195    b.u16(0); // reserved
1196    write_unity_matrix(&mut b);
1197    b.u32(width << 16); // width as 16.16
1198    b.u32(height << 16);
1199    b.finish()
1200}
1201
1202fn build_video_mdia(
1203    width: u32,
1204    height: u32,
1205    timescale: u32,
1206    duration: u64,
1207    frame_duration: u32,
1208    sample_sizes: &[u32],
1209    keyframe_indices: &[u32],
1210    config_obus: &[u8],
1211    chunk_offsets: &[u64],
1212    samples_per_chunk: u32,
1213    use_co64: bool,
1214    color_metadata: &ColorMetadata,
1215) -> Vec<u8> {
1216    let mdhd = build_mdhd(timescale, duration);
1217    let hdlr = build_video_hdlr();
1218    let minf = build_minf(
1219        width,
1220        height,
1221        frame_duration,
1222        sample_sizes,
1223        keyframe_indices,
1224        config_obus,
1225        chunk_offsets,
1226        samples_per_chunk,
1227        use_co64,
1228        color_metadata,
1229    );
1230
1231    let mut b = BoxBuilder::new(b"mdia");
1232    b.extend(&mdhd);
1233    b.extend(&hdlr);
1234    b.extend(&minf);
1235    b.finish()
1236}
1237
1238fn build_mdhd(timescale: u32, duration: u64) -> Vec<u8> {
1239    let mut b = BoxBuilder::new(b"mdhd");
1240    b.u8(0); // version
1241    b.extend(&[0, 0, 0]); // flags
1242    b.u32(0); // creation_time
1243    b.u32(0); // modification_time
1244    b.u32(timescale);
1245    b.u32(duration as u32);
1246    b.u16(0x55c4); // language 'und'
1247    b.u16(0); // pre_defined
1248    b.finish()
1249}
1250
1251fn build_video_hdlr() -> Vec<u8> {
1252    let mut b = BoxBuilder::new(b"hdlr");
1253    b.u8(0); // version
1254    b.extend(&[0, 0, 0]); // flags
1255    b.u32(0); // pre_defined
1256    b.extend(b"vide"); // handler_type
1257    b.u32(0); // reserved[0]
1258    b.u32(0); // reserved[1]
1259    b.u32(0); // reserved[2]
1260    b.extend(b"VideoHandler\0");
1261    b.finish()
1262}
1263
1264// -------- Audio trak / mdia / minf / stbl / mp4a / esds ----------------
1265// These layers match ISO/IEC 14496-12/14 for an AAC sound track sharing
1266// mdat with the video track. Offsets are supplied by the finalize planner;
1267// the builders just embed them.
1268
1269fn build_audio_trak(
1270    plan: &AudioBuildPlan,
1271    duration_in_movie_ts: u64,
1272    chunk_offsets: &[u64],
1273    use_co64: bool,
1274) -> Vec<u8> {
1275    let tkhd = build_audio_tkhd(duration_in_movie_ts);
1276    let mdia = build_audio_mdia(plan, chunk_offsets, use_co64);
1277
1278    let mut b = BoxBuilder::new(b"trak");
1279    b.extend(&tkhd);
1280    b.extend(&mdia);
1281    b.finish()
1282}
1283
1284fn build_audio_tkhd(duration_in_movie_ts: u64) -> Vec<u8> {
1285    let mut b = BoxBuilder::new(b"tkhd");
1286    b.u8(0); // version
1287    b.extend(&[0, 0, 0x03]); // flags: track_enabled | track_in_movie
1288    b.u32(0); // creation_time
1289    b.u32(0); // modification_time
1290    b.u32(2); // track_ID (audio is track 2)
1291    b.u32(0); // reserved
1292    b.u32(duration_in_movie_ts as u32);
1293    b.u32(0); // reserved
1294    b.u32(0);
1295    b.u16(0); // layer
1296    b.u16(0x0001); // alternate_group (1 for audio; lets players swap tracks within the group)
1297    b.u16(0x0100); // volume 1.0 (audio)
1298    b.u16(0); // reserved
1299    write_unity_matrix(&mut b);
1300    b.u32(0); // width = 0 for audio
1301    b.u32(0); // height = 0 for audio
1302    b.finish()
1303}
1304
1305fn build_audio_mdia(plan: &AudioBuildPlan, chunk_offsets: &[u64], use_co64: bool) -> Vec<u8> {
1306    let mdhd = build_mdhd(plan.info.timescale, plan.total_duration_in_own_ts);
1307    let hdlr = build_audio_hdlr();
1308    let minf = build_audio_minf(plan, chunk_offsets, use_co64);
1309
1310    let mut b = BoxBuilder::new(b"mdia");
1311    b.extend(&mdhd);
1312    b.extend(&hdlr);
1313    b.extend(&minf);
1314    b.finish()
1315}
1316
1317fn build_audio_hdlr() -> Vec<u8> {
1318    let mut b = BoxBuilder::new(b"hdlr");
1319    b.u8(0);
1320    b.extend(&[0, 0, 0]);
1321    b.u32(0); // pre_defined
1322    b.extend(b"soun"); // handler_type
1323    b.u32(0);
1324    b.u32(0);
1325    b.u32(0);
1326    b.extend(b"SoundHandler\0");
1327    b.finish()
1328}
1329
1330fn build_audio_minf(plan: &AudioBuildPlan, chunk_offsets: &[u64], use_co64: bool) -> Vec<u8> {
1331    let smhd = build_smhd();
1332    let dinf = build_dinf();
1333    let stbl = build_audio_stbl(plan, chunk_offsets, use_co64);
1334
1335    let mut b = BoxBuilder::new(b"minf");
1336    b.extend(&smhd);
1337    b.extend(&dinf);
1338    b.extend(&stbl);
1339    b.finish()
1340}
1341
1342fn build_smhd() -> Vec<u8> {
1343    let mut b = BoxBuilder::new(b"smhd");
1344    b.u8(0);
1345    b.extend(&[0, 0, 0]); // flags
1346    b.u16(0); // balance (0 = center)
1347    b.u16(0); // reserved
1348    b.finish()
1349}
1350
1351fn build_audio_stbl(plan: &AudioBuildPlan, chunk_offsets: &[u64], use_co64: bool) -> Vec<u8> {
1352    let stsd = build_audio_stsd(&plan.info);
1353    let stts = build_audio_stts(&plan.durations);
1354    let stsc = build_stsc(plan.sample_sizes.len() as u32, plan.samples_per_chunk);
1355    let stsz = build_stsz(&plan.sample_sizes);
1356    let chunk_offset_box = if use_co64 {
1357        build_co64(chunk_offsets)
1358    } else {
1359        build_stco(chunk_offsets)
1360    };
1361
1362    let mut b = BoxBuilder::new(b"stbl");
1363    b.extend(&stsd);
1364    b.extend(&stts);
1365    b.extend(&stsc);
1366    b.extend(&stsz);
1367    b.extend(&chunk_offset_box);
1368    b.finish()
1369}
1370
1371pub(crate) fn build_audio_stsd(info: &AudioInfo) -> Vec<u8> {
1372    // Dispatch on codec — AAC → mp4a + esds; Opus → Opus + dOps;
1373    // AC-3 → ac-3 + dac3; E-AC-3 → ec-3 + dec3. The AudioSampleEntry
1374    // preamble is shared (same v0 layout per ISO/IEC 14496-12 §8.5.2.2 =
1375    // 36 bytes total before child boxes); only the 4-cc and the
1376    // codec-specific child differ.
1377    let kind = AudioCodecKind::from_codec_tag(&info.codec)
1378        .expect("with_audio gate already validated codec tag");
1379    let entry = match kind {
1380        AudioCodecKind::Aac => build_mp4a(info),
1381        AudioCodecKind::Opus => build_opus_sample_entry(info),
1382        AudioCodecKind::Ac3 => build_ac3_sample_entry(info),
1383        AudioCodecKind::Eac3 => build_ec3_sample_entry(info),
1384    };
1385    let mut b = BoxBuilder::new(b"stsd");
1386    b.u8(0);
1387    b.extend(&[0, 0, 0]);
1388    b.u32(1); // entry_count
1389    b.extend(&entry);
1390    b.finish()
1391}
1392
1393/// AudioSampleEntryV0 per ISO/IEC 14496-12 §8.5.2.2, followed by the esds
1394/// descriptor tree per ISO/IEC 14496-14 / 14496-1 §7.2.6.5.
1395///
1396/// `channelcount` reflects the actual decoded-output channel count as
1397/// surfaced by the demuxer. For HE-AAC v2 PS (1-channel core) the demuxer
1398/// upmixes to 2; for 5.1 / 7.1 the AAC channelConfiguration is passed
1399/// straight through (Squad-25). When channels ≥ 3, an Apple `chan`
1400/// (Channel Layout) box is appended after `esds` so iOS Safari /
1401/// QuickTime / AVFoundation render the correct multichannel layout
1402/// rather than defaulting to L+R downmix.
1403fn build_mp4a(info: &AudioInfo) -> Vec<u8> {
1404    let mut b = BoxBuilder::new(b"mp4a");
1405    // SampleEntry header
1406    for _ in 0..6 {
1407        b.u8(0);
1408    } // reserved[6] = 0
1409    b.u16(1); // data_reference_index
1410    // AudioSampleEntry v0 body
1411    b.u32(0); // reserved (was version + revision_level in v0 QuickTime)
1412    b.u32(0); // reserved (vendor in v0 QuickTime)
1413    b.u16(info.channels); // channel_count (driven by demux)
1414    b.u16(16); // sample_size (bits)
1415    b.u16(0); // pre_defined
1416    b.u16(0); // reserved
1417    b.u32(info.sample_rate << 16); // samplerate 16.16 fixed-point
1418    // esds child (carries the AudioSpecificConfig verbatim)
1419    b.extend(&build_esds(info));
1420    // Apple Channel Layout (`chan`) box for multichannel AAC. Per
1421    // QuickTime File Format Spec §"Channel Layout Box" the box nests
1422    // *inside* the `mp4a` AudioSampleEntry alongside `esds`.
1423    if let Some(chan) = build_chan_box(info.channels) {
1424        b.extend(&chan);
1425    }
1426    b.finish()
1427}
1428
1429/// Apple Channel Layout (`chan`) box for ≥3-channel audio. Per the QuickTime
1430/// File Format Specification, §"Channel Layout Box", and CoreAudioBaseTypes.h
1431/// (`AudioChannelLayout`):
1432///
1433///   - `mChannelLayoutTag` (u32 BE): one of the standard layout tags. The
1434///     low 16 bits carry the channel count and the high 16 bits identify
1435///     the layout. Returned by Apple's `kAudioChannelLayoutTag_*` macros.
1436///   - `mChannelBitmap` (u32 BE) = 0 — only used when the tag is
1437///     `kAudioChannelLayoutTag_UseChannelBitmap`.
1438///   - `mNumberChannelDescriptions` (u32 BE) = 0 — only used when the tag
1439///     is `kAudioChannelLayoutTag_UseChannelDescriptions`.
1440///
1441/// Total payload: 12 bytes. Box size: 20 bytes (8-byte header + 12-byte body).
1442///
1443/// Returns `None` for mono / stereo (Apple defaults to standard mono /
1444/// L+R already, no `chan` box needed). Returns `None` for unsupported
1445/// channel counts — caller's `with_audio` gate already restricts to the
1446/// supported set; this function uses `None` as a defence-in-depth.
1447///
1448/// Standard layouts emitted (channels in this order in the bitstream):
1449///   - 5.1 → `kAudioChannelLayoutTag_MPEG_5_1_C` = `(114 << 16) | 6`
1450///     = `0x00720006`. Channels: L, R, C, LFE, Ls, Rs.
1451///   - 7.1 → `kAudioChannelLayoutTag_MPEG_7_1_C` = `(127 << 16) | 8`
1452///     = `0x007F0008`. Channels: L, R, C, LFE, Ls, Rs, Lc, Rc.
1453///
1454/// 7.1 + Atmos and other extended / object-based layouts are NOT emitted
1455/// here (caller's `with_audio` gate already rejects them). Adding a wrong
1456/// `chan` tag is worse than omitting the box — Apple players would map
1457/// channels to the wrong speakers.
1458pub(crate) fn build_chan_box(channels: u16) -> Option<Vec<u8>> {
1459    let tag: u32 = match channels {
1460        1 | 2 => return None,    // Apple default is correct
1461        6 => (114u32 << 16) | 6, // kAudioChannelLayoutTag_MPEG_5_1_C
1462        7 => (127u32 << 16) | 8, // kAudioChannelLayoutTag_MPEG_7_1_C
1463        _ => return None,        // unsupported (gate already rejected)
1464    };
1465    let mut b = BoxBuilder::new(b"chan");
1466    b.u32(tag); // mChannelLayoutTag
1467    b.u32(0); // mChannelBitmap
1468    b.u32(0); // mNumberChannelDescriptions
1469    Some(b.finish())
1470}
1471
1472/// `Opus` sample entry per RFC 7845 §4.4. Same generic AudioSampleEntry v0
1473/// layout as `mp4a` (per ISO/IEC 14496-12 §8.5.2.2) followed by the
1474/// Opus-Specific Box `dOps`.
1475///
1476/// 4-cc is `Opus` exactly — capital O lowercase pus, that spelling is
1477/// load-bearing per RFC 7845 §4.4 ("the four-character code shall be set
1478/// to 'Opus'"). Lowercase variants like `opus` will be rejected by
1479/// strict players (e.g. macOS / iOS AVFoundation).
1480///
1481/// `samplerate` field at the AudioSampleEntry level is set to
1482/// 48000 << 16 (16.16 fixed-point form of 48000) to match the
1483/// `InputSampleRate` we emit inside dOps. Apple's AVFoundation reads this
1484/// field; storing the source's nominal rate (e.g. 44100) would mismatch
1485/// the dOps body and confuse strict validators.
1486///
1487/// `channelcount` carries the actual decoded output channel count
1488/// (matches `OutputChannelCount` in dOps for ChannelMappingFamily=0).
1489fn build_opus_sample_entry(info: &AudioInfo) -> Vec<u8> {
1490    // RFC 7845 §4.4: 4-cc is exactly 'Opus' (capital O).
1491    let mut b = BoxBuilder::new(b"Opus");
1492    // SampleEntry header
1493    for _ in 0..6 {
1494        b.u8(0);
1495    } // reserved[6] = 0
1496    b.u16(1); // data_reference_index
1497    // AudioSampleEntry v0 body
1498    b.u32(0); // reserved
1499    b.u32(0); // reserved
1500    b.u16(info.channels); // channel_count
1501    b.u16(16); // sample_size (bits) — informational for Opus
1502    b.u16(0); // pre_defined
1503    b.u16(0); // reserved
1504    // Opus is internally always 48 kHz (RFC 6716). The sample-entry
1505    // samplerate is the playback / mdhd-aligned rate. Pin to 48000 << 16.
1506    b.u32(48_000u32 << 16); // samplerate 16.16 fixed-point = 48000
1507    // dOps child
1508    b.extend(&build_dops(info));
1509    b.finish()
1510}
1511
1512/// `dOps` Opus-Specific Box per RFC 7845 §4.5.
1513///
1514/// Body layout (11 bytes minimum for ChannelMappingFamily=0):
1515///   - `Version` u8 = 0
1516///   - `OutputChannelCount` u8
1517///   - `PreSkip` u16 BE
1518///   - `InputSampleRate` u32 BE
1519///   - `OutputGain` i16 BE (Q8 dB; 0 = no gain)
1520///   - `ChannelMappingFamily` u8
1521///   - (when family != 0: StreamCount u8 + CoupledCount u8 + ChannelMapping[N])
1522///
1523/// Byte-order conversion: the source `codec_private` carries the OpusHead
1524/// body in **Ogg / WebM little-endian** convention (PreSkip / InputSampleRate
1525/// / OutputGain are LE) — that's what falls out of WebM/MKV `CodecPrivate`
1526/// directly, and what an Opus encoder library (libopusenc) emits when
1527/// asked for OpusHead. RFC 7845 §4.5 mandates **big-endian** for the same
1528/// fields inside `dOps`. We translate field-by-field rather than copying
1529/// bytes verbatim.
1530///
1531/// `Version`: OpusHead carries Version=1 (its own encoding); RFC 7845 §4.5
1532/// requires Version=0 in dOps (this is THE box version, not the Opus
1533/// stream version). We force-write 0 here regardless of what the input
1534/// `codec_private[0]` says.
1535fn build_dops(info: &AudioInfo) -> Vec<u8> {
1536    let p = &info.codec_private;
1537    debug_assert!(
1538        p.len() >= 11,
1539        "with_audio gate must enforce dOps minimum size"
1540    );
1541
1542    // OpusHead → dOps numeric field translation.
1543    // Layout of input bytes (OpusHead, after the 8-byte 'OpusHead' magic
1544    // which the demuxer already strips):
1545    //   [0]    Version (u8) — OpusHead version, NOT the dOps version
1546    //   [1]    OutputChannelCount (u8)
1547    //   [2..4] PreSkip (u16 LE)
1548    //   [4..8] InputSampleRate (u32 LE)
1549    //   [8..10] OutputGain (i16 LE, Q8 dB)
1550    //   [10]   ChannelMappingFamily (u8)
1551    //   // Family != 0 trailer (Squad-28, RFC 7845 §5.1.1):
1552    //   [11]   StreamCount (u8)
1553    //   [12]   CoupledCount (u8)
1554    //   [13..13+N]  ChannelMapping (u8 per output channel)
1555    let output_channels = p[1];
1556    let pre_skip = u16::from_le_bytes([p[2], p[3]]);
1557    let input_sample_rate = u32::from_le_bytes([p[4], p[5], p[6], p[7]]);
1558    let output_gain = i16::from_le_bytes([p[8], p[9]]);
1559    let channel_mapping_family = p[10];
1560
1561    let mut b = BoxBuilder::new(b"dOps");
1562    b.u8(0); // Version (RFC 7845 §4.5: MUST be 0)
1563    b.u8(output_channels); // OutputChannelCount
1564    b.u16(pre_skip); // PreSkip (BE — was LE in OpusHead)
1565    b.u32(input_sample_rate); // InputSampleRate (BE)
1566    // i16 output gain → wire u16 BE (two's complement preserved across the cast).
1567    b.u16(output_gain as u16); // OutputGain (BE Q8)
1568    b.u8(channel_mapping_family); // ChannelMappingFamily
1569
1570    // ChannelMappingFamily != 0 → ChannelMappingTable follows
1571    // (RFC 7845 §5.1.1). For family 1 (Squad-28 multichannel) the
1572    // table is StreamCount + CoupledCount + ChannelMapping[N]. The
1573    // encoder packed these immediately after the 11-byte preamble in
1574    // its `extra_data()` output, so the demuxed `codec_private` buffer
1575    // already carries them in the correct order — we copy verbatim
1576    // (no endianness conversion: u8 fields).
1577    if channel_mapping_family != 0 {
1578        // with_audio's family-1 validation gate ensured codec_private
1579        // has the trailing bytes; this assert is just forward protection
1580        // against a future caller bypassing the gate.
1581        let trailer_len = 2 + output_channels as usize;
1582        debug_assert!(
1583            p.len() >= 11 + trailer_len,
1584            "family={channel_mapping_family} requires {trailer_len} more bytes after the 11-byte preamble; codec_private has {}",
1585            p.len()
1586        );
1587        b.u8(p[11]); // StreamCount
1588        b.u8(p[12]); // CoupledCount
1589        for i in 0..output_channels as usize {
1590            b.u8(p[13 + i]); // ChannelMapping[i]
1591        }
1592    }
1593
1594    b.finish()
1595}
1596
1597// ---- Squad-26: AC-3 / E-AC-3 sample entries + dac3 / dec3 boxes ----------
1598//
1599// Per ETSI TS 102 366 v1.4.1 Annex F:
1600//   §F.2 — AC-3 in MP4 / 3GP: 4cc 'ac-3' AudioSampleEntry + 'dac3' config box.
1601//   §F.4 — `dac3` body layout (3 bytes total payload, 11-byte total box):
1602//     fscod         2 bits   (0=48k 1=44.1k 2=32k)
1603//     bsid          5 bits   (=8 for AC-3 — verified from sync header)
1604//     bsmod         3 bits
1605//     acmod         3 bits
1606//     lfeon         1 bit
1607//     bit_rate_code 5 bits
1608//     reserved      5 bits   = 0
1609//   §F.5 — E-AC-3: 4cc 'ec-3' + 'dec3' config box.
1610//   §F.6 — `dec3` body: data_rate (13b) + num_ind_sub-1 (3b) followed by
1611//     N independent-substream descriptors (3 bytes each, plus 9-bit
1612//     chan_loc when num_dep_sub>0). Squad-26 emits the single-substream
1613//     case (5 bytes total payload, 13-byte box).
1614//
1615// Squad-26 hard-restricts to:
1616//   - AC-3 5.1 / stereo / mono (acmod 1, 2, 7 with optional LFE)
1617//   - E-AC-3 single independent substream (num_ind_sub=0 wire encoding,
1618//     num_dep_sub=0). Vanilla 5.1 is the dominant case in the wild.
1619
1620/// `ac-3` AudioSampleEntry per ETSI TS 102 366 §F.2. Same generic
1621/// AudioSampleEntry v0 layout (per ISO/IEC 14496-12 §8.5.2.2) as `mp4a` /
1622/// `Opus` — 28-byte fixed body after the box header — followed by the
1623/// `dac3` Config Box.
1624///
1625/// 4cc is `ac-3` exactly (with the hyphen, ASCII bytes 0x61 0x63 0x2D
1626/// 0x33). NOT `ac3` — strict players reject the dehyphenated form.
1627///
1628/// `samplerate` field at the AudioSampleEntry level is set to
1629/// `info.sample_rate << 16`. AC-3 samples are 32 / 44.1 / 48 kHz.
1630///
1631/// `channelcount` carries the actual decoded output channel count
1632/// (acmod-derived) — informational; players use the dac3 body for the
1633/// authoritative channel layout.
1634fn build_ac3_sample_entry(info: &AudioInfo) -> Vec<u8> {
1635    let mut b = BoxBuilder::new(b"ac-3");
1636    // SampleEntry header
1637    for _ in 0..6 {
1638        b.u8(0);
1639    } // reserved[6] = 0
1640    b.u16(1); // data_reference_index
1641    // AudioSampleEntry v0 body
1642    b.u32(0); // reserved
1643    b.u32(0); // reserved
1644    b.u16(info.channels); // channel_count (informational)
1645    b.u16(16); // sample_size (bits) — informational
1646    b.u16(0); // pre_defined
1647    b.u16(0); // reserved
1648    b.u32(info.sample_rate << 16); // samplerate 16.16 fixed-point
1649    b.extend(&build_dac3(info)); // dac3 child
1650    b.finish()
1651}
1652
1653/// `ec-3` AudioSampleEntry per ETSI TS 102 366 §F.5. Mirrors `ac-3` with a
1654/// different 4cc and a `dec3` (rather than `dac3`) child config box.
1655fn build_ec3_sample_entry(info: &AudioInfo) -> Vec<u8> {
1656    let mut b = BoxBuilder::new(b"ec-3");
1657    for _ in 0..6 {
1658        b.u8(0);
1659    }
1660    b.u16(1);
1661    b.u32(0);
1662    b.u32(0);
1663    b.u16(info.channels);
1664    b.u16(16);
1665    b.u16(0);
1666    b.u16(0);
1667    b.u32(info.sample_rate << 16);
1668    b.extend(&build_dec3(info));
1669    b.finish()
1670}
1671
1672/// `dac3` AC-3 Config Box per ETSI TS 102 366 §F.4. Box header is 8 bytes;
1673/// payload is exactly 3 bytes (24 bits packed MSB-first). Total = 11 bytes.
1674///
1675/// Bit layout (all MSB-first within the 3-byte payload):
1676/// ```text
1677///   bit  0..2   fscod          (2 bits)
1678///   bit  2..7   bsid           (5 bits)
1679///   bit  7..10  bsmod          (3 bits)
1680///   bit 10..13  acmod          (3 bits)
1681///   bit 13..14  lfeon          (1 bit)
1682///   bit 14..19  bit_rate_code  (5 bits)
1683///   bit 19..24  reserved       (5 bits, must be 0)
1684/// ```
1685///
1686/// The 3 payload bytes carried in `info.codec_private` are emitted verbatim
1687/// — the demuxer side already serialised them per the spec, so this builder
1688/// is a thin wrapper. The 3-byte length contract is checked by `with_audio`.
1689fn build_dac3(info: &AudioInfo) -> Vec<u8> {
1690    debug_assert_eq!(
1691        info.codec_private.len(),
1692        3,
1693        "with_audio gate must enforce dac3 body == 3 bytes"
1694    );
1695    let mut b = BoxBuilder::new(b"dac3");
1696    b.extend(&info.codec_private);
1697    b.finish()
1698}
1699
1700/// `dec3` E-AC-3 Config Box per ETSI TS 102 366 §F.6. Box header is 8 bytes;
1701/// payload is variable size depending on independent / dependent substream
1702/// count. For the single-independent-substream / no-dependent-substream
1703/// case (Squad-26's scope) the payload is 5 bytes:
1704///
1705/// ```text
1706///   bit  0..13   data_rate          (13 bits, kbps / 2)
1707///   bit 13..16   num_ind_sub - 1    (3 bits — 0 = 1 substream)
1708///   per independent substream:
1709///     bit 0..2    fscod            (2 bits)
1710///     bit 2..7    bsid             (5 bits, =16 for E-AC-3)
1711///     bit 7..8    reserved         (1 bit, =0)
1712///     bit 8..9    asvc             (1 bit)
1713///     bit 9..12   bsmod            (3 bits)
1714///     bit 12..15  acmod            (3 bits)
1715///     bit 15..16  lfeon            (1 bit)
1716///     bit 16..19  reserved         (3 bits, =0)
1717///     bit 19..23  num_dep_sub      (4 bits, =0 in Squad-26 scope)
1718///     // (if num_dep_sub > 0: chan_loc 9 bits — not emitted here)
1719/// ```
1720///
1721/// The body is carried in `info.codec_private` and emitted verbatim;
1722/// `with_audio` validates length ≥ 5. Demuxer-side construction of these
1723/// bytes happens in `demux::derive_dec3_from_eac3_sync`.
1724fn build_dec3(info: &AudioInfo) -> Vec<u8> {
1725    debug_assert!(
1726        info.codec_private.len() >= 5,
1727        "with_audio gate must enforce dec3 body >= 5 bytes"
1728    );
1729    let mut b = BoxBuilder::new(b"dec3");
1730    b.extend(&info.codec_private);
1731    b.finish()
1732}
1733
1734/// Construct the 3-byte `dac3` body from a parsed AC-3 sync header. Used
1735/// by the demuxer (derive from first frame) and by tests.
1736///
1737/// Bit layout per ETSI TS 102 366 §F.4 (fscod 2 | bsid 5 | bsmod 3 |
1738/// acmod 3 | lfeon 1 | bit_rate_code 5 | reserved 5).
1739pub fn dac3_body_from_sync(s: &crate::ac3_sync::Ac3SyncInfo) -> [u8; 3] {
1740    let mut bw = MsbBitWriter::new();
1741    bw.put(2, s.fscod as u32);
1742    bw.put(5, s.bsid as u32);
1743    bw.put(3, s.bsmod as u32);
1744    bw.put(3, s.acmod as u32);
1745    bw.put(1, if s.lfeon { 1 } else { 0 });
1746    bw.put(5, s.bit_rate_code as u32);
1747    bw.put(5, 0); // reserved
1748    let bytes = bw.finish();
1749    // Exactly 24 bits = 3 bytes (compile-time invariant of the layout).
1750    [bytes[0], bytes[1], bytes[2]]
1751}
1752
1753/// Construct the 5-byte single-substream `dec3` body from a parsed E-AC-3
1754/// sync header. Used by the demuxer (derive from first frame) and by tests.
1755///
1756/// `data_rate` is the source-frame nominal kbps / 2 per §F.6. Compute it
1757/// from the source: `data_rate = ceil((frame_size_bytes * 8 * sample_rate /
1758/// samples_per_frame) / 2 / 1000)`. We accept it as a parameter so the
1759/// caller can supply either the frame-derived value or a stored/best-known
1760/// value; for vanilla 5.1 48 kHz E-AC-3 at 384 kbps this is 192.
1761pub fn dec3_body_from_sync(s: &crate::ac3_sync::Eac3SyncInfo, data_rate_div2_kbps: u16) -> [u8; 5] {
1762    let mut bw = MsbBitWriter::new();
1763    // Header: data_rate (13b) + num_ind_sub - 1 (3b). num_ind_sub = 1 in
1764    // Squad-26's scope, so the wire field is 0.
1765    bw.put(13, (data_rate_div2_kbps & 0x1FFF) as u32);
1766    bw.put(3, 0); // num_ind_sub - 1 = 0
1767    // Per-independent-substream block (3 bytes for the no-dep-sub case).
1768    bw.put(2, s.fscod as u32);
1769    bw.put(5, 16); // bsid pinned to 16 per §F.6
1770    bw.put(1, 0); // reserved
1771    bw.put(1, 0); // asvc — Squad-26 doesn't carry alternate-stream signalling
1772    bw.put(3, s.bsmod as u32);
1773    bw.put(3, s.acmod as u32);
1774    bw.put(1, if s.lfeon { 1 } else { 0 });
1775    bw.put(3, 0); // reserved
1776    bw.put(4, 0); // num_dep_sub = 0 (Squad-26 scope)
1777    let bytes = bw.finish();
1778    debug_assert_eq!(bytes.len(), 5, "dec3 single-substream body must be 5 bytes");
1779    [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4]]
1780}
1781
1782/// MSB-first bit writer used to pack the dac3 / dec3 bodies. Keeps layout
1783/// math local to the box builders so the bit boundaries stay obvious in
1784/// review.
1785struct MsbBitWriter {
1786    bytes: Vec<u8>,
1787    bit_pos: usize,
1788}
1789
1790impl MsbBitWriter {
1791    fn new() -> Self {
1792        Self {
1793            bytes: Vec::new(),
1794            bit_pos: 0,
1795        }
1796    }
1797    fn put(&mut self, n: usize, v: u32) {
1798        debug_assert!(n <= 24);
1799        for i in (0..n).rev() {
1800            let bit = ((v >> i) & 0x01) as u8;
1801            if self.bit_pos.is_multiple_of(8) {
1802                self.bytes.push(0);
1803            }
1804            let byte_idx = self.bit_pos / 8;
1805            let bit_idx = 7 - (self.bit_pos % 8);
1806            self.bytes[byte_idx] |= bit << bit_idx;
1807            self.bit_pos += 1;
1808        }
1809    }
1810    fn finish(self) -> Vec<u8> {
1811        self.bytes
1812    }
1813}
1814
1815/// Emit `esds` box = FullBox(v=0 f=0) + ES_Descriptor tree per 14496-1.
1816/// See task spec for layout — we materialise each child into a temp Vec
1817/// first to compute exact lengths, then wrap the parent descriptors in
1818/// variable-length headers via `write_descriptor_length`.
1819fn build_esds(info: &AudioInfo) -> Vec<u8> {
1820    // Innermost: DecoderSpecificInfo (tag 0x05) payload = ASC bytes verbatim.
1821    let asc_len = info.asc_bytes.len() as u32;
1822    let mut dsi = Vec::new();
1823    dsi.push(0x05u8);
1824    write_descriptor_length(&mut dsi, asc_len);
1825    dsi.extend_from_slice(&info.asc_bytes);
1826
1827    // DecoderConfigDescriptor (tag 0x04): 13-byte fixed preamble + DSI.
1828    // Fields:
1829    //   objectTypeIndication u8 = 0x40 (MPEG-4 Audio)
1830    //   streamType u6 | upStream u1 | reserved u1 => (0x05 << 2) | 0x01 = 0x15
1831    //   bufferSizeDB u24 = 0
1832    //   maxBitrate u32 = 0
1833    //   avgBitrate u32 = 0
1834    let mut dcd_payload = Vec::new();
1835    dcd_payload.push(0x40); // AAC / MPEG-4 Audio
1836    dcd_payload.push((0x05 << 2) | 0x01); // AudioStream | upstream=1
1837    dcd_payload.extend_from_slice(&[0, 0, 0]); // bufferSizeDB
1838    dcd_payload.extend_from_slice(&0u32.to_be_bytes()); // maxBitrate
1839    dcd_payload.extend_from_slice(&0u32.to_be_bytes()); // avgBitrate
1840    dcd_payload.extend_from_slice(&dsi);
1841    let mut dcd = Vec::new();
1842    dcd.push(0x04);
1843    write_descriptor_length(&mut dcd, dcd_payload.len() as u32);
1844    dcd.extend_from_slice(&dcd_payload);
1845
1846    // SLConfigDescriptor (tag 0x06): one byte payload = predefined=2 (MP4 reserved).
1847    let mut slc = Vec::new();
1848    slc.push(0x06);
1849    write_descriptor_length(&mut slc, 1);
1850    slc.push(0x02);
1851
1852    // ES_Descriptor (tag 0x03): ES_ID u16=0 + flags u8=0 + DCD + SLC.
1853    let mut es_payload = Vec::new();
1854    es_payload.extend_from_slice(&0u16.to_be_bytes()); // ES_ID
1855    es_payload.push(0); // flags
1856    es_payload.extend_from_slice(&dcd);
1857    es_payload.extend_from_slice(&slc);
1858    let mut es = Vec::new();
1859    es.push(0x03);
1860    write_descriptor_length(&mut es, es_payload.len() as u32);
1861    es.extend_from_slice(&es_payload);
1862
1863    // FullBox(0)
1864    let mut b = BoxBuilder::new(b"esds");
1865    b.u8(0);
1866    b.extend(&[0, 0, 0]);
1867    b.extend(&es);
1868    b.finish()
1869}
1870
1871/// Write a variable-length MPEG-4 descriptor length field. For len < 128
1872/// emits a single byte. For larger values emits a 4-byte continuation
1873/// sequence per ISO/IEC 14496-1 (high bit set on every byte but the last,
1874/// low 7 bits carry 7 bits of the length MSB-first).
1875///
1876/// Historical note: the `read_descriptor` peer in demux.rs caps at 4 bytes
1877/// of continuation, so we use 4 bytes consistently on the write side above
1878/// the 128 threshold — this keeps round-trip compatibility with our own
1879/// demuxer and is what ffmpeg / mp4box emit.
1880fn write_descriptor_length(buf: &mut Vec<u8>, len: u32) {
1881    if len < 128 {
1882        buf.push(len as u8);
1883        return;
1884    }
1885    buf.push(((len >> 21) & 0x7F) as u8 | 0x80);
1886    buf.push(((len >> 14) & 0x7F) as u8 | 0x80);
1887    buf.push(((len >> 7) & 0x7F) as u8 | 0x80);
1888    buf.push((len & 0x7F) as u8);
1889}
1890
1891/// Audio stts: one entry per run of samples with identical durations.
1892/// AAC typically has uniform 1024-sample frames so this collapses to a
1893/// single (count, delta) entry, but we handle runs defensively — some
1894/// demuxed streams have a shorter tail sample.
1895fn build_audio_stts(durations: &[u32]) -> Vec<u8> {
1896    let mut b = BoxBuilder::new(b"stts");
1897    b.u8(0);
1898    b.extend(&[0, 0, 0]);
1899    // First pass: count runs.
1900    let mut runs: Vec<(u32, u32)> = Vec::new();
1901    for &d in durations {
1902        if let Some(last) = runs.last_mut()
1903            && last.1 == d
1904        {
1905            last.0 += 1;
1906            continue;
1907        }
1908        runs.push((1, d));
1909    }
1910    b.u32(runs.len() as u32);
1911    for (count, delta) in runs {
1912        b.u32(count);
1913        b.u32(delta);
1914    }
1915    b.finish()
1916}
1917
1918fn build_minf(
1919    width: u32,
1920    height: u32,
1921    frame_duration: u32,
1922    sample_sizes: &[u32],
1923    keyframe_indices: &[u32],
1924    config_obus: &[u8],
1925    chunk_offsets: &[u64],
1926    samples_per_chunk: u32,
1927    use_co64: bool,
1928    color_metadata: &ColorMetadata,
1929) -> Vec<u8> {
1930    let vmhd = build_vmhd();
1931    let dinf = build_dinf();
1932    let stbl = build_stbl(
1933        width,
1934        height,
1935        frame_duration,
1936        sample_sizes,
1937        keyframe_indices,
1938        config_obus,
1939        chunk_offsets,
1940        samples_per_chunk,
1941        use_co64,
1942        color_metadata,
1943    );
1944
1945    let mut b = BoxBuilder::new(b"minf");
1946    b.extend(&vmhd);
1947    b.extend(&dinf);
1948    b.extend(&stbl);
1949    b.finish()
1950}
1951
1952fn build_vmhd() -> Vec<u8> {
1953    let mut b = BoxBuilder::new(b"vmhd");
1954    b.u8(0);
1955    b.extend(&[0, 0, 0x01]); // flags (always 1)
1956    b.u16(0); // graphicsmode
1957    b.u16(0);
1958    b.u16(0);
1959    b.u16(0); // opcolor
1960    b.finish()
1961}
1962
1963fn build_dinf() -> Vec<u8> {
1964    let mut dref = BoxBuilder::new(b"dref");
1965    dref.u8(0);
1966    dref.extend(&[0, 0, 0]);
1967    dref.u32(1); // entry_count
1968    let mut url = BoxBuilder::new(b"url ");
1969    url.u8(0);
1970    url.extend(&[0, 0, 0x01]); // self-contained
1971    dref.extend(&url.finish());
1972
1973    let mut b = BoxBuilder::new(b"dinf");
1974    b.extend(&dref.finish());
1975    b.finish()
1976}
1977
1978fn build_stbl(
1979    width: u32,
1980    height: u32,
1981    frame_duration: u32,
1982    sample_sizes: &[u32],
1983    keyframe_indices: &[u32],
1984    config_obus: &[u8],
1985    chunk_offsets: &[u64],
1986    samples_per_chunk: u32,
1987    use_co64: bool,
1988    color_metadata: &ColorMetadata,
1989) -> Vec<u8> {
1990    let stsd = build_stsd(width, height, config_obus, color_metadata);
1991    let stts = build_stts(sample_sizes.len() as u32, frame_duration);
1992    let stsc = build_stsc(sample_sizes.len() as u32, samples_per_chunk);
1993    let stsz = build_stsz(sample_sizes);
1994    let chunk_offset_box = if use_co64 {
1995        build_co64(chunk_offsets)
1996    } else {
1997        build_stco(chunk_offsets)
1998    };
1999    let stss_box = if !keyframe_indices.is_empty() && keyframe_indices.len() < sample_sizes.len() {
2000        Some(build_stss(keyframe_indices))
2001    } else {
2002        None
2003    };
2004
2005    let mut b = BoxBuilder::new(b"stbl");
2006    b.extend(&stsd);
2007    b.extend(&stts);
2008    if let Some(ss) = &stss_box {
2009        b.extend(ss);
2010    }
2011    b.extend(&stsc);
2012    b.extend(&stsz);
2013    b.extend(&chunk_offset_box);
2014    b.finish()
2015}
2016
2017fn build_stsd(
2018    width: u32,
2019    height: u32,
2020    config_obus: &[u8],
2021    color_metadata: &ColorMetadata,
2022) -> Vec<u8> {
2023    let av01 = build_av01(width, height, config_obus, color_metadata);
2024    let mut b = BoxBuilder::new(b"stsd");
2025    b.u8(0);
2026    b.extend(&[0, 0, 0]); // flags
2027    b.u32(1); // entry_count
2028    b.extend(&av01);
2029    b.finish()
2030}
2031
2032/// AV1 visual sample entry per AV1-ISOBMFF v1.3.0 §2.2. Fourcc is `av01`
2033/// — there is no `hvc1`/`hev1`-style variant for AV1; the configOBU
2034/// transport mode is selected via flags inside `av1C` itself, not via a
2035/// separate sample entry name.
2036///
2037/// Children, in order:
2038/// 1. `av1C` — AV1CodecConfigurationRecord (REQUIRED).
2039/// 2. `colr` — nclx triple + full_range (REQUIRED for Apple, Squad-18).
2040/// 3. `mdcv` — Mastering Display Color Volume (HDR only, Squad-20).
2041/// 4. `clli` — Content Light Level Info (HDR only, Squad-20).
2042///
2043/// The HDR atoms `mdcv` and `clli` are emitted only when
2044/// `ColorMetadata.mastering_display` / `.content_light_level` are
2045/// `Some(_)`. AV1-ISOBMFF v1.3.0 §2.3.4 + §2.3.5 specify the order
2046/// `colr → mdcv → clli` inside the visual sample entry; players that
2047/// scan for `mdcv` / `clli` (browsers via Media Capabilities API,
2048/// AVFoundation) read the box-tree by 4cc, so order is recommended
2049/// but not load-bearing — we match the spec anyway.
2050pub(crate) fn build_av01(
2051    width: u32,
2052    height: u32,
2053    config_obus: &[u8],
2054    color_metadata: &ColorMetadata,
2055) -> Vec<u8> {
2056    let av1c = build_av1c(config_obus);
2057    let colr = build_colr_nclx(color_metadata);
2058    let mdcv = color_metadata.mastering_display.as_ref().map(build_mdcv);
2059    let clli = color_metadata.content_light_level.as_ref().map(build_clli);
2060    let mut b = BoxBuilder::new(b"av01");
2061    // VisualSampleEntry
2062    for _ in 0..6 {
2063        b.u8(0);
2064    } // reserved[6]
2065    b.u16(1); // data_reference_index
2066    b.u16(0); // pre_defined
2067    b.u16(0); // reserved
2068    for _ in 0..3 {
2069        b.u32(0);
2070    } // pre_defined[3]
2071    b.u16(width as u16);
2072    b.u16(height as u16);
2073    b.u32(0x00480000); // horiz 72 dpi
2074    b.u32(0x00480000); // vert 72 dpi
2075    b.u32(0); // reserved
2076    b.u16(1); // frame_count (frames per sample)
2077    // compressorname: 1 length byte + 31 bytes
2078    b.u8(0);
2079    for _ in 0..31 {
2080        b.u8(0);
2081    }
2082    b.u16(0x0018); // depth
2083    b.u16(0xFFFF); // pre_defined
2084    b.extend(&av1c);
2085    b.extend(&colr);
2086    if let Some(mdcv) = &mdcv {
2087        b.extend(mdcv);
2088    }
2089    if let Some(clli) = &clli {
2090        b.extend(clli);
2091    }
2092    b.finish()
2093}
2094
2095/// Map the pipeline's `TransferFn` enum back into an H.273
2096/// `transfer_characteristics` u8 for the `colr nclx` writer. The
2097/// pipeline's enum is lossy — `Bt709` covers H.273 codes 1, 6, 14, 15 —
2098/// so we collapse to the canonical code (1 = BT.709) for the SDR family
2099/// and the spec-defined codes for the HDR transfers.
2100fn transfer_to_h273(transfer: codec::frame::TransferFn) -> u8 {
2101    use codec::frame::TransferFn;
2102    match transfer {
2103        TransferFn::Bt709 => 1,
2104        TransferFn::Bt470Bg => 4,
2105        TransferFn::Linear => 8,
2106        TransferFn::St2084 => 16,
2107        TransferFn::AribStdB67 => 18,
2108        // H.273 reserves 2 for "unspecified". Apple's player treats
2109        // unspecified as BT.709 limited, which is what the rest of this
2110        // code already assumes — so there's no behaviour change between
2111        // emitting 2 and emitting 1 here. Emit 2 to stay honest about
2112        // what the source told us.
2113        TransferFn::Unspecified => 2,
2114    }
2115}
2116
2117/// Emit a `colr` box with `colour_type='nclx'` per ISO/IEC 14496-12 §12.1.5
2118/// and ICC's nclx subtype definition. Layout:
2119///
2120///   size u32 | 'colr' | colour_type[4] | colour_primaries u16
2121///   | transfer_characteristics u16 | matrix_coefficients u16
2122///   | full_range_flag(1) + reserved(7)
2123///
2124/// `nclx` is the right colour_type for video distribution (vs `nclc`
2125/// which is QuickTime-flavored or `rICC`/`prof` for embedded ICC
2126/// profiles). Apple's player and ffmpeg both honour it.
2127fn build_colr_nclx(color_metadata: &ColorMetadata) -> Vec<u8> {
2128    let mut b = BoxBuilder::new(b"colr");
2129    b.extend(b"nclx");
2130    b.u16(color_metadata.colour_primaries as u16);
2131    b.u16(transfer_to_h273(color_metadata.transfer) as u16);
2132    b.u16(color_metadata.matrix_coefficients as u16);
2133    // full_range_flag is the high bit of a single packed byte; the low 7
2134    // bits are reserved-zero per ISO 23001-8.
2135    let full_range_byte: u8 = if color_metadata.full_range {
2136        0x80
2137    } else {
2138        0x00
2139    };
2140    b.u8(full_range_byte);
2141    b.finish()
2142}
2143
2144/// Emit a `mdcv` (Mastering Display Color Volume) box per ISO/IEC
2145/// 14496-12 §12.1.6 / AV1-ISOBMFF v1.3.0 §2.3.4. Carries SMPTE ST 2086
2146/// metadata. Layout:
2147///
2148///   size u32 (=32) | 'mdcv' | display_primaries_R_x u16 | _R_y u16
2149///   | _G_x u16 | _G_y u16 | _B_x u16 | _B_y u16
2150///   | white_point_x u16 | white_point_y u16
2151///   | max_display_mastering_luminance u32
2152///   | min_display_mastering_luminance u32
2153///
2154/// Total payload = 8×2 + 2×4 = 24 bytes; with 8-byte header → 32 bytes.
2155///
2156/// Box type is `'mdcv'` per AV1-ISOBMFF / 14496-12 v6, NOT the older
2157/// `'SmDm'` from QuickTime-flavored MOV. Browsers + AVFoundation read
2158/// `'mdcv'`. The byte order is the standard u16/u32 BE everything else
2159/// in the file uses.
2160///
2161/// Field encoding follows HEVC SEI 137 (`mastering_display_colour_volume`):
2162///   - Chromaticities are u16 in increments of 0.00002 (so a value of
2163///     35400 ↔ x=0.708, the BT.2020 red primary).
2164///   - Luminances are u32 in increments of 0.0001 cd/m² (so 10_000_000
2165///     ↔ 1000 nits, the canonical HDR10 max).
2166///
2167/// We do not normalize/clamp here — the input struct carries spec-domain
2168/// integers already (Squad-21's probe is responsible for that conversion
2169/// from float chromaticities / nits).
2170fn build_mdcv(md: &codec::frame::MasteringDisplay) -> Vec<u8> {
2171    let mut b = BoxBuilder::new(b"mdcv");
2172    b.u16(md.primaries_r_x);
2173    b.u16(md.primaries_r_y);
2174    b.u16(md.primaries_g_x);
2175    b.u16(md.primaries_g_y);
2176    b.u16(md.primaries_b_x);
2177    b.u16(md.primaries_b_y);
2178    b.u16(md.white_point_x);
2179    b.u16(md.white_point_y);
2180    b.u32(md.max_luminance);
2181    b.u32(md.min_luminance);
2182    b.finish()
2183}
2184
2185/// Emit a `clli` (Content Light Level Information) box per ISO/IEC
2186/// 14496-12 §12.1.6 / AV1-ISOBMFF v1.3.0 §2.3.5. Carries CTA-861.3
2187/// metadata. Layout:
2188///
2189///   size u32 (=12) | 'clli' | max_content_light_level u16
2190///   | max_pic_average_light_level u16
2191///
2192/// Total payload = 4 bytes; with 8-byte header → 12 bytes.
2193///
2194/// Box type is `'clli'`, NOT `'CoLL'` (the older MOV variant). Both
2195/// fields are integer cd/m² (nits); MaxCLL is the peak pixel anywhere
2196/// in the stream, MaxFALL is the peak frame-average. The HDR10
2197/// reference values are typically MaxCLL ≈ 1000 nits / MaxFALL ≈
2198/// 400 nits, but we write whatever the source declared verbatim.
2199fn build_clli(cll: &codec::frame::ContentLightLevel) -> Vec<u8> {
2200    let mut b = BoxBuilder::new(b"clli");
2201    b.u16(cll.max_cll);
2202    b.u16(cll.max_fall);
2203    b.finish()
2204}
2205
2206fn build_av1c(config_obus: &[u8]) -> Vec<u8> {
2207    let mut b = BoxBuilder::new(b"av1C");
2208    // marker=1, version=1 -> 0x81
2209    b.u8(0x81);
2210    // seq_profile=0, seq_level_idx_0=0 (default; parse from OBU if present)
2211    let (
2212        seq_profile,
2213        seq_level_idx_0,
2214        seq_tier_0,
2215        high_bitdepth,
2216        twelve_bit,
2217        monochrome,
2218        chroma_sub_x,
2219        chroma_sub_y,
2220        chroma_sample_position,
2221    ) = parse_seq_header_params(config_obus);
2222    b.u8(((seq_profile & 0x7) << 5) | (seq_level_idx_0 & 0x1F));
2223    let byte3 = ((seq_tier_0 & 0x1) << 7)
2224        | ((high_bitdepth as u8 & 0x1) << 6)
2225        | ((twelve_bit as u8 & 0x1) << 5)
2226        | ((monochrome as u8 & 0x1) << 4)
2227        | ((chroma_sub_x & 0x1) << 3)
2228        | ((chroma_sub_y & 0x1) << 2)
2229        | (chroma_sample_position & 0x3);
2230    b.u8(byte3);
2231    // initial_presentation_delay_present=0, reserved bits=0
2232    b.u8(0);
2233    // configOBUs
2234    b.extend(config_obus);
2235    b.finish()
2236}
2237
2238fn build_stts(sample_count: u32, frame_duration: u32) -> Vec<u8> {
2239    let mut b = BoxBuilder::new(b"stts");
2240    b.u8(0);
2241    b.extend(&[0, 0, 0]);
2242    b.u32(1); // entry_count
2243    b.u32(sample_count);
2244    b.u32(frame_duration);
2245    b.finish()
2246}
2247
2248fn build_stss(keyframes: &[u32]) -> Vec<u8> {
2249    let mut b = BoxBuilder::new(b"stss");
2250    b.u8(0);
2251    b.extend(&[0, 0, 0]);
2252    b.u32(keyframes.len() as u32);
2253    for &k in keyframes {
2254        b.u32(k);
2255    }
2256    b.finish()
2257}
2258
2259/// Emit a `stsc` with run-length encoding. Full-size chunks of
2260/// `samples_per_chunk` are represented by one entry starting at chunk 1; if
2261/// the last chunk has a remainder (< samples_per_chunk), a second entry
2262/// records it. sample_description_index is always 1 because we emit a single
2263/// stsd entry (`av01`).
2264fn build_stsc(sample_count: u32, samples_per_chunk: u32) -> Vec<u8> {
2265    let mut b = BoxBuilder::new(b"stsc");
2266    b.u8(0);
2267    b.extend(&[0, 0, 0]);
2268
2269    let spc = samples_per_chunk.max(1);
2270    // Guard against sample_count=0 — the muxer bails before calling this, but
2271    // keep the expression total: empty tables still need a valid entry_count.
2272    if sample_count == 0 {
2273        b.u32(0);
2274        return b.finish();
2275    }
2276
2277    let full_chunks = sample_count / spc;
2278    let remainder = sample_count % spc;
2279
2280    if remainder == 0 {
2281        // Every chunk has spc samples → one entry covers everything.
2282        b.u32(1);
2283        b.u32(1); // first_chunk (1-based)
2284        b.u32(spc); // samples_per_chunk
2285        b.u32(1); // sample_description_index
2286    } else if full_chunks == 0 {
2287        // All samples fit in the final partial chunk → one entry (1, rem, 1).
2288        b.u32(1);
2289        b.u32(1);
2290        b.u32(remainder);
2291        b.u32(1);
2292    } else {
2293        // Full-size run (1 .. full_chunks), then a tail entry for the
2294        // remainder chunk at index full_chunks+1 (1-based).
2295        b.u32(2);
2296        b.u32(1);
2297        b.u32(spc);
2298        b.u32(1);
2299        b.u32(full_chunks + 1); // first_chunk of the tail (1-based)
2300        b.u32(remainder);
2301        b.u32(1);
2302    }
2303    b.finish()
2304}
2305
2306fn build_stsz(sample_sizes: &[u32]) -> Vec<u8> {
2307    let mut b = BoxBuilder::new(b"stsz");
2308    b.u8(0);
2309    b.extend(&[0, 0, 0]);
2310    b.u32(0); // sample_size (0 = varying)
2311    b.u32(sample_sizes.len() as u32); // sample_count
2312    for &s in sample_sizes {
2313        b.u32(s);
2314    }
2315    b.finish()
2316}
2317
2318/// 32-bit chunk offset table. Caller must guarantee every offset fits in u32;
2319/// the muxer's co64-vs-stco decision does that upstream. Internal `as u32`
2320/// cast below is checked via `debug_assert` — `overflow-checks=false` in
2321/// release would otherwise silently wrap.
2322fn build_stco(chunk_offsets: &[u64]) -> Vec<u8> {
2323    let mut b = BoxBuilder::new(b"stco");
2324    b.u8(0);
2325    b.extend(&[0, 0, 0]);
2326    b.u32(chunk_offsets.len() as u32);
2327    for &off in chunk_offsets {
2328        debug_assert!(
2329            off <= u32::MAX as u64,
2330            "stco offset exceeds u32; should be co64"
2331        );
2332        b.u32(off as u32);
2333    }
2334    b.finish()
2335}
2336
2337/// 64-bit chunk offset table. Layout per ISO/IEC 14496-12:
2338/// `size u32be | 'co64' | version u8=0 | flags u8[3]=0 | entry_count u32be
2339/// | entries: u64be chunk_offset[entry_count]`.
2340fn build_co64(chunk_offsets: &[u64]) -> Vec<u8> {
2341    let mut b = BoxBuilder::new(b"co64");
2342    b.u8(0);
2343    b.extend(&[0, 0, 0]);
2344    b.u32(chunk_offsets.len() as u32);
2345    for &off in chunk_offsets {
2346        b.u64(off);
2347    }
2348    b.finish()
2349}
2350
2351/// Partition samples into chunks of size `samples_per_chunk` (last chunk
2352/// may be smaller), then emit an absolute file offset for each chunk's
2353/// first sample by walking `sample_sizes` with a running cursor that starts
2354/// at `first_sample_file_offset`.
2355///
2356/// Superseded by `plan_interleaved_layout` on the hot path — kept here for
2357/// the existing single-track unit tests that exercise the chunking math.
2358#[cfg(test)]
2359fn compute_chunk_offsets(
2360    first_sample_file_offset: u64,
2361    sample_sizes: &[u32],
2362    samples_per_chunk: u32,
2363) -> Vec<u64> {
2364    let spc = samples_per_chunk.max(1) as usize;
2365    let total = sample_sizes.len();
2366    if total == 0 {
2367        return Vec::new();
2368    }
2369    let chunk_count = (total + spc - 1) / spc;
2370    let mut offsets = Vec::with_capacity(chunk_count);
2371    let mut cursor = first_sample_file_offset;
2372    let mut sample_idx = 0usize;
2373    for _ in 0..chunk_count {
2374        offsets.push(cursor);
2375        let end = (sample_idx + spc).min(total);
2376        for &size in &sample_sizes[sample_idx..end] {
2377            cursor = cursor.saturating_add(size as u64);
2378        }
2379        sample_idx = end;
2380    }
2381    offsets
2382}
2383
2384pub(crate) fn write_unity_matrix(b: &mut BoxBuilder) {
2385    b.u32(0x00010000);
2386    b.u32(0);
2387    b.u32(0);
2388    b.u32(0);
2389    b.u32(0x00010000);
2390    b.u32(0);
2391    b.u32(0);
2392    b.u32(0);
2393    b.u32(0x40000000);
2394}
2395
2396pub(crate) struct BoxBuilder {
2397    buf: Vec<u8>,
2398}
2399
2400impl BoxBuilder {
2401    pub(crate) fn new(box_type: &[u8; 4]) -> Self {
2402        let mut buf = Vec::with_capacity(64);
2403        buf.extend_from_slice(&[0, 0, 0, 0]); // size placeholder
2404        buf.extend_from_slice(box_type);
2405        Self { buf }
2406    }
2407
2408    pub(crate) fn u8(&mut self, v: u8) {
2409        self.buf.push(v);
2410    }
2411    pub(crate) fn u16(&mut self, v: u16) {
2412        self.buf.extend_from_slice(&v.to_be_bytes());
2413    }
2414    pub(crate) fn u32(&mut self, v: u32) {
2415        self.buf.extend_from_slice(&v.to_be_bytes());
2416    }
2417    pub(crate) fn u64(&mut self, v: u64) {
2418        self.buf.extend_from_slice(&v.to_be_bytes());
2419    }
2420    pub(crate) fn extend(&mut self, v: &[u8]) {
2421        self.buf.extend_from_slice(v);
2422    }
2423
2424    /// Current byte length of the buffer (header + payload written so far).
2425    /// Used by the CMAF muxer to record the position of `trun.data_offset`
2426    /// so it can be patched once the moof's final size is known.
2427    pub(crate) fn current_len(&self) -> usize {
2428        self.buf.len()
2429    }
2430
2431    pub(crate) fn finish(mut self) -> Vec<u8> {
2432        let size = self.buf.len() as u32;
2433        self.buf[0..4].copy_from_slice(&size.to_be_bytes());
2434        self.buf
2435    }
2436}
2437
2438/// Scan OBU stream for OBU_SEQUENCE_HEADER and return a re-emitted copy with
2439/// obu_has_size_field=1 (required for av1C configOBUs per AV1-ISOBMFF §2.3.3).
2440///
2441/// Requires the encoder to emit Low-Overhead-Bitstream (LOB) format with
2442/// obu_has_size_field set on every OBU — this is the case for rav1e and NVENC.
2443/// If has_size==0, bail rather than stuff frame data into configOBUs: without
2444/// a size field the parser can't know where one OBU ends and the next begins.
2445pub(crate) fn extract_sequence_header(data: &[u8]) -> Result<Vec<u8>> {
2446    let mut pos = 0;
2447    while pos < data.len() {
2448        let header_byte = data[pos];
2449        pos += 1;
2450        let obu_type = (header_byte >> 3) & 0x0F;
2451        let extension_flag = (header_byte >> 2) & 0x1;
2452        let has_size = (header_byte >> 1) & 0x1;
2453        if has_size == 0 {
2454            anyhow::bail!(
2455                "AV1 packet uses Annex-B style OBUs (obu_has_size_field=0); \
2456                 expected LOB format from the encoder"
2457            );
2458        }
2459        if extension_flag != 0 {
2460            if pos >= data.len() {
2461                anyhow::bail!("truncated OBU extension header");
2462            }
2463            pos += 1;
2464        }
2465        let (size64, size_len) = read_leb128(&data[pos..])?;
2466        let size = size64 as usize;
2467        pos += size_len;
2468        if pos + size > data.len() {
2469            anyhow::bail!("OBU payload extends past packet");
2470        }
2471        if obu_type == 1 {
2472            // Re-emit header with ext=0, has_size=1, no temporal/spatial ID.
2473            let header: u8 = (1 << 3) | (1 << 1);
2474            let mut out = Vec::with_capacity(1 + 8 + size);
2475            out.push(header);
2476            write_leb128(&mut out, size as u64);
2477            out.extend_from_slice(&data[pos..pos + size]);
2478            return Ok(out);
2479        }
2480        pos += size;
2481    }
2482    anyhow::bail!("no OBU_SEQUENCE_HEADER found in first packet")
2483}
2484
2485fn read_leb128(data: &[u8]) -> Result<(u64, usize)> {
2486    let mut value: u64 = 0;
2487    let mut len = 0usize;
2488    for i in 0..8 {
2489        if i >= data.len() {
2490            anyhow::bail!("truncated leb128");
2491        }
2492        let byte = data[i];
2493        value |= ((byte & 0x7F) as u64) << (i * 7);
2494        len += 1;
2495        if (byte & 0x80) == 0 {
2496            return Ok((value, len));
2497        }
2498    }
2499    anyhow::bail!("leb128 too long")
2500}
2501
2502fn write_leb128(out: &mut Vec<u8>, mut value: u64) {
2503    loop {
2504        let mut byte = (value & 0x7F) as u8;
2505        value >>= 7;
2506        if value != 0 {
2507            byte |= 0x80;
2508            out.push(byte);
2509        } else {
2510            out.push(byte);
2511            return;
2512        }
2513    }
2514}
2515
2516/// Parse AV1 sequence header OBU to extract parameters needed for av1C.
2517///
2518/// Returns `(seq_profile, seq_level_idx_0, seq_tier_0,
2519///          high_bitdepth, twelve_bit, monochrome,
2520///          chroma_subsampling_x, chroma_subsampling_y, chroma_sample_position)`.
2521///
2522/// Defaults match 8-bit 4:2:0 Main profile if parsing fails — the resulting
2523/// av1C will still be valid for typical rav1e output (profile 0 level 0).
2524fn parse_seq_header_params(obu: &[u8]) -> (u8, u8, u8, bool, bool, bool, u8, u8, u8) {
2525    if obu.len() < 2 {
2526        return (0, 0, 0, false, false, false, 1, 1, 0);
2527    }
2528    // Skip OBU header + leb128 size
2529    let mut pos = 1;
2530    if obu[0] & 0x02 != 0 {
2531        // has_size: parse leb128
2532        match read_leb128(&obu[pos..]) {
2533            Ok((_, len)) => pos += len,
2534            Err(_) => return (0, 0, 0, false, false, false, 1, 1, 0),
2535        }
2536    }
2537    if pos >= obu.len() {
2538        return (0, 0, 0, false, false, false, 1, 1, 0);
2539    }
2540
2541    let mut br = BitReader::new(&obu[pos..]);
2542    let seq_profile = br.bits(3).unwrap_or(0) as u8;
2543    let _still_picture = br.bits(1).unwrap_or(0);
2544    let reduced_still_picture_header = br.bits(1).unwrap_or(0);
2545
2546    let (seq_level_idx_0, seq_tier_0) = if reduced_still_picture_header != 0 {
2547        (br.bits(5).unwrap_or(0) as u8, 0)
2548    } else {
2549        let timing_info_present = br.bits(1).unwrap_or(0);
2550        if timing_info_present != 0 {
2551            let _num_units = br.bits(32);
2552            let _time_scale = br.bits(32);
2553            let equal_pts = br.bits(1).unwrap_or(0);
2554            if equal_pts != 0 {
2555                let _nticks = read_uvlc(&mut br);
2556            }
2557            let decoder_model_info_present = br.bits(1).unwrap_or(0);
2558            if decoder_model_info_present != 0 {
2559                let _bdlm1 = br.bits(5);
2560                let _nts = br.bits(32);
2561                let _brslm1 = br.bits(5);
2562                let _frpdlm1 = br.bits(5);
2563            }
2564        }
2565        let initial_display_delay_present = br.bits(1).unwrap_or(0);
2566        let operating_points_cnt_minus_1 = br.bits(5).unwrap_or(0);
2567        let mut level0 = 0u8;
2568        let mut tier0 = 0u8;
2569        for i in 0..=operating_points_cnt_minus_1 {
2570            let _operating_point_idc = br.bits(12).unwrap_or(0);
2571            let seq_level_idx_i = br.bits(5).unwrap_or(0) as u8;
2572            let seq_tier_i = if seq_level_idx_i > 7 {
2573                br.bits(1).unwrap_or(0) as u8
2574            } else {
2575                0
2576            };
2577            if i == 0 {
2578                level0 = seq_level_idx_i;
2579                tier0 = seq_tier_i;
2580            }
2581            // Decoder model / initial_display_delay skipping
2582            // decoder_model_info_present always 0 in our path above; skip its conditional fields.
2583            if initial_display_delay_present != 0 {
2584                let present = br.bits(1).unwrap_or(0);
2585                if present != 0 {
2586                    let _iddm1 = br.bits(4);
2587                }
2588            }
2589        }
2590        (level0, tier0)
2591    };
2592
2593    let frame_width_bits_minus_1 = br.bits(4).unwrap_or(0);
2594    let frame_height_bits_minus_1 = br.bits(4).unwrap_or(0);
2595    let _max_frame_width_minus_1 = br.bits(frame_width_bits_minus_1 + 1);
2596    let _max_frame_height_minus_1 = br.bits(frame_height_bits_minus_1 + 1);
2597
2598    if reduced_still_picture_header == 0 {
2599        let frame_id_numbers_present = br.bits(1).unwrap_or(0);
2600        if frame_id_numbers_present != 0 {
2601            let _delta_fid_len = br.bits(4);
2602            let _add_fid_len = br.bits(3);
2603        }
2604    }
2605    let _use_128x128 = br.bits(1);
2606    let _enable_filter_intra = br.bits(1);
2607    let _enable_intra_edge_filter = br.bits(1);
2608    if reduced_still_picture_header == 0 {
2609        let _enable_interintra = br.bits(1);
2610        let _enable_masked = br.bits(1);
2611        let _enable_warped = br.bits(1);
2612        let _enable_dual_filter = br.bits(1);
2613        let _enable_order_hint = br.bits(1);
2614        let enable_order_hint = _enable_order_hint.unwrap_or(0);
2615        if enable_order_hint != 0 {
2616            let _enable_jnt_comp = br.bits(1);
2617            let _enable_ref_frame_mvs = br.bits(1);
2618        }
2619        let seq_choose_screen_detection_tools = br.bits(1).unwrap_or(0);
2620        let seq_force_screen_content_tools = if seq_choose_screen_detection_tools != 0 {
2621            2
2622        } else {
2623            br.bits(1).unwrap_or(0)
2624        };
2625        if seq_force_screen_content_tools > 0 {
2626            let seq_choose_integer_mv = br.bits(1).unwrap_or(0);
2627            if seq_choose_integer_mv == 0 {
2628                let _seq_force_integer_mv = br.bits(1);
2629            }
2630        }
2631        if enable_order_hint != 0 {
2632            let _order_hint_bits_minus_1 = br.bits(3);
2633        }
2634    }
2635    let _enable_superres = br.bits(1);
2636    let _enable_cdef = br.bits(1);
2637    let _enable_restoration = br.bits(1);
2638
2639    // color_config() per AV1 §5.5.2
2640    let high_bitdepth = br.bits(1).unwrap_or(0) != 0;
2641    let twelve_bit = if seq_profile == 2 && high_bitdepth {
2642        br.bits(1).unwrap_or(0) != 0
2643    } else {
2644        false
2645    };
2646    let monochrome = if seq_profile == 1 {
2647        false
2648    } else {
2649        br.bits(1).unwrap_or(0) != 0
2650    };
2651    let color_description_present = br.bits(1).unwrap_or(0) != 0;
2652    let (color_primaries, transfer_characteristics, matrix_coefficients) =
2653        if color_description_present {
2654            let cp = br.bits(8).unwrap_or(2) as u8;
2655            let tc = br.bits(8).unwrap_or(2) as u8;
2656            let mc = br.bits(8).unwrap_or(2) as u8;
2657            (cp, tc, mc)
2658        } else {
2659            (2u8, 2u8, 2u8) // CP_UNSPECIFIED / TC_UNSPECIFIED / MC_UNSPECIFIED
2660        };
2661    let (subsampling_x, subsampling_y, chroma_sample_position) = if monochrome {
2662        // color_range
2663        let _color_range = br.bits(1);
2664        (1u8, 1u8, 0u8)
2665    } else if color_primaries == 1 /* CP_BT_709 */
2666        && transfer_characteristics == 13 /* TC_SRGB */
2667        && matrix_coefficients == 0
2668    /* MC_IDENTITY */
2669    {
2670        // color_range is implicitly full (1), RGB 4:4:4
2671        (0u8, 0u8, 0u8)
2672    } else {
2673        let _color_range = br.bits(1);
2674        let (sx, sy) = if seq_profile == 0 {
2675            (1u8, 1u8)
2676        } else if seq_profile == 1 {
2677            (0u8, 0u8)
2678        } else {
2679            let bit_depth = if high_bitdepth {
2680                if twelve_bit { 12 } else { 10 }
2681            } else {
2682                8
2683            };
2684            if bit_depth == 12 {
2685                let sxb = br.bits(1).unwrap_or(1) as u8;
2686                let syb = if sxb != 0 {
2687                    br.bits(1).unwrap_or(1) as u8
2688                } else {
2689                    0
2690                };
2691                (sxb, syb)
2692            } else {
2693                (1u8, 0u8)
2694            }
2695        };
2696        let csp = if sx != 0 && sy != 0 {
2697            br.bits(2).unwrap_or(0) as u8
2698        } else {
2699            0u8
2700        };
2701        (sx, sy, csp)
2702    };
2703    // separate_uv_deltas follows but we don't emit it; parser state ends here.
2704
2705    (
2706        seq_profile,
2707        seq_level_idx_0,
2708        seq_tier_0,
2709        high_bitdepth,
2710        twelve_bit,
2711        monochrome,
2712        subsampling_x,
2713        subsampling_y,
2714        chroma_sample_position,
2715    )
2716}
2717
2718struct BitReader<'a> {
2719    data: &'a [u8],
2720    pos: usize,
2721}
2722
2723impl<'a> BitReader<'a> {
2724    fn new(data: &'a [u8]) -> Self {
2725        Self { data, pos: 0 }
2726    }
2727
2728    fn bits(&mut self, n: u32) -> Option<u32> {
2729        let mut v: u32 = 0;
2730        for _ in 0..n {
2731            if self.pos / 8 >= self.data.len() {
2732                return None;
2733            }
2734            let byte = self.data[self.pos / 8];
2735            let bit = (byte >> (7 - (self.pos % 8))) & 1;
2736            v = (v << 1) | bit as u32;
2737            self.pos += 1;
2738        }
2739        Some(v)
2740    }
2741}
2742
2743fn read_uvlc(br: &mut BitReader) -> u32 {
2744    let mut leading_zeros = 0u32;
2745    while leading_zeros < 32 {
2746        match br.bits(1) {
2747            Some(0) => leading_zeros += 1,
2748            Some(_) => break,
2749            None => return 0,
2750        }
2751    }
2752    if leading_zeros >= 32 {
2753        return u32::MAX;
2754    }
2755    let value = br.bits(leading_zeros).unwrap_or(0);
2756    value + ((1u32 << leading_zeros) - 1)
2757}
2758
2759#[cfg(test)]
2760mod tests {
2761    use super::*;
2762
2763    #[test]
2764    fn ftyp_starts_with_size_and_type() {
2765        let ftyp = build_ftyp();
2766        let size = u32::from_be_bytes([ftyp[0], ftyp[1], ftyp[2], ftyp[3]]);
2767        assert_eq!(size as usize, ftyp.len());
2768        assert_eq!(&ftyp[4..8], b"ftyp");
2769    }
2770
2771    #[test]
2772    fn leb128_roundtrip() {
2773        let mut buf = Vec::new();
2774        write_leb128(&mut buf, 300);
2775        let (v, n) = read_leb128(&buf).unwrap();
2776        assert_eq!(v, 300);
2777        assert_eq!(n, buf.len());
2778    }
2779
2780    #[test]
2781    fn box_builder_sizes_correctly() {
2782        let mut b = BoxBuilder::new(b"test");
2783        b.u32(0xDEADBEEF);
2784        let out = b.finish();
2785        assert_eq!(out.len(), 12);
2786        assert_eq!(&out[4..8], b"test");
2787        assert_eq!(u32::from_be_bytes([out[0], out[1], out[2], out[3]]), 12);
2788    }
2789
2790    // ---- stsc chunk-run tests --------------------------------------------
2791
2792    /// Parse a `stsc` box bytes → Vec<(first_chunk, samples_per_chunk, sdi)>.
2793    fn parse_stsc_entries(stsc: &[u8]) -> Vec<(u32, u32, u32)> {
2794        assert_eq!(&stsc[4..8], b"stsc");
2795        // size(4) type(4) ver(1) flags(3) count(4)
2796        let count = u32::from_be_bytes([stsc[12], stsc[13], stsc[14], stsc[15]]) as usize;
2797        let mut out = Vec::with_capacity(count);
2798        let mut p = 16usize;
2799        for _ in 0..count {
2800            let fc = u32::from_be_bytes([stsc[p], stsc[p + 1], stsc[p + 2], stsc[p + 3]]);
2801            let spc = u32::from_be_bytes([stsc[p + 4], stsc[p + 5], stsc[p + 6], stsc[p + 7]]);
2802            let sdi = u32::from_be_bytes([stsc[p + 8], stsc[p + 9], stsc[p + 10], stsc[p + 11]]);
2803            out.push((fc, spc, sdi));
2804            p += 12;
2805        }
2806        out
2807    }
2808
2809    #[test]
2810    fn mux_stsc_emits_multiple_chunk_runs() {
2811        // 120 samples at spc=24 → 5 full chunks of 24, no remainder.
2812        let stsc = build_stsc(120, 24);
2813        let entries = parse_stsc_entries(&stsc);
2814        assert_eq!(entries, vec![(1, 24, 1)]);
2815    }
2816
2817    #[test]
2818    fn mux_stsc_last_chunk_under_spc_emits_tail_entry() {
2819        // 121 samples at spc=24 → 5 full chunks + 1 tail of 1.
2820        let stsc = build_stsc(121, 24);
2821        let entries = parse_stsc_entries(&stsc);
2822        assert_eq!(entries, vec![(1, 24, 1), (6, 1, 1)]);
2823    }
2824
2825    #[test]
2826    fn mux_stsc_all_under_spc_single_entry() {
2827        // 10 samples at spc=24 → one partial chunk.
2828        let stsc = build_stsc(10, 24);
2829        let entries = parse_stsc_entries(&stsc);
2830        assert_eq!(entries, vec![(1, 10, 1)]);
2831    }
2832
2833    // ---- chunk offset computation ----------------------------------------
2834
2835    #[test]
2836    fn compute_chunk_offsets_walks_sample_sizes() {
2837        let sizes = vec![100u32, 200, 300, 400, 500, 600, 700];
2838        let offs = compute_chunk_offsets(1000, &sizes, 3);
2839        // chunks: [0..3]=1000, [3..6]=1000+600=1600, [6..7]=1600+1500=3100
2840        assert_eq!(offs, vec![1000, 1600, 3100]);
2841    }
2842
2843    #[test]
2844    fn compute_chunk_offsets_single_chunk() {
2845        let sizes = vec![10u32; 5];
2846        let offs = compute_chunk_offsets(42, &sizes, 120);
2847        assert_eq!(offs, vec![42]);
2848    }
2849
2850    // ---- stco / co64 ------------------------------------------------------
2851
2852    #[test]
2853    fn build_stco_emits_32bit_offsets() {
2854        let offs = vec![8u64, 1_000_000, u32::MAX as u64];
2855        let box_bytes = build_stco(&offs);
2856        assert_eq!(&box_bytes[4..8], b"stco");
2857        let count =
2858            u32::from_be_bytes([box_bytes[12], box_bytes[13], box_bytes[14], box_bytes[15]]);
2859        assert_eq!(count, 3);
2860        // 3 × 4 = 12 entry bytes. Header: 4 size + 4 type + 1 ver + 3 flags + 4 count = 16.
2861        assert_eq!(box_bytes.len(), 16 + 12);
2862        let last = u32::from_be_bytes([box_bytes[24], box_bytes[25], box_bytes[26], box_bytes[27]]);
2863        assert_eq!(last, u32::MAX);
2864    }
2865
2866    #[test]
2867    fn build_co64_emits_64bit_offsets() {
2868        let big = (u32::MAX as u64) + 100;
2869        let offs = vec![8u64, big, big + 1_000_000];
2870        let box_bytes = build_co64(&offs);
2871        assert_eq!(&box_bytes[4..8], b"co64");
2872        let count =
2873            u32::from_be_bytes([box_bytes[12], box_bytes[13], box_bytes[14], box_bytes[15]]);
2874        assert_eq!(count, 3);
2875        // 3 × 8 = 24 entry bytes. Header = 16.
2876        assert_eq!(box_bytes.len(), 16 + 24);
2877        // Second entry: bytes 24..32.
2878        let got = u64::from_be_bytes([
2879            box_bytes[24],
2880            box_bytes[25],
2881            box_bytes[26],
2882            box_bytes[27],
2883            box_bytes[28],
2884            box_bytes[29],
2885            box_bytes[30],
2886            box_bytes[31],
2887        ]);
2888        assert_eq!(got, big);
2889    }
2890
2891    #[test]
2892    fn build_co64_offsets_are_monotonic_and_be() {
2893        // Craft a descending payload input to guard against accidental
2894        // little-endian or re-sort bugs.
2895        let offs: Vec<u64> = (0..5)
2896            .map(|i| 10_000_000_000u64 + i as u64 * 4096)
2897            .collect();
2898        let box_bytes = build_co64(&offs);
2899        let mut prev = 0u64;
2900        for i in 0..5 {
2901            let p = 16 + i * 8;
2902            let v = u64::from_be_bytes([
2903                box_bytes[p],
2904                box_bytes[p + 1],
2905                box_bytes[p + 2],
2906                box_bytes[p + 3],
2907                box_bytes[p + 4],
2908                box_bytes[p + 5],
2909                box_bytes[p + 6],
2910                box_bytes[p + 7],
2911            ]);
2912            assert!(v > prev, "offsets not monotonic: {v} after {prev}");
2913            prev = v;
2914        }
2915    }
2916
2917    // ---- moov-level stco vs co64 -----------------------------------------
2918
2919    /// Find a 4-cc occurrence in a byte slice. Used to assert presence of
2920    /// `co64`/`stco` in built moov blobs. Returns None if absent.
2921    fn find_fourcc(data: &[u8], tag: &[u8; 4]) -> Option<usize> {
2922        data.windows(4).position(|w| w == tag)
2923    }
2924
2925    #[test]
2926    fn moov_with_use_co64_true_emits_co64_not_stco() {
2927        let sample_sizes = vec![1000u32; 120];
2928        // Offsets span past u32::MAX — representative of a 5 GiB file.
2929        let chunk_offsets: Vec<u64> = (0..5)
2930            .map(|i| (u32::MAX as u64) + i * 1_000_000_000)
2931            .collect();
2932        // Minimal config_obus — content is opaque to stbl layout.
2933        let config_obus = vec![0x0Au8, 0x03, 0x00, 0x00, 0x00];
2934        let moov = build_moov(
2935            1920,
2936            1080,
2937            90_000,
2938            120 * 3750,
2939            3750,
2940            &sample_sizes,
2941            &[],
2942            &config_obus,
2943            &chunk_offsets,
2944            24,
2945            true,
2946        );
2947        assert!(find_fourcc(&moov, b"co64").is_some(), "co64 box missing");
2948        // NB: must check for standalone `stco` not a substring — `stco` can
2949        // appear in payload or other labels. Use exact 4-byte box-type match.
2950        assert!(
2951            find_fourcc(&moov, b"stco").is_none(),
2952            "stco present when co64 chosen"
2953        );
2954    }
2955
2956    #[test]
2957    fn moov_with_use_co64_false_emits_stco_not_co64() {
2958        let sample_sizes = vec![1000u32; 120];
2959        let chunk_offsets: Vec<u64> = (0..5).map(|i| 1000 + i * 24_000).collect();
2960        let config_obus = vec![0x0Au8, 0x03, 0x00, 0x00, 0x00];
2961        let moov = build_moov(
2962            1920,
2963            1080,
2964            90_000,
2965            120 * 3750,
2966            3750,
2967            &sample_sizes,
2968            &[],
2969            &config_obus,
2970            &chunk_offsets,
2971            24,
2972            false,
2973        );
2974        assert!(find_fourcc(&moov, b"stco").is_some(), "stco box missing");
2975        assert!(
2976            find_fourcc(&moov, b"co64").is_none(),
2977            "co64 present when stco chosen"
2978        );
2979    }
2980
2981    // ---- Apple-compat: ftyp brands ---------------------------------------
2982
2983    /// AV1-ISOBMFF v1.3.0 §2.1 mandates `av01` in `compatible_brands`. Apple
2984    /// QuickTime / iOS Safari additionally need a structural ISOBMFF brand
2985    /// (`iso6` covers co64 / largesize from 14496-12 sixth edition). `mp42`
2986    /// is conventional for AAC parsing rules.
2987    #[test]
2988    fn ftyp_lists_av01_and_iso6_and_mp42_brands() {
2989        let ftyp = build_ftyp();
2990        // major_brand at offset 8..12 (after size + 'ftyp')
2991        assert_eq!(&ftyp[8..12], b"iso6", "major_brand should be iso6");
2992        // After major(4) + minor(4) the compatible_brands list runs to end.
2993        let compat = &ftyp[16..];
2994        let brands: Vec<&[u8]> = compat.chunks_exact(4).collect();
2995        assert!(
2996            brands.contains(&b"av01".as_ref()),
2997            "compatible_brands must list av01 per AV1-ISOBMFF §2.1; got {:?}",
2998            brands
2999        );
3000        assert!(
3001            brands.contains(&b"iso6".as_ref()),
3002            "compatible_brands must list iso6 (14496-12 v6 — covers co64/largesize)"
3003        );
3004        assert!(
3005            brands.contains(&b"mp42".as_ref()),
3006            "compatible_brands should list mp42 for AAC parsing rules"
3007        );
3008    }
3009
3010    // ---- Apple-compat: colr nclx atom ------------------------------------
3011
3012    /// Find every occurrence of the 4-byte tag (used for assertions where
3013    /// the tag may legitimately appear inside payload too).
3014    fn count_fourcc_occurrences(data: &[u8], tag: &[u8; 4]) -> usize {
3015        data.windows(4).filter(|w| *w == tag).count()
3016    }
3017
3018    #[test]
3019    fn av01_sample_entry_includes_colr_nclx_box() {
3020        let cm = ColorMetadata::default();
3021        let sample_sizes = vec![100u32; 30];
3022        let chunk_offsets: Vec<u64> = vec![1000];
3023        let config_obus = vec![0x0Au8, 0x03, 0x00, 0x00, 0x00];
3024        let moov = build_moov_any(
3025            1920,
3026            1080,
3027            90_000,
3028            90_000,
3029            30 * 3000,
3030            30 * 3000,
3031            3000,
3032            &sample_sizes,
3033            &[],
3034            &config_obus,
3035            &chunk_offsets,
3036            30,
3037            None,
3038            &[],
3039            false,
3040            &cm,
3041        );
3042        let colr_pos = find_fourcc(&moov, b"colr").expect("colr atom missing");
3043        // Body layout: [pos-4..pos] = size, [pos..pos+4] = 'colr',
3044        // [pos+4..pos+8] = colour_type, then 6 bytes nclx fields.
3045        assert_eq!(
3046            &moov[colr_pos + 4..colr_pos + 8],
3047            b"nclx",
3048            "colour_type must be 'nclx' per ISO/IEC 23001-8"
3049        );
3050        // colour_primaries (u16 BE) at +8..+10
3051        let cp = u16::from_be_bytes([moov[colr_pos + 8], moov[colr_pos + 9]]);
3052        assert_eq!(cp, 1, "default BT.709 colour_primaries=1");
3053        // transfer_characteristics at +10..+12
3054        let tc = u16::from_be_bytes([moov[colr_pos + 10], moov[colr_pos + 11]]);
3055        assert_eq!(tc, 1, "default BT.709 transfer_characteristics=1");
3056        // matrix_coefficients at +12..+14
3057        let mc = u16::from_be_bytes([moov[colr_pos + 12], moov[colr_pos + 13]]);
3058        assert_eq!(mc, 1, "default BT.709 matrix_coefficients=1");
3059        // full_range_flag is the high bit of the byte at +14
3060        let fr = moov[colr_pos + 14];
3061        assert_eq!(fr & 0x80, 0x00, "default limited-range full_range_flag=0");
3062    }
3063
3064    #[test]
3065    fn colr_nclx_carries_hdr10_metadata() {
3066        // HDR10: BT.2020 NCL primaries (9), ST 2084 PQ transfer (16),
3067        // BT.2020 NCL matrix (9), limited range. This is the canonical
3068        // HDR10 nclx triple — Apple's player needs it to apply PQ tone
3069        // mapping correctly.
3070        let cm = ColorMetadata {
3071            transfer: codec::frame::TransferFn::St2084,
3072            matrix_coefficients: 9,
3073            colour_primaries: 9,
3074            full_range: false,
3075            ..ColorMetadata::default()
3076        };
3077        let colr = build_colr_nclx(&cm);
3078        assert_eq!(&colr[4..8], b"colr");
3079        assert_eq!(&colr[8..12], b"nclx");
3080        let cp = u16::from_be_bytes([colr[12], colr[13]]);
3081        let tc = u16::from_be_bytes([colr[14], colr[15]]);
3082        let mc = u16::from_be_bytes([colr[16], colr[17]]);
3083        let fr = colr[18];
3084        assert_eq!(cp, 9, "BT.2020 NCL primaries");
3085        assert_eq!(tc, 16, "ST 2084 PQ transfer");
3086        assert_eq!(mc, 9, "BT.2020 NCL matrix");
3087        assert_eq!(fr & 0x80, 0x00, "HDR10 typically signals limited range");
3088    }
3089
3090    #[test]
3091    fn colr_nclx_full_range_sets_high_bit() {
3092        let cm = ColorMetadata {
3093            transfer: codec::frame::TransferFn::Bt709,
3094            matrix_coefficients: 1,
3095            colour_primaries: 1,
3096            full_range: true,
3097            ..ColorMetadata::default()
3098        };
3099        let colr = build_colr_nclx(&cm);
3100        assert_eq!(colr[18] & 0x80, 0x80, "full_range high bit must be set");
3101        // Low 7 bits are reserved-zero per ISO 23001-8.
3102        assert_eq!(colr[18] & 0x7F, 0x00, "reserved bits must be zero");
3103    }
3104
3105    #[test]
3106    fn colr_nclx_box_size_matches_layout() {
3107        // Box: 4 size + 4 'colr' + 4 colour_type + 2 cp + 2 tc + 2 mc + 1 packed = 19 bytes.
3108        let colr = build_colr_nclx(&ColorMetadata::default());
3109        let size = u32::from_be_bytes([colr[0], colr[1], colr[2], colr[3]]) as usize;
3110        assert_eq!(
3111            size,
3112            colr.len(),
3113            "colr box size field must equal box length"
3114        );
3115        assert_eq!(size, 19, "colr nclx must be exactly 19 bytes");
3116    }
3117
3118    /// Sanity: the `colr` atom must live inside the visual sample entry,
3119    /// not float at the moov / trak / stbl level. Players look for it
3120    /// nested inside `av01` (or `avc1`/`hvc1`) in `stsd`.
3121    #[test]
3122    fn colr_lives_inside_av01_sample_entry() {
3123        let cm = ColorMetadata::default();
3124        let sample_sizes = vec![100u32; 30];
3125        let chunk_offsets: Vec<u64> = vec![1000];
3126        let config_obus = vec![0x0Au8, 0x03, 0x00, 0x00, 0x00];
3127        let moov = build_moov_any(
3128            1920,
3129            1080,
3130            90_000,
3131            90_000,
3132            30 * 3000,
3133            30 * 3000,
3134            3000,
3135            &sample_sizes,
3136            &[],
3137            &config_obus,
3138            &chunk_offsets,
3139            30,
3140            None,
3141            &[],
3142            false,
3143            &cm,
3144        );
3145        let av01_pos = find_fourcc(&moov, b"av01").expect("av01 sample entry missing");
3146        let av01_size = u32::from_be_bytes([
3147            moov[av01_pos - 4],
3148            moov[av01_pos - 3],
3149            moov[av01_pos - 2],
3150            moov[av01_pos - 1],
3151        ]) as usize;
3152        let av01_end = av01_pos - 4 + av01_size;
3153        let colr_pos = find_fourcc(&moov, b"colr").expect("colr missing");
3154        assert!(
3155            colr_pos > av01_pos && colr_pos < av01_end,
3156            "colr must be nested inside av01 sample entry: av01@{}..{} colr@{}",
3157            av01_pos,
3158            av01_end,
3159            colr_pos
3160        );
3161        assert_eq!(
3162            count_fourcc_occurrences(&moov, b"colr"),
3163            1,
3164            "exactly one colr atom expected"
3165        );
3166    }
3167
3168    // ---- mdat 64-bit largesize -------------------------------------------
3169
3170    /// transfer_to_h273 should round-trip through the H.273 codes the
3171    /// pipeline knows about. The Bt709 enum variant collapses 4 H.273
3172    /// codes (1, 6, 14, 15) — we always emit the canonical 1 on write.
3173    #[test]
3174    fn transfer_to_h273_emits_canonical_codes() {
3175        use codec::frame::TransferFn;
3176        assert_eq!(transfer_to_h273(TransferFn::Bt709), 1);
3177        assert_eq!(transfer_to_h273(TransferFn::Bt470Bg), 4);
3178        assert_eq!(transfer_to_h273(TransferFn::Linear), 8);
3179        assert_eq!(transfer_to_h273(TransferFn::St2084), 16);
3180        assert_eq!(transfer_to_h273(TransferFn::AribStdB67), 18);
3181        assert_eq!(transfer_to_h273(TransferFn::Unspecified), 2);
3182    }
3183
3184    // ---- HDR atoms: mdcv (Mastering Display Color Volume) ----------------
3185
3186    /// HDR10-canonical mastering display values: BT.2020 primaries +
3187    /// D65 white point + 1000 nits / 0.0001 nits luminance, all in the
3188    /// HEVC SEI 137 / SMPTE ST 2086 spec-domain integer encoding.
3189    ///
3190    /// Cross-references for the wire numbers (so future reviewers can
3191    /// re-derive without chasing a spec PDF):
3192    ///   BT.2020 R primary  (0.708 , 0.292)  → (35400, 14600)
3193    ///   BT.2020 G primary  (0.170 , 0.797)  → ( 8500, 39850)
3194    ///   BT.2020 B primary  (0.131 , 0.046)  → ( 6550,  2300)
3195    ///   D65 white point    (0.3127, 0.3290) → (15635, 16450)
3196    ///   max luminance       1000 cd/m²      → 10_000_000  (0.0001 cd/m² steps)
3197    ///   min luminance       0.0001 cd/m²    →          1
3198    fn hdr10_mastering_display() -> codec::frame::MasteringDisplay {
3199        codec::frame::MasteringDisplay {
3200            primaries_r_x: 35400,
3201            primaries_r_y: 14600,
3202            primaries_g_x: 8500,
3203            primaries_g_y: 39850,
3204            primaries_b_x: 6550,
3205            primaries_b_y: 2300,
3206            white_point_x: 15635,
3207            white_point_y: 16450,
3208            max_luminance: 10_000_000,
3209            min_luminance: 1,
3210        }
3211    }
3212
3213    /// 24-byte payload + 8-byte header = 32 bytes. Bytes laid out big-endian.
3214    /// Box-type is `'mdcv'` (NOT `'SmDm'`).
3215    #[test]
3216    fn mdcv_box_24_byte_payload_layout() {
3217        let md = hdr10_mastering_display();
3218        let mdcv = build_mdcv(&md);
3219        assert_eq!(
3220            mdcv.len(),
3221            32,
3222            "mdcv box must be exactly 32 bytes (8 header + 24 payload)"
3223        );
3224        let size = u32::from_be_bytes([mdcv[0], mdcv[1], mdcv[2], mdcv[3]]) as usize;
3225        assert_eq!(size, mdcv.len(), "size field must equal box length");
3226        assert_eq!(&mdcv[4..8], b"mdcv", "box type must be 'mdcv' (not 'SmDm')");
3227        // Body fields, all u16 BE except the trailing two u32s.
3228        let u16_at = |off: usize| u16::from_be_bytes([mdcv[off], mdcv[off + 1]]);
3229        let u32_at = |off: usize| {
3230            u32::from_be_bytes([mdcv[off], mdcv[off + 1], mdcv[off + 2], mdcv[off + 3]])
3231        };
3232        assert_eq!(u16_at(8), 35400, "primaries_r_x");
3233        assert_eq!(u16_at(10), 14600, "primaries_r_y");
3234        assert_eq!(u16_at(12), 8500, "primaries_g_x");
3235        assert_eq!(u16_at(14), 39850, "primaries_g_y");
3236        assert_eq!(u16_at(16), 6550, "primaries_b_x");
3237        assert_eq!(u16_at(18), 2300, "primaries_b_y");
3238        assert_eq!(u16_at(20), 15635, "white_point_x");
3239        assert_eq!(u16_at(22), 16450, "white_point_y");
3240        assert_eq!(u32_at(24), 10_000_000, "max_luminance (0.0001 cd/m² steps)");
3241        assert_eq!(u32_at(28), 1, "min_luminance");
3242    }
3243
3244    /// 4-byte payload + 8-byte header = 12 bytes. Box-type is `'clli'`
3245    /// (NOT `'CoLL'`).
3246    #[test]
3247    fn clli_box_4_byte_payload_layout() {
3248        let cll = codec::frame::ContentLightLevel {
3249            max_cll: 1000,
3250            max_fall: 400,
3251        };
3252        let clli = build_clli(&cll);
3253        assert_eq!(
3254            clli.len(),
3255            12,
3256            "clli box must be exactly 12 bytes (8 header + 4 payload)"
3257        );
3258        let size = u32::from_be_bytes([clli[0], clli[1], clli[2], clli[3]]) as usize;
3259        assert_eq!(size, clli.len(), "size field must equal box length");
3260        assert_eq!(&clli[4..8], b"clli", "box type must be 'clli' (not 'CoLL')");
3261        let max_cll = u16::from_be_bytes([clli[8], clli[9]]);
3262        let max_fall = u16::from_be_bytes([clli[10], clli[11]]);
3263        assert_eq!(max_cll, 1000, "max_cll");
3264        assert_eq!(max_fall, 400, "max_fall");
3265    }
3266
3267    /// When mastering_display is None, the av01 sample entry must omit
3268    /// the `mdcv` box entirely. SDR sources should produce a moov with
3269    /// no `mdcv` 4cc anywhere.
3270    #[test]
3271    fn mdcv_omitted_when_none() {
3272        let cm = ColorMetadata::default(); // None, None
3273        let sample_sizes = vec![100u32; 30];
3274        let chunk_offsets: Vec<u64> = vec![1000];
3275        let config_obus = vec![0x0Au8, 0x03, 0x00, 0x00, 0x00];
3276        let moov = build_moov_any(
3277            1920,
3278            1080,
3279            90_000,
3280            90_000,
3281            30 * 3000,
3282            30 * 3000,
3283            3000,
3284            &sample_sizes,
3285            &[],
3286            &config_obus,
3287            &chunk_offsets,
3288            30,
3289            None,
3290            &[],
3291            false,
3292            &cm,
3293        );
3294        assert!(
3295            find_fourcc(&moov, b"mdcv").is_none(),
3296            "SDR (mastering_display=None) moov must NOT contain mdcv box"
3297        );
3298    }
3299
3300    /// When content_light_level is None, the av01 sample entry must omit
3301    /// the `clli` box entirely.
3302    #[test]
3303    fn clli_omitted_when_none() {
3304        let cm = ColorMetadata::default();
3305        let sample_sizes = vec![100u32; 30];
3306        let chunk_offsets: Vec<u64> = vec![1000];
3307        let config_obus = vec![0x0Au8, 0x03, 0x00, 0x00, 0x00];
3308        let moov = build_moov_any(
3309            1920,
3310            1080,
3311            90_000,
3312            90_000,
3313            30 * 3000,
3314            30 * 3000,
3315            3000,
3316            &sample_sizes,
3317            &[],
3318            &config_obus,
3319            &chunk_offsets,
3320            30,
3321            None,
3322            &[],
3323            false,
3324            &cm,
3325        );
3326        assert!(
3327            find_fourcc(&moov, b"clli").is_none(),
3328            "SDR (content_light_level=None) moov must NOT contain clli box"
3329        );
3330    }
3331
3332    /// AV1-ISOBMFF v1.3.0 §2.3.4 + §2.3.5 prescribe the order
3333    /// `colr → mdcv → clli` inside the visual sample entry. Players
3334    /// scan by 4cc so order is recommended-not-required, but matching
3335    /// the spec keeps us defensible against strict validators
3336    /// (mp4parser, GPAC's mp4box -info).
3337    #[test]
3338    fn av01_sample_entry_emits_mdcv_and_clli_in_order() {
3339        let cm = ColorMetadata {
3340            transfer: codec::frame::TransferFn::St2084,
3341            matrix_coefficients: 9,
3342            colour_primaries: 9,
3343            full_range: false,
3344            mastering_display: Some(hdr10_mastering_display()),
3345            content_light_level: Some(codec::frame::ContentLightLevel {
3346                max_cll: 1000,
3347                max_fall: 400,
3348            }),
3349        };
3350        let sample_sizes = vec![100u32; 30];
3351        let chunk_offsets: Vec<u64> = vec![1000];
3352        let config_obus = vec![0x0Au8, 0x03, 0x00, 0x00, 0x00];
3353        let moov = build_moov_any(
3354            1920,
3355            1080,
3356            90_000,
3357            90_000,
3358            30 * 3000,
3359            30 * 3000,
3360            3000,
3361            &sample_sizes,
3362            &[],
3363            &config_obus,
3364            &chunk_offsets,
3365            30,
3366            None,
3367            &[],
3368            false,
3369            &cm,
3370        );
3371        let av01_pos = find_fourcc(&moov, b"av01").expect("av01 sample entry missing");
3372        let av01_size = u32::from_be_bytes([
3373            moov[av01_pos - 4],
3374            moov[av01_pos - 3],
3375            moov[av01_pos - 2],
3376            moov[av01_pos - 1],
3377        ]) as usize;
3378        let av01_end = av01_pos - 4 + av01_size;
3379        let av01_body = &moov[av01_pos..av01_end];
3380        let colr_rel = av01_body
3381            .windows(4)
3382            .position(|w| w == b"colr")
3383            .expect("colr nested in av01");
3384        let mdcv_rel = av01_body
3385            .windows(4)
3386            .position(|w| w == b"mdcv")
3387            .expect("mdcv nested in av01");
3388        let clli_rel = av01_body
3389            .windows(4)
3390            .position(|w| w == b"clli")
3391            .expect("clli nested in av01");
3392        assert!(
3393            colr_rel < mdcv_rel,
3394            "colr ({}) must precede mdcv ({})",
3395            colr_rel,
3396            mdcv_rel
3397        );
3398        assert!(
3399            mdcv_rel < clli_rel,
3400            "mdcv ({}) must precede clli ({})",
3401            mdcv_rel,
3402            clli_rel
3403        );
3404        // Exactly one of each, all under av01.
3405        assert_eq!(
3406            count_fourcc_occurrences(&moov, b"mdcv"),
3407            1,
3408            "exactly one mdcv expected"
3409        );
3410        assert_eq!(
3411            count_fourcc_occurrences(&moov, b"clli"),
3412            1,
3413            "exactly one clli expected"
3414        );
3415    }
3416
3417    // ---- colr nclx HDR transfer-code coverage (Squad-18 verification) ----
3418
3419    /// PQ transfer (HDR10) is H.273 transfer_characteristics = 16. Apple
3420    /// + browsers key off this code to apply the ST 2084 EOTF; emitting
3421    /// 1 (BT.709) here would render HDR10 as washed-out SDR.
3422    #[test]
3423    fn colr_handles_pq_transfer_code_16() {
3424        let cm = ColorMetadata {
3425            transfer: codec::frame::TransferFn::St2084,
3426            matrix_coefficients: 9,
3427            colour_primaries: 9,
3428            full_range: false,
3429            ..ColorMetadata::default()
3430        };
3431        let colr = build_colr_nclx(&cm);
3432        let tc = u16::from_be_bytes([colr[14], colr[15]]);
3433        assert_eq!(tc, 16, "PQ transfer must encode as H.273 code 16");
3434    }
3435
3436    /// HLG transfer is H.273 transfer_characteristics = 18. Same role as
3437    /// PQ but for broadcast HDR; players that support HLG read 18 to
3438    /// activate the ARIB STD-B67 OETF.
3439    #[test]
3440    fn colr_handles_hlg_transfer_code_18() {
3441        let cm = ColorMetadata {
3442            transfer: codec::frame::TransferFn::AribStdB67,
3443            matrix_coefficients: 9,
3444            colour_primaries: 9,
3445            full_range: false,
3446            ..ColorMetadata::default()
3447        };
3448        let colr = build_colr_nclx(&cm);
3449        let tc = u16::from_be_bytes([colr[14], colr[15]]);
3450        assert_eq!(tc, 18, "HLG transfer must encode as H.273 code 18");
3451    }
3452
3453    /// BT.2020 colour_primaries = 9, matrix_coefficients = 9 (NCL) or 10
3454    /// (CL). Both must round-trip verbatim — the pipeline preserves the
3455    /// raw u8 from the source SPS so the encode side can pick the right
3456    /// matrix back out.
3457    #[test]
3458    fn colr_bt2020_primaries_matrix() {
3459        // NCL variant (most common — matrix_coefficients = 9)
3460        let cm_ncl = ColorMetadata {
3461            transfer: codec::frame::TransferFn::St2084,
3462            matrix_coefficients: 9,
3463            colour_primaries: 9,
3464            full_range: false,
3465            ..ColorMetadata::default()
3466        };
3467        let colr_ncl = build_colr_nclx(&cm_ncl);
3468        let cp_ncl = u16::from_be_bytes([colr_ncl[12], colr_ncl[13]]);
3469        let mc_ncl = u16::from_be_bytes([colr_ncl[16], colr_ncl[17]]);
3470        assert_eq!(cp_ncl, 9, "BT.2020 colour_primaries must be 9");
3471        assert_eq!(mc_ncl, 9, "BT.2020 NCL matrix must be 9");
3472
3473        // CL variant (matrix_coefficients = 10)
3474        let cm_cl = ColorMetadata {
3475            matrix_coefficients: 10,
3476            ..cm_ncl
3477        };
3478        let colr_cl = build_colr_nclx(&cm_cl);
3479        let mc_cl = u16::from_be_bytes([colr_cl[16], colr_cl[17]]);
3480        assert_eq!(
3481            mc_cl, 10,
3482            "BT.2020 CL matrix must be 10 (preserved verbatim)"
3483        );
3484    }
3485
3486    // ---- Squad-23: Opus + dOps box layout (RFC 7845) ---------------------
3487
3488    /// Standard OpusHead body for stereo @ 48 kHz with PreSkip = 312
3489    /// (the typical libopus encoder lookahead at 48 kHz). Output gain = 0,
3490    /// ChannelMappingFamily = 0 (stereo).
3491    ///
3492    /// Layout (post-magic body, 11 bytes; LE numeric fields per RFC 7845
3493    /// §5.1):
3494    ///   [0]    Version=1
3495    ///   [1]    OutputChannelCount=2
3496    ///   [2..4] PreSkip=312 LE → 38 01
3497    ///   [4..8] InputSampleRate=48000 LE → 80 BB 00 00
3498    ///   [8..10] OutputGain=0 LE → 00 00
3499    ///   [10]   ChannelMappingFamily=0
3500    fn opus_head_stereo_48k_preskip_312() -> Vec<u8> {
3501        let mut head = Vec::with_capacity(11);
3502        head.push(1u8); // Version
3503        head.push(2u8); // OutputChannelCount
3504        head.extend_from_slice(&312u16.to_le_bytes()); // PreSkip
3505        head.extend_from_slice(&48_000u32.to_le_bytes()); // InputSampleRate
3506        head.extend_from_slice(&0i16.to_le_bytes()); // OutputGain
3507        head.push(0u8); // ChannelMappingFamily
3508        head
3509    }
3510
3511    fn opus_info_stereo_48k() -> AudioInfo {
3512        AudioInfo {
3513            codec: "opus".into(),
3514            sample_rate: 48_000,
3515            channels: 2,
3516            timescale: 48_000,
3517            asc_bytes: Vec::new(),
3518            codec_private: opus_head_stereo_48k_preskip_312(),
3519        }
3520    }
3521
3522    /// `dOps` body layout per RFC 7845 §4.5: 11-byte minimum. Box wrapper
3523    /// adds 8-byte ISOBMFF header → total 19 bytes for ChannelMappingFamily=0.
3524    /// Numeric fields are big-endian (NOT the little-endian convention of
3525    /// the OpusHead source bytes).
3526    #[test]
3527    fn dops_box_11_byte_payload_layout() {
3528        let info = opus_info_stereo_48k();
3529        let dops = build_dops(&info);
3530        assert_eq!(
3531            dops.len(),
3532            19,
3533            "dOps must be exactly 19 bytes (8 header + 11 payload)"
3534        );
3535        let size = u32::from_be_bytes([dops[0], dops[1], dops[2], dops[3]]) as usize;
3536        assert_eq!(size, dops.len(), "size field must equal box length");
3537        assert_eq!(
3538            &dops[4..8],
3539            b"dOps",
3540            "box type must be 'dOps' (capital O lowercase ps)"
3541        );
3542        // Body fields, all BE per §4.5.
3543        assert_eq!(dops[8], 0, "Version (RFC 7845 §4.5: MUST be 0)");
3544        assert_eq!(dops[9], 2, "OutputChannelCount = stereo");
3545        let pre_skip = u16::from_be_bytes([dops[10], dops[11]]);
3546        assert_eq!(pre_skip, 312, "PreSkip = 312 (BE)");
3547        let input_sample_rate = u32::from_be_bytes([dops[12], dops[13], dops[14], dops[15]]);
3548        assert_eq!(input_sample_rate, 48_000, "InputSampleRate = 48000 (BE)");
3549        let output_gain = i16::from_be_bytes([dops[16], dops[17]]);
3550        assert_eq!(output_gain, 0, "OutputGain = 0 (Q8 dB, BE)");
3551        assert_eq!(dops[18], 0, "ChannelMappingFamily = 0 (mono/stereo)");
3552    }
3553
3554    /// The byte-order conversion between OpusHead (LE) and dOps (BE) is
3555    /// the load-bearing piece — easy to mess up. PreSkip=312 in LE is
3556    /// `38 01`; in BE it must come back out as `01 38`.
3557    #[test]
3558    fn dops_byte_order_flipped_from_opushead() {
3559        let info = opus_info_stereo_48k();
3560        // Sanity check the input is in LE.
3561        assert_eq!(
3562            info.codec_private[2..4],
3563            [0x38, 0x01],
3564            "OpusHead PreSkip must be LE"
3565        );
3566        let dops = build_dops(&info);
3567        // PreSkip in dOps body = bytes 10..12 of the box (after 8-byte header).
3568        assert_eq!(
3569            dops[10..12],
3570            [0x01, 0x38],
3571            "dOps PreSkip must be BE — got {:02X?}",
3572            &dops[10..12]
3573        );
3574    }
3575
3576    /// `Opus` sample entry per RFC 7845 §4.4. Same generic AudioSampleEntry
3577    /// preamble as `mp4a` (36 bytes including header) plus the dOps child.
3578    /// Total = 36 + 19 = 55 bytes for the minimum-channel-count case.
3579    /// 4-cc is `Opus` exactly (capital O).
3580    #[test]
3581    fn opus_sample_entry_size_and_fourcc() {
3582        let info = opus_info_stereo_48k();
3583        let entry = build_opus_sample_entry(&info);
3584        let size = u32::from_be_bytes([entry[0], entry[1], entry[2], entry[3]]) as usize;
3585        assert_eq!(size, entry.len(), "size field must equal box length");
3586        assert_eq!(&entry[4..8], b"Opus", "4-cc MUST be 'Opus' (capital O)");
3587        assert_ne!(&entry[4..8], b"opus", "lowercase 'opus' is non-conformant");
3588        // Total = 36 (sample entry preamble inc 8-byte header) + 19 (dOps) = 55.
3589        assert_eq!(
3590            entry.len(),
3591            55,
3592            "Opus sample entry should be 55 bytes for stereo + dOps minimum"
3593        );
3594    }
3595
3596    /// AudioSampleEntry-level samplerate field inside `Opus` MUST be
3597    /// 48000 << 16 — RFC 7845 §3 mandates 48 kHz internally; emitting
3598    /// the source's nominal rate (e.g. 44100) would mismatch dOps and
3599    /// confuse strict validators.
3600    #[test]
3601    fn opus_sample_entry_samplerate_is_48000_q16() {
3602        let info = AudioInfo {
3603            // Source nominal sample_rate is 44100, but the sample-entry
3604            // and mdhd MUST report 48000.
3605            sample_rate: 44_100,
3606            ..opus_info_stereo_48k()
3607        };
3608        let entry = build_opus_sample_entry(&info);
3609        // Layout offsets inside the sample entry (after the 8-byte box header):
3610        //   reserved[6]+data_ref(2)=8, reserved2(8)=16, channelcount(2)=18,
3611        //   sample_size(2)=20, pre_def(2)=22, reserved3(2)=24,
3612        //   samplerate u32 16.16 at +24..+28.
3613        // So box-relative offset 8 + 24 = 32.
3614        let sr_q16 = u32::from_be_bytes([entry[32], entry[33], entry[34], entry[35]]);
3615        assert_eq!(
3616            sr_q16,
3617            48_000u32 << 16,
3618            "samplerate field MUST be 48000<<16 (Q16); got 0x{:08X}",
3619            sr_q16
3620        );
3621    }
3622
3623    /// `dOps` must nest inside the `Opus` sample entry. The build_audio_stsd
3624    /// dispatcher routes Opus → build_opus_sample_entry → dOps child.
3625    #[test]
3626    fn dops_nests_inside_opus_sample_entry() {
3627        let info = opus_info_stereo_48k();
3628        let entry = build_opus_sample_entry(&info);
3629        let dops_pos = entry
3630            .windows(4)
3631            .position(|w| w == b"dOps")
3632            .expect("dOps child missing inside Opus sample entry");
3633        // dOps must come AFTER the 36-byte AudioSampleEntry preamble.
3634        assert!(
3635            dops_pos > 28,
3636            "dOps must come after the AudioSampleEntry preamble; got pos={}",
3637            dops_pos
3638        );
3639    }
3640
3641    /// stsd dispatcher: AAC info → mp4a; Opus info → Opus. The dispatcher
3642    /// must NEVER produce mp4a for Opus or Opus for AAC.
3643    #[test]
3644    fn stsd_dispatcher_routes_codec_to_correct_sample_entry() {
3645        let aac = AudioInfo {
3646            codec: "aac".into(),
3647            sample_rate: 44_100,
3648            channels: 2,
3649            timescale: 44_100,
3650            asc_bytes: vec![0x12, 0x10],
3651            codec_private: Vec::new(),
3652        };
3653        let stsd_aac = build_audio_stsd(&aac);
3654        assert!(
3655            stsd_aac.windows(4).any(|w| w == b"mp4a"),
3656            "AAC stsd must contain mp4a"
3657        );
3658        assert!(
3659            !stsd_aac.windows(4).any(|w| w == b"Opus"),
3660            "AAC stsd must NOT contain Opus"
3661        );
3662        assert!(
3663            stsd_aac.windows(4).any(|w| w == b"esds"),
3664            "AAC stsd must contain esds"
3665        );
3666
3667        let opus = opus_info_stereo_48k();
3668        let stsd_opus = build_audio_stsd(&opus);
3669        assert!(
3670            stsd_opus.windows(4).any(|w| w == b"Opus"),
3671            "Opus stsd must contain Opus"
3672        );
3673        assert!(
3674            !stsd_opus.windows(4).any(|w| w == b"mp4a"),
3675            "Opus stsd must NOT contain mp4a"
3676        );
3677        assert!(
3678            stsd_opus.windows(4).any(|w| w == b"dOps"),
3679            "Opus stsd must contain dOps"
3680        );
3681        assert!(
3682            !stsd_opus.windows(4).any(|w| w == b"esds"),
3683            "Opus stsd must NOT contain esds"
3684        );
3685    }
3686
3687    /// Negative output gain (-3 dB Q8 = -768) round-trips correctly through
3688    /// the i16-as-u16 BE conversion.
3689    #[test]
3690    fn dops_handles_negative_output_gain() {
3691        let mut head = opus_head_stereo_48k_preskip_312();
3692        // OutputGain at offset 8..10. Set to -768 (i.e. -3 dB Q8).
3693        let gain: i16 = -768;
3694        head[8..10].copy_from_slice(&gain.to_le_bytes());
3695        let info = AudioInfo {
3696            codec_private: head,
3697            ..opus_info_stereo_48k()
3698        };
3699        let dops = build_dops(&info);
3700        let recovered = i16::from_be_bytes([dops[16], dops[17]]);
3701        assert_eq!(
3702            recovered, -768,
3703            "negative OutputGain must survive LE→BE roundtrip"
3704        );
3705    }
3706
3707    /// PreSkip from the encoder's actual `OPUS_GET_LOOKAHEAD` (often
3708    /// non-default like 156, 312, 480) must round-trip verbatim — we
3709    /// don't normalize to 312.
3710    #[test]
3711    fn dops_preserves_arbitrary_preskip() {
3712        for &expected in &[0u16, 156, 312, 480, 1024, 65535] {
3713            let mut head = opus_head_stereo_48k_preskip_312();
3714            head[2..4].copy_from_slice(&expected.to_le_bytes());
3715            let info = AudioInfo {
3716                codec_private: head,
3717                ..opus_info_stereo_48k()
3718            };
3719            let dops = build_dops(&info);
3720            let got = u16::from_be_bytes([dops[10], dops[11]]);
3721            assert_eq!(got, expected, "PreSkip {} must survive LE→BE", expected);
3722        }
3723    }
3724
3725    // ---- Squad-28: multichannel Opus dOps family=1 ----------------------
3726
3727    /// Build an OpusHead body for an N-channel surround layout per
3728    /// RFC 7845 §5.1. Layout matches what Squad-28's
3729    /// `OpusEncoder::extra_data()` emits and what an MKV/WebM
3730    /// `CodecPrivate` carries verbatim. All multi-byte fields LE.
3731    fn opus_head_surround(
3732        channels: u8,
3733        pre_skip: u16,
3734        input_sample_rate: u32,
3735        streams: u8,
3736        coupled: u8,
3737        mapping: &[u8],
3738    ) -> Vec<u8> {
3739        assert_eq!(mapping.len(), channels as usize);
3740        let mut h = Vec::with_capacity(11 + 2 + channels as usize);
3741        h.push(1u8); // Version
3742        h.push(channels);
3743        h.extend_from_slice(&pre_skip.to_le_bytes());
3744        h.extend_from_slice(&input_sample_rate.to_le_bytes());
3745        h.extend_from_slice(&0i16.to_le_bytes()); // OutputGain
3746        h.push(1u8); // ChannelMappingFamily=1
3747        h.push(streams);
3748        h.push(coupled);
3749        h.extend_from_slice(mapping);
3750        h
3751    }
3752
3753    fn opus_info_5_1() -> AudioInfo {
3754        // RFC 7845 §5.1.1.2 5.1 layout: streams=4, coupled=2,
3755        // mapping = [0, 4, 1, 2, 3, 5]. PreSkip=312 (typical libopus
3756        // lookahead).
3757        let cp = opus_head_surround(6, 312, 48_000, 4, 2, &[0, 4, 1, 2, 3, 5]);
3758        AudioInfo {
3759            codec: "opus".into(),
3760            sample_rate: 48_000,
3761            channels: 6,
3762            timescale: 48_000,
3763            asc_bytes: Vec::new(),
3764            codec_private: cp,
3765        }
3766    }
3767
3768    /// 5.1 dOps box payload = 11 + 2 + 6 = 19 bytes; with the 8-byte
3769    /// box header the total is 27 bytes. All numeric fields BE inside
3770    /// the box; the trailing channel-mapping bytes are u8 each so no
3771    /// endianness conversion needed.
3772    #[test]
3773    fn dops_box_5_1_payload_is_19_bytes_total_27() {
3774        let info = opus_info_5_1();
3775        let dops = build_dops(&info);
3776        assert_eq!(
3777            dops.len(),
3778            27,
3779            "5.1 dOps box = 8 header + 19 payload = 27 bytes; got {}",
3780            dops.len()
3781        );
3782        let size = u32::from_be_bytes([dops[0], dops[1], dops[2], dops[3]]) as usize;
3783        assert_eq!(size, dops.len());
3784        assert_eq!(&dops[4..8], b"dOps");
3785        // Body
3786        assert_eq!(dops[8], 0, "Version");
3787        assert_eq!(dops[9], 6, "OutputChannelCount = 6 for 5.1");
3788        let pre_skip = u16::from_be_bytes([dops[10], dops[11]]);
3789        assert_eq!(pre_skip, 312);
3790        let isr = u32::from_be_bytes([dops[12], dops[13], dops[14], dops[15]]);
3791        assert_eq!(isr, 48_000);
3792        assert_eq!(i16::from_be_bytes([dops[16], dops[17]]), 0);
3793        assert_eq!(dops[18], 1, "ChannelMappingFamily = 1 for surround");
3794        assert_eq!(dops[19], 4, "StreamCount = 4 for 5.1");
3795        assert_eq!(dops[20], 2, "CoupledCount = 2 for 5.1");
3796        assert_eq!(
3797            &dops[21..27],
3798            &[0u8, 4, 1, 2, 3, 5][..],
3799            "ChannelMapping for 5.1"
3800        );
3801    }
3802
3803    /// 7.1 layout: streams=5, coupled=3, mapping = [0, 6, 1, 2, 3, 4, 5, 7].
3804    /// dOps box = 8 header + 11 preamble + 2 stream/coupled + 8 mapping = 29 bytes.
3805    #[test]
3806    fn dops_box_7_1_payload_is_21_bytes_total_29() {
3807        let cp = opus_head_surround(8, 312, 48_000, 5, 3, &[0, 6, 1, 2, 3, 4, 5, 7]);
3808        let info = AudioInfo {
3809            codec: "opus".into(),
3810            sample_rate: 48_000,
3811            channels: 8,
3812            timescale: 48_000,
3813            asc_bytes: Vec::new(),
3814            codec_private: cp,
3815        };
3816        let dops = build_dops(&info);
3817        assert_eq!(dops.len(), 29);
3818        assert_eq!(dops[18], 1, "Family = 1");
3819        assert_eq!(dops[19], 5, "StreamCount = 5 for 7.1");
3820        assert_eq!(dops[20], 3, "CoupledCount = 3 for 7.1");
3821        assert_eq!(&dops[21..29], &[0u8, 6, 1, 2, 3, 4, 5, 7][..]);
3822    }
3823
3824    /// Hex-dump the 5.1 dOps box for the deliverables report.
3825    #[test]
3826    fn dops_box_5_1_hex_dump() {
3827        let info = opus_info_5_1();
3828        let dops = build_dops(&info);
3829        let hex: String = dops.iter().map(|b| format!("{b:02x} ")).collect();
3830        println!("5.1 dOps box hex (27 bytes total): {}", hex.trim_end());
3831    }
3832
3833    /// `Opus` sample entry containing a family-1 dOps for 5.1. Total
3834    /// size = 36 (sample-entry preamble) + 27 (5.1 dOps) = 63 bytes.
3835    #[test]
3836    fn opus_sample_entry_5_1_size_and_dops_nesting() {
3837        let info = opus_info_5_1();
3838        let entry = build_opus_sample_entry(&info);
3839        assert_eq!(
3840            entry.len(),
3841            36 + 27,
3842            "Opus sample entry for 5.1 = 36 + 27 = 63 bytes; got {}",
3843            entry.len()
3844        );
3845        // Sample-entry channel_count field is at offset 24 inside the
3846        // sample entry (after 8-byte box header + 6 reserved + 2 dri +
3847        // 8 reserved = 24).
3848        let entry_channels = u16::from_be_bytes([entry[24], entry[25]]);
3849        assert_eq!(
3850            entry_channels, 6,
3851            "channel_count in AudioSampleEntry must reflect 5.1"
3852        );
3853        // The dOps child should appear after the 36-byte preamble.
3854        assert!(entry[36..].windows(4).any(|w| w == b"dOps"));
3855        // Family byte inside the dOps child = entry[36 + 8 + 10] = entry[54].
3856        // (8-byte dOps box header + 11-byte preamble offset 10 = family).
3857        assert_eq!(
3858            entry[36 + 8 + 10],
3859            1,
3860            "dOps inside Opus sample entry must carry family=1 for 5.1"
3861        );
3862    }
3863
3864    /// `with_audio()` family=1 validation: stream count + coupled +
3865    /// mapping must all be sane. Each negative case below is rejected
3866    /// loudly with a clear error message.
3867    #[test]
3868    fn with_audio_rejects_family_1_with_truncated_codec_private() {
3869        let mut muxer = Av1Mp4Muxer::new(640, 480, 30.0).unwrap();
3870        let mut info = opus_info_5_1();
3871        // Truncate so the channel-mapping table is missing.
3872        info.codec_private.truncate(13); // header + 2 stream/coupled, no mapping
3873        let err = match muxer.with_audio(info) {
3874            Ok(_) => panic!("truncated family=1 codec_private must reject"),
3875            Err(e) => e,
3876        };
3877        let msg = format!("{}", err);
3878        assert!(
3879            msg.contains("≥") && msg.contains("preamble"),
3880            "error message must explain the size requirement; got: {msg}"
3881        );
3882    }
3883
3884    #[test]
3885    fn with_audio_rejects_family_1_with_zero_streams() {
3886        let mut muxer = Av1Mp4Muxer::new(640, 480, 30.0).unwrap();
3887        let mut info = opus_info_5_1();
3888        // Zero out StreamCount byte (offset 11).
3889        info.codec_private[11] = 0;
3890        let r = muxer.with_audio(info);
3891        assert!(r.is_err(), "StreamCount = 0 must reject");
3892    }
3893
3894    #[test]
3895    fn with_audio_rejects_family_1_with_coupled_exceeding_streams() {
3896        let mut muxer = Av1Mp4Muxer::new(640, 480, 30.0).unwrap();
3897        let mut info = opus_info_5_1();
3898        // Make CoupledCount > StreamCount (offset 12 vs 11).
3899        info.codec_private[11] = 2;
3900        info.codec_private[12] = 5;
3901        let r = muxer.with_audio(info);
3902        assert!(r.is_err(), "CoupledCount > StreamCount must reject");
3903    }
3904
3905    #[test]
3906    fn with_audio_rejects_family_1_with_mapping_index_out_of_range() {
3907        let mut muxer = Av1Mp4Muxer::new(640, 480, 30.0).unwrap();
3908        let mut info = opus_info_5_1();
3909        // Streams=4, coupled=2 → max valid mapping index = 5. Set first
3910        // mapping byte to 99 to force the out-of-range branch.
3911        info.codec_private[13] = 99;
3912        let r = muxer.with_audio(info);
3913        assert!(r.is_err(), "ChannelMapping out-of-range must reject");
3914    }
3915
3916    #[test]
3917    fn with_audio_rejects_family_0_with_5_1_channels() {
3918        let mut muxer = Av1Mp4Muxer::new(640, 480, 30.0).unwrap();
3919        // Build a hand-crafted family-0 head but claim 6 channels.
3920        // Family 0 only supports 1..=2 channels per RFC 7845 §5.1.1.
3921        let mut head = Vec::with_capacity(11);
3922        head.push(1u8);
3923        head.push(6u8);
3924        head.extend_from_slice(&312u16.to_le_bytes());
3925        head.extend_from_slice(&48_000u32.to_le_bytes());
3926        head.extend_from_slice(&0i16.to_le_bytes());
3927        head.push(0u8); // family=0
3928        let info = AudioInfo {
3929            codec: "opus".into(),
3930            sample_rate: 48_000,
3931            channels: 6,
3932            timescale: 48_000,
3933            asc_bytes: Vec::new(),
3934            codec_private: head,
3935        };
3936        let r = muxer.with_audio(info);
3937        assert!(r.is_err(), "family=0 + 6 channels must reject");
3938    }
3939
3940    #[test]
3941    fn with_audio_accepts_5_1_opus() {
3942        let mut muxer = Av1Mp4Muxer::new(640, 480, 30.0).unwrap();
3943        let info = opus_info_5_1();
3944        muxer
3945            .with_audio(info)
3946            .expect("5.1 Opus with valid family=1 trailer must accept");
3947    }
3948
3949    #[test]
3950    fn with_audio_rejects_9_channel_opus() {
3951        let mut muxer = Av1Mp4Muxer::new(640, 480, 30.0).unwrap();
3952        // 9 channels has no defined family-1 layout.
3953        let mut head = Vec::with_capacity(11 + 2 + 9);
3954        head.push(1u8);
3955        head.push(9u8);
3956        head.extend_from_slice(&312u16.to_le_bytes());
3957        head.extend_from_slice(&48_000u32.to_le_bytes());
3958        head.extend_from_slice(&0i16.to_le_bytes());
3959        head.push(1u8); // family=1
3960        head.push(5);
3961        head.push(3);
3962        head.extend_from_slice(&[0u8, 1, 2, 3, 4, 5, 6, 7, 0]);
3963        let info = AudioInfo {
3964            codec: "opus".into(),
3965            sample_rate: 48_000,
3966            channels: 9,
3967            timescale: 48_000,
3968            asc_bytes: Vec::new(),
3969            codec_private: head,
3970        };
3971        let r = muxer.with_audio(info);
3972        assert!(
3973            r.is_err(),
3974            "9-channel Opus must reject (no family-1 layout above 8)"
3975        );
3976    }
3977
3978    // ---- Squad-25: Apple `chan` (Channel Layout) box -----------------------
3979
3980    /// Mono / stereo: no `chan` box (Apple's default layouts are correct).
3981    #[test]
3982    fn chan_box_omitted_for_mono_and_stereo() {
3983        assert!(build_chan_box(1).is_none(), "mono should not emit chan");
3984        assert!(build_chan_box(2).is_none(), "stereo should not emit chan");
3985    }
3986
3987    /// Unsupported channel counts return None — defence-in-depth (the
3988    /// caller's `with_audio` gate already rejects them, so seeing 8/Atmos
3989    /// here means a code path bypassed that gate).
3990    #[test]
3991    fn chan_box_omitted_for_unsupported_counts() {
3992        for &c in &[0u16, 3, 4, 5, 8, 9, 16] {
3993            assert!(
3994                build_chan_box(c).is_none(),
3995                "channels={c} must not emit chan"
3996            );
3997        }
3998    }
3999
4000    /// 5.1 → kAudioChannelLayoutTag_MPEG_5_1_C = (114 << 16) | 6 = 0x00720006.
4001    /// Body layout: tag u32 (4) | bitmap u32 (4) | num_descriptions u32 (4)
4002    /// = 12 bytes. Total box = 8-byte header + 12-byte body = 20 bytes.
4003    #[test]
4004    fn chan_box_5_1_layout_and_size() {
4005        let chan = build_chan_box(6).expect("5.1 must emit chan");
4006        assert_eq!(
4007            chan.len(),
4008            20,
4009            "5.1 chan box must be 20 bytes (8 header + 12 body)"
4010        );
4011        let size = u32::from_be_bytes([chan[0], chan[1], chan[2], chan[3]]);
4012        assert_eq!(
4013            size as usize,
4014            chan.len(),
4015            "size field must equal box length"
4016        );
4017        assert_eq!(&chan[4..8], b"chan", "fourcc must be 'chan'");
4018        let tag = u32::from_be_bytes([chan[8], chan[9], chan[10], chan[11]]);
4019        assert_eq!(
4020            tag, 0x00720006u32,
4021            "5.1 tag must be kAudioChannelLayoutTag_MPEG_5_1_C = 0x00720006; got 0x{tag:08X}"
4022        );
4023        let bitmap = u32::from_be_bytes([chan[12], chan[13], chan[14], chan[15]]);
4024        assert_eq!(bitmap, 0, "mChannelBitmap must be 0 for tag form");
4025        let ndescs = u32::from_be_bytes([chan[16], chan[17], chan[18], chan[19]]);
4026        assert_eq!(
4027            ndescs, 0,
4028            "mNumberChannelDescriptions must be 0 for tag form"
4029        );
4030    }
4031
4032    /// 7.1 → kAudioChannelLayoutTag_MPEG_7_1_C = (127 << 16) | 8 = 0x007F0008.
4033    #[test]
4034    fn chan_box_7_1_layout_and_size() {
4035        let chan = build_chan_box(7).expect("7.1 must emit chan");
4036        assert_eq!(chan.len(), 20);
4037        let tag = u32::from_be_bytes([chan[8], chan[9], chan[10], chan[11]]);
4038        assert_eq!(
4039            tag, 0x007F0008u32,
4040            "7.1 tag must be kAudioChannelLayoutTag_MPEG_7_1_C = 0x007F0008; got 0x{tag:08X}"
4041        );
4042    }
4043
4044    /// `chan` nests inside the `mp4a` AudioSampleEntry (alongside `esds`)
4045    /// per QuickTime File Format Spec. Multichannel mp4a should contain
4046    /// both an esds AND a chan child.
4047    #[test]
4048    fn chan_nests_inside_mp4a_for_5_1() {
4049        // 5.1 ASC: AOT=2 SFI=3 chan=6 → 0x11 0xB0.
4050        let info = AudioInfo {
4051            codec: "aac".into(),
4052            sample_rate: 48_000,
4053            channels: 6,
4054            timescale: 48_000,
4055            asc_bytes: vec![0x11, 0xB0],
4056            codec_private: Vec::new(),
4057        };
4058        let mp4a = build_mp4a(&info);
4059        assert_eq!(&mp4a[4..8], b"mp4a", "outer box must be mp4a");
4060        let chan_pos = mp4a
4061            .windows(4)
4062            .position(|w| w == b"chan")
4063            .expect("multichannel mp4a must contain chan child");
4064        let esds_pos = mp4a
4065            .windows(4)
4066            .position(|w| w == b"esds")
4067            .expect("mp4a must always contain esds child");
4068        // chan should come AFTER esds (we append chan last in build_mp4a).
4069        assert!(
4070            chan_pos > esds_pos,
4071            "chan should come after esds in mp4a (esds @ {}, chan @ {})",
4072            esds_pos,
4073            chan_pos
4074        );
4075    }
4076
4077    /// Stereo mp4a must NOT carry a `chan` box — Apple's default L+R
4078    /// stereo layout is correct without one, and emitting a stereo `chan`
4079    /// would just bloat the output.
4080    #[test]
4081    fn chan_absent_from_stereo_mp4a() {
4082        let info = AudioInfo {
4083            codec: "aac".into(),
4084            sample_rate: 48_000,
4085            channels: 2,
4086            timescale: 48_000,
4087            asc_bytes: vec![0x11, 0x90],
4088            codec_private: Vec::new(),
4089        };
4090        let mp4a = build_mp4a(&info);
4091        assert!(
4092            mp4a.windows(4).all(|w| w != b"chan"),
4093            "stereo mp4a must not contain a chan box"
4094        );
4095    }
4096
4097    // ---- Squad-26: AC-3 + E-AC-3 mux box layout (ETSI TS 102 366 §F) ----
4098
4099    use crate::ac3_sync::{Ac3SyncInfo, Eac3SyncInfo};
4100
4101    /// Canonical 5.1 384 kbps 48 kHz AC-3:
4102    ///   fscod=0, bsid=8, bsmod=0, acmod=7 (3/2), lfeon=1, bit_rate_code=14.
4103    fn ac3_sync_5_1_384k_48k() -> Ac3SyncInfo {
4104        Ac3SyncInfo {
4105            fscod: 0,
4106            bit_rate_code: 14,
4107            bsid: 8,
4108            bsmod: 0,
4109            acmod: 7,
4110            lfeon: true,
4111        }
4112    }
4113
4114    fn ac3_info_5_1_384k() -> AudioInfo {
4115        let body = dac3_body_from_sync(&ac3_sync_5_1_384k_48k());
4116        AudioInfo::ac3(48_000, 6, body.to_vec())
4117    }
4118
4119    /// Vanilla 5.1 E-AC-3 single independent substream, 48 kHz, 384 kbps.
4120    fn eac3_sync_5_1_48k() -> Eac3SyncInfo {
4121        Eac3SyncInfo {
4122            strmtyp: 0,
4123            substreamid: 0,
4124            // frmsiz arbitrary for box-layout tests; choose 191 → frame
4125            // size = 384 bytes which corresponds to 384 kbps @ 48 kHz / 1536
4126            // samples-per-frame.
4127            frmsiz: 191,
4128            fscod: 0,
4129            fscod2: 0,
4130            numblkscod: 3,
4131            acmod: 7,
4132            lfeon: true,
4133            bsid: 16,
4134            dialnorm: 0,
4135            bsmod: 0,
4136        }
4137    }
4138
4139    fn eac3_info_5_1_384k() -> AudioInfo {
4140        // 384 kbps → data_rate field = 192 (the "kbps / 2" encoding).
4141        let body = dec3_body_from_sync(&eac3_sync_5_1_48k(), 192);
4142        AudioInfo::eac3(48_000, 6, body.to_vec())
4143    }
4144
4145    /// `dac3` is exactly 11 bytes total (8-byte box header + 3-byte body).
4146    /// Body field positions per ETSI TS 102 366 §F.4: fscod 2b | bsid 5b |
4147    /// bsmod 3b | acmod 3b | lfeon 1b | bit_rate_code 5b | reserved 5b.
4148    #[test]
4149    fn dac3_box_3_byte_payload_layout() {
4150        let info = ac3_info_5_1_384k();
4151        let dac3 = build_dac3(&info);
4152        assert_eq!(dac3.len(), 11, "dac3 = 8-byte header + 3-byte body");
4153        let size = u32::from_be_bytes([dac3[0], dac3[1], dac3[2], dac3[3]]) as usize;
4154        assert_eq!(size, dac3.len(), "size field equals box length");
4155        assert_eq!(&dac3[4..8], b"dac3", "box type 'dac3'");
4156        // Body bit-extract (24 bits, MSB-first across 3 bytes 8..11).
4157        let raw = ((dac3[8] as u32) << 16) | ((dac3[9] as u32) << 8) | dac3[10] as u32;
4158        assert_eq!((raw >> 22) & 0x03, 0, "fscod = 0 (48 kHz)");
4159        assert_eq!((raw >> 17) & 0x1F, 8, "bsid = 8 (AC-3)");
4160        assert_eq!((raw >> 14) & 0x07, 0, "bsmod = 0");
4161        assert_eq!((raw >> 11) & 0x07, 7, "acmod = 7 (3/2 = 5.1 with LFE)");
4162        assert_eq!((raw >> 10) & 0x01, 1, "lfeon = 1");
4163        assert_eq!((raw >> 5) & 0x1F, 14, "bit_rate_code = 14 (= 384 kbps)");
4164        assert_eq!(raw & 0x1F, 0, "reserved 5 bits = 0");
4165    }
4166
4167    /// `ac-3` AudioSampleEntry per ETSI TS 102 366 §F.2.
4168    /// Total = 36-byte sample-entry preamble + 11-byte dac3 = 47 bytes.
4169    /// 4cc is `ac-3` exactly (with the hyphen at byte index 6 = 0x2D).
4170    #[test]
4171    fn ac3_sample_entry_size_and_fourcc() {
4172        let info = ac3_info_5_1_384k();
4173        let entry = build_ac3_sample_entry(&info);
4174        let size = u32::from_be_bytes([entry[0], entry[1], entry[2], entry[3]]) as usize;
4175        assert_eq!(size, entry.len(), "size field equals box length");
4176        assert_eq!(&entry[4..8], b"ac-3", "4cc MUST be 'ac-3' (with hyphen)");
4177        // Reject the dehyphenated form
4178        assert_ne!(
4179            &entry[4..8],
4180            b"ac3\0",
4181            "4cc 'ac3' (3-char) is non-conformant"
4182        );
4183        assert_eq!(
4184            entry.len(),
4185            47,
4186            "ac-3 sample entry = 36 (preamble) + 11 (dac3)"
4187        );
4188        // dac3 must nest inside.
4189        let dac3_pos = entry
4190            .windows(4)
4191            .position(|w| w == b"dac3")
4192            .expect("dac3 child missing");
4193        assert!(
4194            dac3_pos > 28,
4195            "dac3 must come after AudioSampleEntry preamble"
4196        );
4197        // samplerate field at box-relative offset 8 + 24 = 32.
4198        let sr_q16 = u32::from_be_bytes([entry[32], entry[33], entry[34], entry[35]]);
4199        assert_eq!(sr_q16, 48_000u32 << 16, "samplerate = 48000 << 16 (Q16)");
4200    }
4201
4202    /// `dec3` for a single independent substream (Squad-26's scope) is
4203    /// 13 bytes total = 8-byte box header + 5-byte body (no dependent
4204    /// substreams = no chan_loc tail). Body layout per ETSI TS 102 366
4205    /// §F.6.
4206    #[test]
4207    fn dec3_box_5_byte_payload_layout() {
4208        let info = eac3_info_5_1_384k();
4209        let dec3 = build_dec3(&info);
4210        assert_eq!(dec3.len(), 13, "dec3 = 8-byte header + 5-byte body");
4211        let size = u32::from_be_bytes([dec3[0], dec3[1], dec3[2], dec3[3]]) as usize;
4212        assert_eq!(size, dec3.len(), "size field equals box length");
4213        assert_eq!(&dec3[4..8], b"dec3", "box type 'dec3'");
4214        // Body header: data_rate(13) + num_ind_sub-1(3) packed in bytes 8..10.
4215        let header = ((dec3[8] as u16) << 8) | dec3[9] as u16;
4216        let data_rate = (header >> 3) & 0x1FFF;
4217        assert_eq!(data_rate, 192, "data_rate = 192 (= 384 kbps / 2)");
4218        let num_ind_sub_minus_1 = header & 0x07;
4219        assert_eq!(num_ind_sub_minus_1, 0, "single substream → field = 0");
4220        // Per-independent-substream block: bits 16..40 (3 bytes 10..13).
4221        // Layout shifts within the 24-bit window:
4222        //   bit 23..22 fscod
4223        //   bit 21..17 bsid (=16)
4224        //   bit 16     reserved
4225        //   bit 15     asvc
4226        //   bit 14..12 bsmod
4227        //   bit 11..9  acmod
4228        //   bit 8      lfeon
4229        //   bit 7..5   reserved
4230        //   bit 4..1   num_dep_sub (=0)
4231        //   bit 0      reserved
4232        let sub = ((dec3[10] as u32) << 16) | ((dec3[11] as u32) << 8) | dec3[12] as u32;
4233        assert_eq!((sub >> 22) & 0x03, 0, "fscod = 0 (48 kHz)");
4234        assert_eq!((sub >> 17) & 0x1F, 16, "bsid = 16 (E-AC-3 marker)");
4235        assert_eq!((sub >> 12) & 0x07, 0, "bsmod = 0");
4236        assert_eq!((sub >> 9) & 0x07, 7, "acmod = 7 (3/2 = 5.1 with LFE)");
4237        assert_eq!((sub >> 8) & 0x01, 1, "lfeon = 1");
4238        assert_eq!((sub >> 1) & 0x0F, 0, "num_dep_sub = 0 (single substream)");
4239    }
4240
4241    /// `ec-3` AudioSampleEntry per ETSI TS 102 366 §F.5.
4242    /// Total = 36-byte sample-entry preamble + 13-byte dec3 = 49 bytes.
4243    #[test]
4244    fn ec3_sample_entry_size_and_fourcc() {
4245        let info = eac3_info_5_1_384k();
4246        let entry = build_ec3_sample_entry(&info);
4247        let size = u32::from_be_bytes([entry[0], entry[1], entry[2], entry[3]]) as usize;
4248        assert_eq!(size, entry.len(), "size field equals box length");
4249        assert_eq!(&entry[4..8], b"ec-3", "4cc MUST be 'ec-3' (with hyphen)");
4250        assert_eq!(
4251            entry.len(),
4252            49,
4253            "ec-3 sample entry = 36 (preamble) + 13 (dec3)"
4254        );
4255        let dec3_pos = entry
4256            .windows(4)
4257            .position(|w| w == b"dec3")
4258            .expect("dec3 child missing");
4259        assert!(
4260            dec3_pos > 28,
4261            "dec3 must come after AudioSampleEntry preamble"
4262        );
4263    }
4264
4265    /// stsd dispatcher: ac3 info → ac-3 entry; eac3 info → ec-3 entry.
4266    /// Must NOT cross-pollinate with mp4a / Opus.
4267    #[test]
4268    fn stsd_dispatcher_routes_ac3_eac3() {
4269        let stsd_ac3 = build_audio_stsd(&ac3_info_5_1_384k());
4270        assert!(
4271            stsd_ac3.windows(4).any(|w| w == b"ac-3"),
4272            "AC-3 stsd has 'ac-3'"
4273        );
4274        assert!(
4275            stsd_ac3.windows(4).any(|w| w == b"dac3"),
4276            "AC-3 stsd has 'dac3'"
4277        );
4278        assert!(
4279            !stsd_ac3.windows(4).any(|w| w == b"mp4a"),
4280            "AC-3 stsd MUST NOT have mp4a"
4281        );
4282        assert!(
4283            !stsd_ac3.windows(4).any(|w| w == b"Opus"),
4284            "AC-3 stsd MUST NOT have Opus"
4285        );
4286        assert!(
4287            !stsd_ac3.windows(4).any(|w| w == b"esds"),
4288            "AC-3 stsd MUST NOT have esds"
4289        );
4290
4291        let stsd_eac3 = build_audio_stsd(&eac3_info_5_1_384k());
4292        assert!(
4293            stsd_eac3.windows(4).any(|w| w == b"ec-3"),
4294            "E-AC-3 stsd has 'ec-3'"
4295        );
4296        assert!(
4297            stsd_eac3.windows(4).any(|w| w == b"dec3"),
4298            "E-AC-3 stsd has 'dec3'"
4299        );
4300        assert!(
4301            !stsd_eac3.windows(4).any(|w| w == b"mp4a"),
4302            "E-AC-3 stsd MUST NOT have mp4a"
4303        );
4304        assert!(
4305            !stsd_eac3.windows(4).any(|w| w == b"esds"),
4306            "E-AC-3 stsd MUST NOT have esds"
4307        );
4308        assert!(
4309            !stsd_eac3.windows(4).any(|w| w == b"dac3"),
4310            "E-AC-3 stsd MUST NOT have dac3"
4311        );
4312    }
4313
4314    /// `with_audio` must accept a 5.1 AC-3 info and reject obvious shape
4315    /// errors (wrong dac3 body length, wrong sample rate).
4316    #[test]
4317    fn with_audio_accepts_ac3_5_1_and_rejects_bad_shape() {
4318        let mut muxer = Av1Mp4Muxer::new(320, 240, 30.0).unwrap();
4319        muxer
4320            .with_audio(ac3_info_5_1_384k())
4321            .expect("5.1 AC-3 must be accepted");
4322
4323        // Wrong body length
4324        let mut muxer2 = Av1Mp4Muxer::new(320, 240, 30.0).unwrap();
4325        let mut bad = ac3_info_5_1_384k();
4326        bad.codec_private = vec![0u8; 2];
4327        let err = muxer2
4328            .with_audio(bad)
4329            .err()
4330            .expect("must reject 2-byte dac3");
4331        assert!(format!("{err:#}").contains("3 bytes"));
4332
4333        // Wrong sample rate
4334        let mut muxer3 = Av1Mp4Muxer::new(320, 240, 30.0).unwrap();
4335        let bad_sr = AudioInfo {
4336            sample_rate: 22_050,
4337            timescale: 22_050,
4338            ..ac3_info_5_1_384k()
4339        };
4340        let err = muxer3
4341            .with_audio(bad_sr)
4342            .err()
4343            .expect("must reject 22050 for AC-3");
4344        assert!(format!("{err:#}").contains("32000"));
4345    }
4346
4347    /// `with_audio` must accept a single-substream E-AC-3 info and reject
4348    /// an under-sized dec3 body.
4349    #[test]
4350    fn with_audio_accepts_eac3_5_1_and_rejects_short_dec3() {
4351        let mut muxer = Av1Mp4Muxer::new(320, 240, 30.0).unwrap();
4352        muxer
4353            .with_audio(eac3_info_5_1_384k())
4354            .expect("5.1 E-AC-3 must be accepted");
4355
4356        let mut muxer2 = Av1Mp4Muxer::new(320, 240, 30.0).unwrap();
4357        let mut bad = eac3_info_5_1_384k();
4358        bad.codec_private = vec![0u8; 4];
4359        let err = muxer2
4360            .with_audio(bad)
4361            .err()
4362            .expect("must reject short dec3");
4363        assert!(format!("{err:#}").contains("≥5"));
4364    }
4365
4366    /// AC-3 / E-AC-3 channel count gate: must reject >6.
4367    #[test]
4368    fn with_audio_rejects_ac3_more_than_6_channels() {
4369        let mut muxer = Av1Mp4Muxer::new(320, 240, 30.0).unwrap();
4370        let bad = AudioInfo {
4371            channels: 8,
4372            ..ac3_info_5_1_384k()
4373        };
4374        let err = muxer.with_audio(bad).err().expect("must reject 8 channels");
4375        assert!(format!("{err:#}").contains("1..=6"));
4376    }
4377
4378    /// Round-trip: parse a synthetic 5.1 AC-3 sync header → derive dac3
4379    /// body → pack into an `ac-3` sample entry → walk the bytes back out
4380    /// and recover fscod / acmod / lfeon / bit_rate_code unchanged.
4381    #[test]
4382    fn ac3_sync_to_dac3_to_sample_entry_roundtrip() {
4383        let sync = ac3_sync_5_1_384k_48k();
4384        let body = dac3_body_from_sync(&sync);
4385        let info = AudioInfo::ac3(48_000, 6, body.to_vec());
4386        let entry = build_ac3_sample_entry(&info);
4387        // Find dac3 box body (8-byte box header inside the entry then 3
4388        // body bytes).
4389        let dac3_pos = entry.windows(4).position(|w| w == b"dac3").unwrap();
4390        let dac3_body_start = dac3_pos + 4;
4391        let raw = ((entry[dac3_body_start] as u32) << 16)
4392            | ((entry[dac3_body_start + 1] as u32) << 8)
4393            | entry[dac3_body_start + 2] as u32;
4394        assert_eq!((raw >> 22) & 0x03, sync.fscod as u32);
4395        assert_eq!((raw >> 17) & 0x1F, sync.bsid as u32);
4396        assert_eq!((raw >> 11) & 0x07, sync.acmod as u32);
4397        assert_eq!((raw >> 10) & 0x01, sync.lfeon as u32);
4398        assert_eq!((raw >> 5) & 0x1F, sync.bit_rate_code as u32);
4399    }
4400}