diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index 22d71fa..4423ea2 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -550,6 +550,25 @@ impl MkvTrack { field_duration_ns: 0, sample_rate: sr, channels: ch, + // KNOWN GAP, not a deliberate omission. The Matroska Codec + // Specifications say of `A_PCM/INT/BIG` and `A_PCM/INT/LIT` that "the + // audio bit depth MUST be read and set from the `BitDepth` element", + // and raw PCM has no in-band header left to recover it from once + // `LpcmParser` has stripped the BD/DVD framing — so a `Codec::Lpcm` + // track written with 0 here (the serializer then omits BitDepth + // entirely) has no recoverable sample width. + // + // The value is NOT available at this call site: [`AudioStream`] has no + // bit-depth member, and neither of the two places that know it reaches + // here. BD LPCM signals it in the 4-byte ES header (byte 3 bits 7-6) + // that `LpcmParser` discards, and the Blu-ray clip info does not carry + // it at all; DVD LPCM signals it in the IFO audio-attribute block + // (byte 1 bits 7-6, quantization: 16/20/24-bit), which `parse_audio_attr` + // does not read. Closing it means carrying the depth on `AudioStream` + // for the DVD path and a deferred setter fed from the first PES for the + // BD path (the route `hdr10` and `field_order` already take). Guessing + // 16 here would be worse than omitting: it turns a track a player may + // probe or reject into one it confidently misdecodes. bit_depth: 0, dv_config: None, hdr10: None, diff --git a/src/mux/mp4/audio.rs b/src/mux/mp4/audio.rs index cdababe..7812d60 100644 --- a/src/mux/mp4/audio.rs +++ b/src/mux/mp4/audio.rs @@ -5,7 +5,9 @@ //! Atmos-in-DD+ JOC), and **DTS / DTS-HD** (`dtsc`/`dtsh` + `ddts`, describing //! the core with whole access units passed through so an HD decoder finds the //! extension). Config boxes are derived from the first audio frame's bitstream -//! (ISO/IEC 14496-12 amendments; ETSI TS 102 366 / 102 114). Codecs with no +//! (ISO/IEC 14496-12 amendments; ETSI TS 102 366 / 102 114) — so the entry always +//! describes the syntax actually found, and a track the playlist calls DD+ whose +//! first syncframe is a legacy AC-3 one is declared `ac-3`/`dac3`. Codecs with no //! clean MP4 mapping (TrueHD, LPCM, bitmap subtitles) are excluded by the fit //! oracle in the sink. @@ -18,6 +20,15 @@ const FSCOD_RATES: [u32; 3] = [48_000, 44_100, 32_000]; const EAC3_REDUCED_RATES: [u32; 4] = [24_000, 22_050, 16_000, 48_000]; /// Base channel count per `acmod` (A/52 Table 5.8), before the LFE. const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 4, 4, 5]; +/// Lowest `bsid` that identifies an Annex-E (E-AC-3) bitstream: ETSI TS 102 366 +/// Annex E uses bsid 16, while 8 is AC-3 and 9/10 are the AC-3 alternate bit +/// stream syntax of Annex D. Both the parser and the sample-entry chooser read +/// this one constant so they cannot disagree about which syntax was found. +const EAC3_MIN_BSID: u8 = 11; +/// Width of the `dec3` `data_rate` field in bits (ETSI TS 102 366 Annex F.6.1). +const DEC3_DATA_RATE_BITS: u32 = 13; +/// Largest data rate the 13-bit `dec3` `data_rate` field can express, in kbit/s. +const DEC3_MAX_DATA_RATE_KBPS: u16 = (1 << DEC3_DATA_RATE_BITS) - 1; /// A big-endian MSB-first bit reader over a byte slice. struct BitReader<'a> { @@ -78,7 +89,7 @@ pub(super) fn parse_dolby(frame: &[u8]) -> Option { } // bsid lives in byte 5 bits 7-3 for both AC-3 and E-AC-3. let bsid = (f[5] >> 3) & 0x1F; - if bsid >= 11 { + if bsid >= EAC3_MIN_BSID { parse_eac3(f) } else { parse_ac3(f) @@ -188,10 +199,20 @@ pub(super) fn dac3_box(c: &DolbyConfig) -> Vec { /// substream, no dependent substreams: data_rate(13) num_ind_sub(3) then /// fscod(2) bsid(5) reserved(1) asvc(1) bsmod(3) acmod(3) lfeon(1) reserved(3) /// num_dep_sub(4) reserved(1). +/// +/// `data_rate` states the bitstream's rate in kbit/s and must be non-zero; only +/// [`parse_eac3`] computes one, so this box is written only for a config that came +/// from an Annex-E syncframe (see [`dolby_sample_entry`]). pub(super) fn dec3_box(c: &DolbyConfig) -> Vec { let mut v: u64 = 0; let mut push = |val: u64, bits: u32| v = (v << bits) | (val & ((1u64 << bits) - 1)); - push(c.data_rate_kbps as u64, 13); + // Saturate rather than let `push`'s mask wrap a rate that does not fit the + // 13-bit field: a truncated/garbage frame yielding e.g. 9000 kbit/s would + // otherwise be declared as 808. + push( + c.data_rate_kbps.min(DEC3_MAX_DATA_RATE_KBPS) as u64, + DEC3_DATA_RATE_BITS, + ); push(0, 3); // num_ind_sub - 1 = 0 (one substream) push(c.fscod as u64, 2); push(c.bsid as u64, 5); @@ -324,17 +345,24 @@ fn parse_dts(frame: &[u8]) -> Option { /// the Ls/Rs pair, so it is 3 channels and `0x0012`, not 4 and `0x0006`. /// `ddts_channel_layout_speaker_count_matches_declared_channels` pins the /// invariant for all 16 values. +/// The AMODE annotations below name the layout ETSI TS 102 114 §5.3.1 gives for +/// that AMODE and the mask that encodes it. Three of them used to be rotated by +/// one (AMODE 2 labelled "sum/difference", 3 "left/right total", 4 plain "L/R") +/// and AMODE 9 was labelled "5.1 core with LFE" although 0x0007 is the 5.0 mask — +/// LFE is bit 3, OR'd in separately by [`dts_channel_layout`]. Both mislabels +/// invited a "correction" to the VALUES, which are right and are pinned by +/// `ddts_channel_layout_speaker_count_matches_declared_channels`. const DTS_AMODE_LAYOUT: [u16; 16] = [ - 0x0001, // 0 C - 0x0002, // 1 L/R - 0x0002, // 2 L/R (sum/difference) - 0x0002, // 3 L/R (left/right total) - 0x0002, // 4 L/R + 0x0001, // 0 A → C + 0x0002, // 1 A + B (dual mono) → L/R + 0x0002, // 2 L + R (stereo) → L/R + 0x0002, // 3 (L+R) + (L−R) (sum/difference) → L/R + 0x0002, // 4 LT + RT (left/right total) → L/R 0x0003, // 5 C + L/R 0x0012, // 6 L/R + Cs 0x0013, // 7 C + L/R + Cs 0x0006, // 8 L/R + Ls/Rs - 0x0007, // 9 C + L/R + Ls/Rs (5.1 core with LFE) + 0x0007, // 9 C + L/R + Ls/Rs (5.0; the LFE bit is added separately) 0x0206, // 10 L/R + Ls/Rs + Lc/Rc 0x0143, // 11 C + L/R + Lsr/Rsr + Oh 0x0053, // 12 C + L/R + Cs + Lsr/Rsr @@ -405,7 +433,16 @@ fn ddts_box(c: &DtsConfig) -> Vec { push(0, 1); // StereoDownmix push(0, 3); // RepresentationType push(c.channel_layout as u128, 16); - push(c.has_extension as u128, 1); // MultiAssetFlag + // MultiAssetFlag signals more than one audio ASSET in the substream — a parser + // that reads it goes on to select between asset descriptors. `has_extension` + // is a different thing entirely: a DTS-HD MA / HRA track is ONE asset whose + // extension substream carries the XLL/XBR component, so deriving the flag from + // it sent a parser looking for a second asset that does not exist (while + // StreamConstruction, pinned to the core-only value below, simultaneously said + // there was no extension at all — the box contradicting itself). This module + // parses the core header only and never reads the EXSS asset table, so the + // single-asset declaration is the only one it can honestly make. + push(0, 1); // MultiAssetFlag push(0, 1); // LBRDurationMod push(0, 1); // ReservedBoxPresent push(0, 5); // Reserved @@ -431,11 +468,28 @@ pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option { let c = parse_dolby(first_frame)?; + // The config box must describe the syncframe that was actually found, + // not the codec the playlist claimed. When the first syncframe carries + // a legacy AC-3 bsid (≤ 10 — a Dolby Digital compatibility substream + // ahead of the Annex-E substreams, or a misdetected stream), + // `parse_dolby` took the AC-3 path: it has no nominal data rate, so an + // EC3SpecificBox built from it declares data_rate = 0 with an AC-3 + // bit_stream_identification — and ETSI TS 102 366 Annex F.6.1 defines + // data_rate as the bitstream's rate in kbit/s, which 0 is not. Annex F.4 + // assigns the AC3SampleEntry (`ac-3`) + AC3SpecificBox to an AC-3 + // bitstream, so emit that: it describes the parsed frame exactly, and + // whole access units still pass through, so a DD+ decoder finds any + // Annex-E substreams in the samples themselves. + let (fourcc, config): (&[u8; 4], Vec) = if c.bsid >= EAC3_MIN_BSID { + (b"ec-3", dec3_box(&c)) + } else { + (b"ac-3", dac3_box(&c)) + }; Some(audio_sample_entry( - b"ec-3", + fourcc, c.channels, c.sample_rate, - &dec3_box(&c), + &config, )) } Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr => { @@ -502,8 +556,9 @@ mod tests { assert_eq!(&dac3[4..8], b"dac3"); } - #[test] - fn eac3_bsi_and_dec3() { + /// A synthetic Annex-E (E-AC-3) syncframe: bsid=16, fscod=0 (48 kHz), + /// numblkscod=3 (6 blocks), acmod=7 (3/2), lfeon=1 → 5.1, frmsiz=63 (128 B). + fn eac3_frame_5_1() -> Vec { // E-AC-3: syncword | strmtyp/substreamid/frmsiz | fscod/numblks/acmod/lfeon | bsid let mut f = vec![0x0B, 0x77]; f.push(0x00); // strmtyp=0, substreamid=0, frmsiz high=0 @@ -513,6 +568,19 @@ mod tests { // byte5: bsid(5)=16 (E-AC-3) | dialnorm high(3) f.push(0b10000_000); f.push(0x00); + f + } + + /// Decode `data_rate` (the leading 13 bits) back out of an emitted `dec3` + /// box body, so a test measures the FILE and not this module's arithmetic. + fn dec3_data_rate(dec3: &[u8]) -> u16 { + assert_eq!(&dec3[4..8], b"dec3"); + (u16::from_be_bytes([dec3[8], dec3[9]]) >> 3) & 0x1FFF + } + + #[test] + fn eac3_bsi_and_dec3() { + let f = eac3_frame_5_1(); let c = parse_dolby(&f).expect("parsed"); assert!(c.bsid >= 11, "E-AC-3"); assert_eq!(c.bsid, 16); @@ -527,6 +595,75 @@ mod tests { assert_eq!(&dec3[4..8], b"dec3"); } + #[test] + fn ac3plus_with_a_legacy_syncframe_is_declared_ac3_not_a_zero_rate_ec3() { + // A Codec::Ac3Plus track whose first syncframe is a legacy AC-3 frame + // (bsid 8 < 11) is parsed by `parse_ac3`, which has no nominal data rate. + // ETSI TS 102 366 Annex F.6.1 requires the EC3SpecificBox `data_rate` to + // state the bitstream's rate in kbit/s and `bit_stream_identification` to + // be the substream's bsid, so an `ec-3` entry built from that config + // declared 0 kbps for an AC-3 bsid — a decoder configured from it sees a + // zero-bitrate stream. Annex F.4 assigns the AC3SampleEntry ('ac-3') + + // AC3SpecificBox to an AC-3 bitstream: emit the box that matches the + // syncframe that was actually parsed. + let e = dolby_sample_entry(Codec::Ac3Plus, &ac3_frame_5_1()).expect("entry built"); + assert_eq!( + &e[4..8], + b"ac-3", + "a legacy AC-3 syncframe gets the AC3SampleEntry" + ); + assert!( + e.windows(4).any(|w| w == b"dac3"), + "AC3SpecificBox present in the emitted entry" + ); + assert!( + !e.windows(4).any(|w| w == b"dec3"), + "no EC3SpecificBox may describe an AC-3 syncframe" + ); + } + + #[test] + fn ec3_entry_declares_a_nonzero_data_rate_decoded_from_the_box() { + // A real Annex-E syncframe still gets `ec-3` + `dec3`, and the data_rate + // read back out of the emitted box is the frame's nominal rate, not 0. + // 128 B / 1536 samples @ 48 kHz = 32 kbit/s. + let e = dolby_sample_entry(Codec::Ac3Plus, &eac3_frame_5_1()).expect("entry built"); + assert_eq!(&e[4..8], b"ec-3"); + let i = e + .windows(4) + .position(|w| w == b"dec3") + .expect("EC3SpecificBox present"); + let rate = dec3_data_rate(&e[i - 4..]); + assert_ne!( + rate, 0, + "ETSI TS 102 366 Annex F.6.1: data_rate is in kbit/s" + ); + assert_eq!(rate, 32); + } + + #[test] + fn dec3_data_rate_saturates_at_the_13_bit_field_maximum() { + // `data_rate` is a 13-bit field (max 8191 kbit/s). The `& mask` in `push` + // wraps anything larger into a small, wrong rate — 9000 became 808. + let c = DolbyConfig { + fscod: 0, + bsid: 16, + bsmod: 0, + acmod: 7, + lfeon: true, + bit_rate_code: 0, + data_rate_kbps: 9000, + sample_rate: 48_000, + channels: 6, + }; + let rate = dec3_data_rate(&dec3_box(&c)); + assert_eq!( + rate, 8191, + "an out-of-range rate saturates, it does not wrap" + ); + assert_ne!(rate, 808, "9000 & 0x1FFF"); + } + #[test] fn sample_entry_shape() { let c = parse_dolby(&ac3_frame_5_1()).unwrap(); @@ -673,6 +810,37 @@ mod tests { assert_ne!(core_size, 0, "wrapping to 0 declares an empty core frame"); } + #[test] + fn ddts_multi_asset_flag_is_clear_for_a_single_asset_with_an_extension() { + // MultiAssetFlag in the ETSI TS 102 114 DTSSpecificBox signals more than + // one audio ASSET in the substream. A DTS-HD MA track is ONE asset whose + // extension substream carries the XLL component, so deriving the flag + // from "an EXSS sync follows the core" told a parser to go select a + // second asset descriptor that does not exist. This module never parses + // the EXSS asset table, so the only declaration it can make is 0. + let f = vec![ + 0x7F, 0xFE, 0x80, 0x01, 0x00, 0x00, 0x00, 0x80, 0x00, 0x64, 0x58, 0x20, 0x25, + ]; + let c = parse_dts(&f).expect("parses"); + assert!(c.has_extension, "fixture must exercise the extension path"); + + // Decode the flag back out of the emitted box: 8-byte box header, then + // DTSSamplingFrequency(4) + maxBitrate(4) + avgBitrate(4) + + // pcmSampleDepth(1) = 13 bytes, then the 56-bit packed tail. Within the + // tail MultiAssetFlag is bit 48 — after FrameDuration(2) + + // StreamConstruction(5) + CoreLFEPresent(1) + CoreLayout(6) + + // CoreSize(14) + StereoDownmix(1) + RepresentationType(3) + + // ChannelLayout(16) — i.e. the MSB of the tail's 7th byte. + let b = ddts_box(&c); + let tail = &b[8 + 13..]; + assert_eq!(tail.len(), 7, "56-bit packed tail"); + assert_eq!( + (tail[6] >> 7) & 1, + 0, + "MultiAssetFlag must not be set from has_extension" + ); + } + #[test] fn ddts_channel_layout_speaker_count_matches_declared_channels() { // The `ddts` box carries BOTH a channel count and a 16-bit speaker mask, diff --git a/src/mux/mp4/mod.rs b/src/mux/mp4/mod.rs index 9dea766..65f7ece 100644 --- a/src/mux/mp4/mod.rs +++ b/src/mux/mp4/mod.rs @@ -560,8 +560,12 @@ fn audio_sample_durations(samples: &[Sample], timescale: u32) -> Vec { durs } -/// Standard frame rates as `(timescale, sample_duration)` — exact integer ratios -/// so a CFR track has zero accumulated drift. +/// Standard frame rates as `(timescale, sample_duration, fps)` — exact integer +/// ratios so a CFR track has zero accumulated drift. +/// +/// The order of this table is NOT significant: [`detect_rate`] picks the entry +/// nearest the measured rate, so a new rate may be appended anywhere without +/// shadowing an existing one. const STD_RATES: &[(u32, u32, f64)] = &[ (24000, 1001, 23.976), (24, 1, 24.0), @@ -573,6 +577,12 @@ const STD_RATES: &[(u32, u32, f64)] = &[ (60, 1, 60.0), ]; +/// How far the measured rate may sit from a [`STD_RATES`] entry and still snap to +/// it. Half an fps separates every neighbouring pair in the table (23.976/24 are +/// 0.024 apart, so both fall inside one another's window — which is exactly why +/// the match must be nearest-wins, not first-wins). +const RATE_TOLERANCE_FPS: f64 = 0.5; + /// Detect the constant frame rate from the median presentation delta, snapping /// to the nearest standard rate. Falls back to a 90 kHz timescale with a rounded /// duration when nothing matches (non-standard / too few samples). @@ -593,11 +603,24 @@ fn detect_rate(samples: &[Sample]) -> (u32, u32) { deltas.sort_unstable(); let median = deltas[deltas.len() / 2]; let fps = NS as f64 / median as f64; + // Snap to the NEAREST standard rate inside the tolerance window, not the + // first one inside it. First-match made the answer depend on table order: + // every 1000/1001 rate sits within 0.5 fps of its integer twin and precedes + // it, so an exact 24.000 / 30.000 / 60.000 fps source was always declared + // 24000/1001, 30000/1001, 60000/1001 — a 0.1% timing error over the whole + // track. Nearest-match is order-independent, so the fix cannot be undone by + // someone appending a rate to STD_RATES (which is why it is preferred over + // simply reordering the table). + let mut best: Option<(u32, u32, f64)> = None; for &(ts, dur, rate) in STD_RATES { - if (fps - rate).abs() < 0.5 { - return (ts, dur); + let d = (fps - rate).abs(); + if d < RATE_TOLERANCE_FPS && best.is_none_or(|(_, _, best_d)| d < best_d) { + best = Some((ts, dur, d)); } } + if let Some((ts, dur, _)) = best { + return (ts, dur); + } let dur = ((median as i128 * 90_000) / NS as i128).max(1) as u32; (90_000, dur) } @@ -725,29 +748,37 @@ fn build_dinf() -> Vec { bx(b"dinf", &dref) } -/// Colour signalling for the `colr` box (nclx): (primaries, transfer, matrix, -/// full_range). `None` when the stream carries no usable colour info. +/// Colour signalling for the `colr` box (nclx, ISO/IEC 14496-12 §12.1.5): +/// (primaries, transfer, matrix, full_range) as ITU-T H.273 code points. `None` +/// when the stream carries no usable colour info. +/// +/// The code points come from [`crate::mux::mkv::cicp_for_video`] — the single +/// resolver EVERY sink shares (measured bitstream CICP first, then the coarse +/// `ColorSpace` enum with the HDR-driven transfer override). This box must never +/// carry its own copy of that mapping: the copy that used to live here had drifted +/// to hardcode transfer 16 (SMPTE ST 2084 / PQ) for all BT.2020 — tagging an HLG +/// title, whose transfer is 18 (ARIB STD-B67), as PQ — and transfer 6 (BT.601) for +/// BT.470 System B/G, whose transfer is 5. Both disagreed with the MKV sink and +/// the FVI sidecar for the same disc. fn video_colr(stream: &DiscStream) -> Option<(u16, u16, u16, bool)> { let DiscStream::Video(v) = stream else { return None; }; - if let Some(c) = v.measured_cicp { - return Some(( - c.primaries as u16, - c.transfer as u16, - c.matrix as u16, - c.range == 2, - )); + // No measured CICP and no colorimetry from the playlist → nothing usable to + // signal. The shared resolver returns the CICP "unspecified" triple (2/2/2) + // for that case; an ABSENT `colr` box already means exactly that, so omit the + // box rather than write it (unchanged behaviour for this sink). + if v.measured_cicp.is_none() && v.color_space == crate::disc::ColorSpace::Unknown { + return None; } - use crate::disc::ColorSpace::*; - let cicp = match v.color_space { - Bt709 => (1, 1, 1), - Bt2020 => (9, 16, 9), - Bt470bg => (5, 6, 5), - Smpte170m => (6, 6, 6), - Unknown => return None, - }; - Some((cicp.0, cicp.1, cicp.2, false)) + let (matrix, transfer, primaries, range) = crate::mux::mkv::cicp_for_video(v); + Some(( + primaries as u16, + transfer as u16, + matrix as u16, + // MeasuredCicp/Matroska Range: 2 = full, 1 = limited (the disc norm). + range == 2, + )) } /// Video `stbl`: sample entry + `stts`(constant) + `stss` + `ctts` + `stsc` + @@ -1214,4 +1245,171 @@ mod tests { .collect(); assert_eq!(detect_rate(&samples), (24000, 1001)); } + + // ── colr (ITU-T H.273 / CICP) ──────────────────────────────────────────── + + /// Decode `(primaries, transfer, matrix, full_range)` back out of the `colr` + /// nclx box of an emitted visual sample entry, so the assertion is on the + /// bytes that reach the file. `None` when no `colr` box was written. + fn colr_of(v: &VideoStream) -> Option<(u16, u16, u16, bool)> { + // `codec_private` is a byte pattern that cannot itself contain "colr". + let stsd = build_visual_stsd( + Codec::Hevc, + &[0u8; 8], + 1920, + 1080, + video_colr(&DiscStream::Video(v.clone())), + ); + let i = stsd.windows(4).position(|w| w == b"colr")?; + let p = &stsd[i + 4..]; + assert_eq!(&p[..4], b"nclx", "only the nclx colour type is written"); + Some(( + u16::from_be_bytes([p[4], p[5]]), + u16::from_be_bytes([p[6], p[7]]), + u16::from_be_bytes([p[8], p[9]]), + p[10] & 0x80 != 0, + )) + } + + fn video_stream() -> VideoStream { + match hevc_video() { + DiscStream::Video(v) => v, + _ => unreachable!(), + } + } + + #[test] + fn colr_transfer_is_hlg_for_an_hlg_title_not_pq() { + // ITU-T H.273 Table 3: transfer 18 = ARIB STD-B67 (HLG), 16 = SMPTE + // ST 2084 (PQ). `video_colr` hardcoded 16 for every BT.2020 stream, so an + // HLG title got the PQ EOTF applied to it — while the MKV sink of the same + // rip correctly wrote 18. + let mut v = video_stream(); + v.hdr = HdrFormat::Hlg; + v.color_space = ColorSpace::Bt2020; + assert_eq!( + colr_of(&v).expect("colr written"), + (9, 18, 9, false), + "BT.2020 primaries/matrix (9) with the HLG transfer (18)" + ); + } + + #[test] + fn colr_transfer_is_bt470bg_for_a_pal_dvd_not_bt601() { + // ITU-T H.273: transfer 5 = ITU-R BT.470-6 System B/G, 6 = BT.601. + // A PAL DVD is System B/G in all three code points. + let mut v = video_stream(); + v.hdr = HdrFormat::Sdr; + v.color_space = ColorSpace::Bt470bg; + assert_eq!(colr_of(&v).expect("colr written"), (5, 5, 5, false)); + } + + #[test] + fn colr_agrees_with_the_shared_cicp_resolver_for_every_color_space() { + // One resolver, every sink: the `colr` box must carry exactly what + // `mkv::cicp_for_video` returns for the same stream, so an mp4:// rip and + // an mkv:// rip of one title can never describe different colour. + for cs in [ + ColorSpace::Bt709, + ColorSpace::Bt2020, + ColorSpace::Bt470bg, + ColorSpace::Smpte170m, + ] { + for hdr in [ + HdrFormat::Sdr, + HdrFormat::Hdr10, + HdrFormat::Hdr10Plus, + HdrFormat::Hlg, + HdrFormat::DolbyVision, + ] { + let mut v = video_stream(); + v.color_space = cs; + v.hdr = hdr; + let (m, t, p, r) = crate::mux::mkv::cicp_for_video(&v); + assert_eq!( + colr_of(&v).expect("colr written"), + (p as u16, t as u16, m as u16, r == 2), + "colr disagrees with the shared resolver for {cs:?} / {hdr:?}" + ); + } + } + // Unknown colorimetry: no usable colour info, so no `colr` box at all — + // an absent box and an "unspecified" (2/2/2) box mean the same thing, and + // writing nothing is what this sink has always done. + let mut v = video_stream(); + v.color_space = ColorSpace::Unknown; + assert!(colr_of(&v).is_none()); + } + + // ── detect_rate ────────────────────────────────────────────────────────── + + /// Mux a video-only MP4 whose samples are exactly `delta_ns` apart and return + /// the `(mdhd.timescale, stts.sample_delta)` decoded out of the emitted file. + fn muxed_video_timing(delta_ns: i64) -> (u32, u32) { + let t = title(vec![hevc_video()], vec![Some(vec![1, 2, 3, 4])]); + let mut s = Mp4Sink::create(std::io::Cursor::new(Vec::new()), &t).unwrap(); + for i in 0..10i64 { + s.write(&frame(0, i * delta_ns, i == 0, vec![0xAB; 16])) + .unwrap(); + } + s.finish().unwrap(); + let buf = s.writer.into_inner(); + + // One trak → exactly one `mdhd` and one `stts`. + let i = buf.windows(4).position(|w| w == b"mdhd").expect("mdhd"); + // After the type: version+flags(4), creation(8), modification(8), timescale(4). + let timescale = u32::from_be_bytes(buf[i + 24..i + 28].try_into().unwrap()); + let j = buf.windows(4).position(|w| w == b"stts").expect("stts"); + // After the type: version+flags(4), entry_count(4), sample_count(4), sample_delta(4). + let delta = u32::from_be_bytes(buf[j + 16..j + 20].try_into().unwrap()); + (timescale, delta) + } + + #[test] + fn exact_integer_frame_rates_are_not_declared_as_their_fractional_twins() { + // `detect_rate` returned the FIRST STD_RATES entry within 0.5 fps, and each + // 1000/1001 rate precedes its integer twin, so 24.000 / 30.000 / 60.000 + // were always written as 24000/1001, 30000/1001 and 60000/1001. The + // declared timescale/sample_delta is read back out of the muxed file. + for (delta_ns, want) in [ + (41_666_667i64, (24u32, 1u32)), // 24.000 + (33_333_333, (30, 1)), // 30.000 + (16_666_667, (60, 1)), // 60.000 + (40_000_000, (25, 1)), // 25.000 + (20_000_000, (50, 1)), // 50.000 + (41_708_333, (24_000, 1001)), // 23.976 + (33_366_667, (30_000, 1001)), // 29.97 + (16_683_333, (60_000, 1001)), // 59.94 + ] { + assert_eq!( + muxed_video_timing(delta_ns), + want, + "{delta_ns} ns/frame must be declared as {want:?}" + ); + } + } + + #[test] + fn detect_rate_picks_the_nearest_std_rate_regardless_of_table_order() { + // Order-independence is the property that keeps this fixed: every entry + // must resolve to itself when its own exact rate is measured, no matter + // where it sits in STD_RATES. A first-match rule can only satisfy this if + // the table happens to be ordered, which is what broke. + for &(ts, dur, rate) in STD_RATES { + let d = (NS as f64 / rate).round() as i64; + let samples: Vec = (0..10) + .map(|i| Sample { + offset: 0, + size: 1, + pts_ns: i as i64 * d, + keyframe: i == 0, + }) + .collect(); + assert_eq!( + detect_rate(&samples), + (ts, dur), + "{rate} fps must resolve to its own STD_RATES entry" + ); + } + } }