Fix four MP4 conformance defects against the standards

**dec3 declared a 0 kbit/s AC-3 substream.** parse_dolby routes bsid < 11 to
parse_ac3, which leaves data_rate_kbps = 0 and keeps the AC-3 bsid, yet
dolby_sample_entry wrapped that config in ec-3/dec3 for any Codec::Ac3Plus track.
ETSI TS 102 366 Annex F.4 assigns ac-3/dac3 to an AC-3 bitstream and F.6 assigns
ec-3/dec3 to an Enhanced AC-3 one, so the entry now follows the SYNCFRAME that was
actually parsed, not the playlist's codec label. Computing an AC-3 data rate and
keeping ec-3 was rejected: it fixes one field while bit_stream_identification and
num_dep_sub keep misdescribing the stream. The bsid threshold is hoisted into one
constant so parser and entry-chooser cannot drift. Adjacent defect fixed in the
same box: data_rate is 13 bits from a u16 source, so push's mask WRAPPED anything
above 8191 (9000 became 808); it now saturates.

**colr tagged HLG as PQ, and PAL as BT.601.** video_colr carried a second,
drifted copy of the ColorSpace-to-CICP map: transfer 16 (PQ) for every BT.2020
stream with no HdrFormat override, and 6 (BT.601) for Bt470bg. Per ITU-T H.273
Table 3, HLG is 18 and BT.470-6 System B/G is 5 — and mkv::cicp_for_video already
got both right. video_colr now delegates to that shared resolver, so the
duplicated table is gone and cannot drift again. It keeps only its own decision
about WHETHER to emit the box, since an absent colr and an all-unspecified colr
mean the same thing per ISO/IEC 14496-12.

**Exact 24.000 / 30.000 / 60.000 fps was declared 23.976 / 29.97 / 59.94.**
detect_rate took the FIRST STD_RATES entry within 0.5 fps, and every 1000/1001
entry precedes its integer twin 0.024 fps away — a 0.1% error across the whole
track's mdhd and stts. Fixed as nearest-wins rather than by reordering the table:
reordering fixes today's table and re-breaks the moment someone appends a rate,
while nearest-wins is order-independent. Verified red here independently by
reverting to first-match, which fails exactly the two timing tests.

**ddts MultiAssetFlag was set from has_extension.** In the DTSSpecificBox
(ETSI TS 102 114) that flag signals more than one audio ASSET. A DTS-HD MA/HRA
track is one asset whose extension substream carries the XLL/XBR component, so
setting it from "an EXSS sync follows the core" sent a parser looking for a second
asset descriptor while StreamConstruction simultaneously said there was no
extension — the box contradicting itself. This module parses the core header only
and never reads the EXSS asset table, so 0 is the only honest declaration. A
StreamConstruction index for core+EXSS was deliberately NOT invented: that field
is a table lookup that could not be confirmed against the standard, and a wrong
index is worse than an under-declaration.

DTS_AMODE_LAYOUT's masks were independently re-derived against ETSI TS 102 114
§5.3.1 and all 16 are CORRECT — only three adjacent comments were wrong (the
AMODE 2/3/4 annotations were rotated by one, and AMODE 9's said "5.1 with LFE"
when 0x0007 is the 5.0 mask and LFE is OR'd in separately). Comments corrected.

Every one of the eight new tests decodes the field back OUT of the emitted bytes —
data_rate from the dec3 body's leading 13 bits, MultiAssetFlag from bit 48 of the
ddts tail, colr from the nclx payload inside a real stsd, and the frame rate from
mdhd.timescale plus stts.sample_delta of a fully muxed MP4 — rather than
restating arithmetic. This audit has already caught one of my own tests doing the
latter.

NOT fixed, root-caused and recorded at the site instead: an A_PCM/INT/BIG track
ships with no BitDepth, which the Matroska Codec Specifications make a MUST. The
width exists on disc (BD LPCM signals it in the ES header byte 3, DVD in the IFO
audio attribute byte 1) but neither source reaches MkvTrack::audio, and the fix
needs a new AudioStream member plus a deferred setter in files another agent held
this round. Guessing 16 was rejected — it would confidently misdecode every
24-bit disc — as was refusing the track, which would regress the 16-bit majority
that currently plays by accident.
This commit is contained in:
Matthew Jackson
2026-07-29 20:29:17 -07:00
parent 13897e14f0
commit 9ad68dd092
3 changed files with 421 additions and 36 deletions
+19
View File
@@ -550,6 +550,25 @@ impl MkvTrack {
field_duration_ns: 0, field_duration_ns: 0,
sample_rate: sr, sample_rate: sr,
channels: ch, 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, bit_depth: 0,
dv_config: None, dv_config: None,
hdr10: None, hdr10: None,
+182 -14
View File
@@ -5,7 +5,9 @@
//! Atmos-in-DD+ JOC), and **DTS / DTS-HD** (`dtsc`/`dtsh` + `ddts`, describing //! 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 //! 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 //! 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 //! clean MP4 mapping (TrueHD, LPCM, bitmap subtitles) are excluded by the fit
//! oracle in the sink. //! 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]; 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. /// 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]; 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. /// A big-endian MSB-first bit reader over a byte slice.
struct BitReader<'a> { struct BitReader<'a> {
@@ -78,7 +89,7 @@ pub(super) fn parse_dolby(frame: &[u8]) -> Option<DolbyConfig> {
} }
// bsid lives in byte 5 bits 7-3 for both AC-3 and E-AC-3. // bsid lives in byte 5 bits 7-3 for both AC-3 and E-AC-3.
let bsid = (f[5] >> 3) & 0x1F; let bsid = (f[5] >> 3) & 0x1F;
if bsid >= 11 { if bsid >= EAC3_MIN_BSID {
parse_eac3(f) parse_eac3(f)
} else { } else {
parse_ac3(f) parse_ac3(f)
@@ -188,10 +199,20 @@ pub(super) fn dac3_box(c: &DolbyConfig) -> Vec<u8> {
/// substream, no dependent substreams: data_rate(13) num_ind_sub(3) then /// 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) /// fscod(2) bsid(5) reserved(1) asvc(1) bsmod(3) acmod(3) lfeon(1) reserved(3)
/// num_dep_sub(4) reserved(1). /// 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<u8> { pub(super) fn dec3_box(c: &DolbyConfig) -> Vec<u8> {
let mut v: u64 = 0; let mut v: u64 = 0;
let mut push = |val: u64, bits: u32| v = (v << bits) | (val & ((1u64 << bits) - 1)); 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(0, 3); // num_ind_sub - 1 = 0 (one substream)
push(c.fscod as u64, 2); push(c.fscod as u64, 2);
push(c.bsid as u64, 5); push(c.bsid as u64, 5);
@@ -324,17 +345,24 @@ fn parse_dts(frame: &[u8]) -> Option<DtsConfig> {
/// the Ls/Rs pair, so it is 3 channels and `0x0012`, not 4 and `0x0006`. /// 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 /// `ddts_channel_layout_speaker_count_matches_declared_channels` pins the
/// invariant for all 16 values. /// 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] = [ const DTS_AMODE_LAYOUT: [u16; 16] = [
0x0001, // 0 C 0x0001, // 0 A → C
0x0002, // 1 L/R 0x0002, // 1 A + B (dual mono) → L/R
0x0002, // 2 L/R (sum/difference) 0x0002, // 2 L + R (stereo) → L/R
0x0002, // 3 L/R (left/right total) 0x0002, // 3 (L+R) + (LR) (sum/difference) → L/R
0x0002, // 4 L/R 0x0002, // 4 LT + RT (left/right total) → L/R
0x0003, // 5 C + L/R 0x0003, // 5 C + L/R
0x0012, // 6 L/R + Cs 0x0012, // 6 L/R + Cs
0x0013, // 7 C + L/R + Cs 0x0013, // 7 C + L/R + Cs
0x0006, // 8 L/R + Ls/Rs 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 0x0206, // 10 L/R + Ls/Rs + Lc/Rc
0x0143, // 11 C + L/R + Lsr/Rsr + Oh 0x0143, // 11 C + L/R + Lsr/Rsr + Oh
0x0053, // 12 C + L/R + Cs + Lsr/Rsr 0x0053, // 12 C + L/R + Cs + Lsr/Rsr
@@ -405,7 +433,16 @@ fn ddts_box(c: &DtsConfig) -> Vec<u8> {
push(0, 1); // StereoDownmix push(0, 1); // StereoDownmix
push(0, 3); // RepresentationType push(0, 3); // RepresentationType
push(c.channel_layout as u128, 16); 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); // LBRDurationMod
push(0, 1); // ReservedBoxPresent push(0, 1); // ReservedBoxPresent
push(0, 5); // Reserved push(0, 5); // Reserved
@@ -431,11 +468,28 @@ pub(super) fn dolby_sample_entry(codec: Codec, first_frame: &[u8]) -> Option<Vec
} }
Codec::Ac3Plus => { Codec::Ac3Plus => {
let c = parse_dolby(first_frame)?; 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<u8>) = if c.bsid >= EAC3_MIN_BSID {
(b"ec-3", dec3_box(&c))
} else {
(b"ac-3", dac3_box(&c))
};
Some(audio_sample_entry( Some(audio_sample_entry(
b"ec-3", fourcc,
c.channels, c.channels,
c.sample_rate, c.sample_rate,
&dec3_box(&c), &config,
)) ))
} }
Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr => { Codec::Dts | Codec::DtsHdMa | Codec::DtsHdHr => {
@@ -502,8 +556,9 @@ mod tests {
assert_eq!(&dac3[4..8], b"dac3"); assert_eq!(&dac3[4..8], b"dac3");
} }
#[test] /// A synthetic Annex-E (E-AC-3) syncframe: bsid=16, fscod=0 (48 kHz),
fn eac3_bsi_and_dec3() { /// numblkscod=3 (6 blocks), acmod=7 (3/2), lfeon=1 → 5.1, frmsiz=63 (128 B).
fn eac3_frame_5_1() -> Vec<u8> {
// E-AC-3: syncword | strmtyp/substreamid/frmsiz | fscod/numblks/acmod/lfeon | bsid // E-AC-3: syncword | strmtyp/substreamid/frmsiz | fscod/numblks/acmod/lfeon | bsid
let mut f = vec![0x0B, 0x77]; let mut f = vec![0x0B, 0x77];
f.push(0x00); // strmtyp=0, substreamid=0, frmsiz high=0 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) // byte5: bsid(5)=16 (E-AC-3) | dialnorm high(3)
f.push(0b10000_000); f.push(0b10000_000);
f.push(0x00); 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"); let c = parse_dolby(&f).expect("parsed");
assert!(c.bsid >= 11, "E-AC-3"); assert!(c.bsid >= 11, "E-AC-3");
assert_eq!(c.bsid, 16); assert_eq!(c.bsid, 16);
@@ -527,6 +595,75 @@ mod tests {
assert_eq!(&dec3[4..8], b"dec3"); 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] #[test]
fn sample_entry_shape() { fn sample_entry_shape() {
let c = parse_dolby(&ac3_frame_5_1()).unwrap(); 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"); 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] #[test]
fn ddts_channel_layout_speaker_count_matches_declared_channels() { fn ddts_channel_layout_speaker_count_matches_declared_channels() {
// The `ddts` box carries BOTH a channel count and a 16-bit speaker mask, // The `ddts` box carries BOTH a channel count and a 16-bit speaker mask,
+220 -22
View File
@@ -560,8 +560,12 @@ fn audio_sample_durations(samples: &[Sample], timescale: u32) -> Vec<u32> {
durs durs
} }
/// Standard frame rates as `(timescale, sample_duration)` — exact integer ratios /// Standard frame rates as `(timescale, sample_duration, fps)` — exact integer
/// so a CFR track has zero accumulated drift. /// 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)] = &[ const STD_RATES: &[(u32, u32, f64)] = &[
(24000, 1001, 23.976), (24000, 1001, 23.976),
(24, 1, 24.0), (24, 1, 24.0),
@@ -573,6 +577,12 @@ const STD_RATES: &[(u32, u32, f64)] = &[
(60, 1, 60.0), (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 /// 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 /// to the nearest standard rate. Falls back to a 90 kHz timescale with a rounded
/// duration when nothing matches (non-standard / too few samples). /// duration when nothing matches (non-standard / too few samples).
@@ -593,11 +603,24 @@ fn detect_rate(samples: &[Sample]) -> (u32, u32) {
deltas.sort_unstable(); deltas.sort_unstable();
let median = deltas[deltas.len() / 2]; let median = deltas[deltas.len() / 2];
let fps = NS as f64 / median as f64; 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 { for &(ts, dur, rate) in STD_RATES {
if (fps - rate).abs() < 0.5 { let d = (fps - rate).abs();
return (ts, dur); 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; let dur = ((median as i128 * 90_000) / NS as i128).max(1) as u32;
(90_000, dur) (90_000, dur)
} }
@@ -725,29 +748,37 @@ fn build_dinf() -> Vec<u8> {
bx(b"dinf", &dref) bx(b"dinf", &dref)
} }
/// Colour signalling for the `colr` box (nclx): (primaries, transfer, matrix, /// Colour signalling for the `colr` box (nclx, ISO/IEC 14496-12 §12.1.5):
/// full_range). `None` when the stream carries no usable colour info. /// (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)> { fn video_colr(stream: &DiscStream) -> Option<(u16, u16, u16, bool)> {
let DiscStream::Video(v) = stream else { let DiscStream::Video(v) = stream else {
return None; return None;
}; };
if let Some(c) = v.measured_cicp { // No measured CICP and no colorimetry from the playlist → nothing usable to
return Some(( // signal. The shared resolver returns the CICP "unspecified" triple (2/2/2)
c.primaries as u16, // for that case; an ABSENT `colr` box already means exactly that, so omit the
c.transfer as u16, // box rather than write it (unchanged behaviour for this sink).
c.matrix as u16, if v.measured_cicp.is_none() && v.color_space == crate::disc::ColorSpace::Unknown {
c.range == 2, return None;
));
} }
use crate::disc::ColorSpace::*; let (matrix, transfer, primaries, range) = crate::mux::mkv::cicp_for_video(v);
let cicp = match v.color_space { Some((
Bt709 => (1, 1, 1), primaries as u16,
Bt2020 => (9, 16, 9), transfer as u16,
Bt470bg => (5, 6, 5), matrix as u16,
Smpte170m => (6, 6, 6), // MeasuredCicp/Matroska Range: 2 = full, 1 = limited (the disc norm).
Unknown => return None, range == 2,
}; ))
Some((cicp.0, cicp.1, cicp.2, false))
} }
/// Video `stbl`: sample entry + `stts`(constant) + `stss` + `ctts` + `stsc` + /// Video `stbl`: sample entry + `stts`(constant) + `stss` + `ctts` + `stsc` +
@@ -1214,4 +1245,171 @@ mod tests {
.collect(); .collect();
assert_eq!(detect_rate(&samples), (24000, 1001)); 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<Sample> = (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"
);
}
}
} }