From 82217737e1619c2c947176c9ca57f95815094f7d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:36:14 -0700 Subject: [PATCH 1/4] dvdsub: studio-range YCbCr palette conversion, warn on truncated SPU --- src/mux/codec/dvdsub.rs | 71 +++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/src/mux/codec/dvdsub.rs b/src/mux/codec/dvdsub.rs index 575b5a2d..ab54f932 100644 --- a/src/mux/codec/dvdsub.rs +++ b/src/mux/codec/dvdsub.rs @@ -45,7 +45,15 @@ impl DvdSubParser { // `complete` is only true when `self.pending` was `Some` above, but // borrow through `?` rather than a prod `unwrap()` on a fact the // compiler can't see across the two lines. - let (facts, _, data) = self.pending.take()?; + let (facts, size, data) = self.pending.take()?; + if data.len() < size { + tracing::warn!( + target: "mux", + have = data.len(), + declared = size, + "dvdsub: emitting a truncated subpicture unit" + ); + } // Guaranteed Some: `pending` is only ever created from a PES with // `pts.is_some()` (L054 dropped the no-PTS orphan path), so this // never takes the fallback; kept as unwrap_or, never a prod unwrap. @@ -165,7 +173,6 @@ impl CodecParser for DvdSubParser { // ── YCbCr → RGB conversion and palette formatting ───────────────────────── -// Byte-2-as-Cb is invisible on achromatic (Cb=Cr=128) palette entries. /// Convert a single YCbCr color to RGB, clamping to [0, 255]. /// /// Input: `[padding, Y, Cr, Cb]` (as stored in DVD IFO PGC data). Note the @@ -174,15 +181,17 @@ impl CodecParser for DvdSubParser { /// /// Returns `[R, G, B]`. /// -/// Uses full-range (JFIF) BT.601 coefficients, matching the VobSub `.idx` on-disk convention. +/// The DVD CLUT is studio-range (Y 16..=235, chroma 16..=240), so this applies +/// the limited-range BT.601 expansion: Y=16 maps to black, Y=235 to white. The +/// result is the full-range RGB a VobSub `.idx` palette carries. pub fn ycbcr_to_rgb(color: &[u8; 4]) -> [u8; 3] { - let y = color[1] as f64; - let cr = color[2] as f64; - let cb = color[3] as f64; + let y = 1.164 * (color[1] as f64 - 16.0); + let cr = color[2] as f64 - 128.0; + let cb = color[3] as f64 - 128.0; - let r = y + 1.402 * (cr - 128.0); - let g = y - 0.344 * (cb - 128.0) - 0.714 * (cr - 128.0); - let b = y + 1.772 * (cb - 128.0); + let r = y + 1.596 * cr; + let g = y - 0.392 * cb - 0.813 * cr; + let b = y + 2.017 * cb; [clamp_u8(r), clamp_u8(g), clamp_u8(b)] } @@ -424,22 +433,16 @@ mod tests { #[test] fn ycbcr_to_rgb_white() { - // White in YCbCr: Y=235, Cb=128, Cr=128 → R=235, G=235, B=235 + // Studio-range white: Y=235 at neutral chroma expands to full white. let color = [0x00, 235, 128, 128]; - let [r, g, b] = ycbcr_to_rgb(&color); - assert_eq!(r, 235); - assert_eq!(g, 235); - assert_eq!(b, 235); + assert_eq!(ycbcr_to_rgb(&color), [255, 255, 255]); } #[test] fn ycbcr_to_rgb_black() { - // Black: Y=16, Cb=128, Cr=128 → R=16, G=16, B=16 + // Studio-range black: Y=16 at neutral chroma is true black. let color = [0x00, 16, 128, 128]; - let [r, g, b] = ycbcr_to_rgb(&color); - assert_eq!(r, 16); - assert_eq!(g, 16); - assert_eq!(b, 16); + assert_eq!(ycbcr_to_rgb(&color), [0, 0, 0]); } #[test] @@ -465,9 +468,6 @@ mod tests { // Approximate red: Y=82, Cr=240, Cb=90 — on disc as [pad, Y, Cr, Cb]. let color = [0x00, 82, 240, 90]; let [r, g, b] = ycbcr_to_rgb(&color); - // R = 82 + 1.402*(240-128) = 82 + 156.9 ≈ 239 - // G = 82 - 0.344*(90-128) - 0.714*(240-128) = 82 + 13.1 - 79.97 ≈ 15 - // B = 82 + 1.772*(90-128) = 82 - 67.3 ≈ 15 assert!(r > 200, "R should be high for red, got {}", r); assert!(g < 30, "G should be low for red, got {}", g); assert!(b < 30, "B should be low for red, got {}", b); @@ -486,7 +486,7 @@ mod tests { "on-disc red [0,Y=76,Cr=255,Cb=85] must render red-dominant, \ got R={r} G={g} B={b} (R and B swapped => byte 2/3 are transposed)" ); - assert_eq!([r, g, b], [254, 0, 0], "exact full-range BT.601 red"); + assert_eq!([r, g, b], [255, 0, 0], "exact studio-range BT.601 red"); // And the converse: a saturated BLUE on-disc entry (Y=29, Cr=107, Cb=255) // must not come out red. @@ -545,8 +545,8 @@ mod tests { #[test] fn format_palette_hex_format() { - // Y=128, Cb=128, Cr=128 → R=128, G=128, B=128 → "808080" - let palette = vec![[0x00, 128, 128, 128]]; + // Y=126 at neutral chroma → R=G=B=128 → "808080" + let palette = vec![[0x00, 126, 128, 128]]; let result = format_palette(&palette, 0, 0); let text = String::from_utf8(result).unwrap(); assert_eq!(text, "palette: 808080\n"); @@ -556,7 +556,7 @@ mod tests { fn format_palette_emits_size_line_before_palette() { // With non-zero dimensions the `.idx` `size:` line is prepended ahead of // the palette so players place/scale the VobSub bitmap (PAL 720x576). - let palette = vec![[0x00, 128, 128, 128]]; + let palette = vec![[0x00, 126, 128, 128]]; let result = format_palette(&palette, 720, 576); let text = String::from_utf8(result).unwrap(); assert_eq!( @@ -569,7 +569,7 @@ mod tests { fn format_palette_omits_size_line_when_dimensions_unknown() { // 0 width/height (unknown resolution) omits the size line rather than // emitting a 0x0 frame; the palette line is still present. - let palette = vec![[0x00, 128, 128, 128]]; + let palette = vec![[0x00, 126, 128, 128]]; let result = format_palette(&palette, 0, 576); let text = String::from_utf8(result).unwrap(); assert_eq!(text, "palette: 808080\n", "no size line when a dim is 0"); @@ -717,12 +717,21 @@ mod tests { #[test] fn ycbcr_blue_channel_clamps_high() { - // B = Y + 1.772*(Cb-128). Y=128, Cb=255 → 128 + 1.772*127 ≈ 353 → clamp 255. + // B = 1.164*(Y-16) + 2.017*(Cb-128) ≈ 130 + 256 → clamp 255. // Cb is byte 3 in the on-disc [pad, Y, Cr, Cb] layout. let [_r, _g, b] = ycbcr_to_rgb(&[0x00, 128, 128, 255]); assert_eq!(b, 255, "blue clamps at 255"); } + #[test] + fn ycbcr_to_rgb_green_uses_both_chroma_terms() { + // Y=128, Cr=100, Cb=90: G = 1.164*112 - 0.392*(-38) - 0.813*(-28) = 167.9. + assert_eq!(ycbcr_to_rgb(&[0x00, 128, 100, 90])[1], 168); + // Each chroma term alone: Cr moves G by -0.813/step, Cb by -0.392/step. + assert_eq!(ycbcr_to_rgb(&[0x00, 128, 100, 128])[1], 153); + assert_eq!(ycbcr_to_rgb(&[0x00, 128, 128, 90])[1], 145); + } + #[test] fn format_palette_empty_is_just_prefix() { // An empty palette yields "palette: \n" (prefix + newline, no entries). @@ -733,9 +742,9 @@ mod tests { #[test] fn format_palette_pads_each_channel_to_two_hex_digits() { // Each RGB channel is formatted as exactly 2 hex digits (zero-padded). - // Y=16,neutral → 0x10 → "101010" (each channel two digits). - let result = format_palette(&[[0x00, 16, 128, 128]], 0, 0); - assert_eq!(String::from_utf8(result).unwrap(), "palette: 101010\n"); + // Y=20,neutral → 5 → "050505" (each channel two digits). + let result = format_palette(&[[0x00, 20, 128, 128]], 0, 0); + assert_eq!(String::from_utf8(result).unwrap(), "palette: 050505\n"); } // Text guard in codec/mod.rs can't see `source: facts.source` with no From 7faccaab4b928068882621f779019b838f44fa86 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:40:54 -0700 Subject: [PATCH 2/4] pgs: gap before a PCS drops an END-less set, lost PCS skips its orphans, gap resets the clear scan --- src/disc/pgs_forced_probe.rs | 20 ++++++++- src/mux/codec/pgs.rs | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/disc/pgs_forced_probe.rs b/src/disc/pgs_forced_probe.rs index 58503058..308f8eb5 100644 --- a/src/disc/pgs_forced_probe.rs +++ b/src/disc/pgs_forced_probe.rs @@ -896,6 +896,19 @@ mod tests { let es_off = p + 14; // ES data follows the 14-byte PES header let n = es.len().min(192 - es_off); pkt[es_off..es_off + n].copy_from_slice(&es[..n]); + // Stuff through the adaptation field so the PES ends exactly at its last + // ES byte rather than carrying zero padding after it. + let len = es_off + n - p; + if len < 184 { + let start = 192 - len; + pkt.copy_within(p..p + len, start); + pkt[7] |= 0x20; + pkt[8] = (start - 9) as u8; + if start > 9 { + pkt[9] = 0; + pkt[10..start].fill(0xFF); + } + } pkt } @@ -1675,7 +1688,12 @@ mod tests { // Slot per track so two tracks sharing a sector do not // overwrite each other; both stay on the 192-byte grid. let off = (at - u64::from(lba)) as usize * SECTOR_BYTES + idx * 192; - let pkt = bd_pes_packet(t.pid, (i % 16) as u8, &pcs_display(t.forced)); + // PCS + END: a display set cut off before its END is not + // emitted when a stream gap follows it. + let mut set = pcs_display(t.forced); + set[2] = (set.len() - 3) as u8; + set.extend_from_slice(&[0x80, 0, 0]); + let pkt = bd_pes_packet(t.pid, (i % 16) as u8, &set); if off + pkt.len() <= want { buf[off..off + pkt.len()].copy_from_slice(&pkt); } diff --git a/src/mux/codec/pgs.rs b/src/mux/codec/pgs.rs index 6dabff17..4583f0cb 100644 --- a/src/mux/codec/pgs.rs +++ b/src/mux/codec/pgs.rs @@ -333,6 +333,7 @@ impl CodecParser for PgsParser { // let it fall to the non-PCS arm (would pollute the pending set): close // any pending set with a fallback and drop the header to resync on next PCS. if is_pcs && pes.data.len() <= PCS_NUM_OBJECTS_OFFSET { + self.skip_to_pcs = true; return self.emit_pending(None).into_iter().collect(); } @@ -350,6 +351,17 @@ impl CodecParser for PgsParser { Some(_) => match pts { Some(start) => { self.skip_to_pcs = false; + // A gap before this PCS cut the held set off from its END; + // closing it would emit a malformed display set. + if pes.discontinuity + && !self + .pending + .as_ref() + .is_some_and(|(_, d)| Self::ends_with_end(d)) + { + self.pending = None; + } + self.clear_scan_offset = 0; out.extend(self.emit_pending(Some(start))); // The set's facts are THIS packet's — the one that opened // it. `start` is that packet's PTS by construction. @@ -359,6 +371,7 @@ impl CodecParser for PgsParser { // store it with a 0 sentinel (wrong start, absurd duration). // Flush any prior pending with a fallback and skip this one. None => { + self.skip_to_pcs = true; out.extend(self.emit_pending(None)); } }, @@ -377,6 +390,7 @@ impl CodecParser for PgsParser { out.extend(self.emit_pending(None)); } self.pending = None; + self.clear_scan_offset = 0; self.skip_to_pcs = true; } if self.skip_to_pcs { @@ -634,6 +648,66 @@ mod tests { assert!(parser.flush().is_empty()); } + #[test] + fn pcs_after_gap_drops_an_incomplete_pending_set() { + let mut parser = PgsParser::new(); + let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000))); + let mut next = make_pes(pcs_bytes(1), Some(180000)); + next.discontinuity = true; + assert!( + parser.parse(&next).is_empty(), + "a set cut off before its END is not emitted" + ); + let tail = parser.flush(); + assert_eq!(tail.len(), 1, "the post-gap PCS opens a fresh set"); + assert_eq!(tail[0].pts_ns, 2_000_000_000); + } + + #[test] + fn pcs_after_gap_emits_a_complete_pending_set() { + let mut parser = PgsParser::new(); + let _ = parser.parse(&make_pes(pcs_bytes(1), Some(90000))); + let _ = parser.parse(&make_pes(vec![0x80, 0x00, 0x00], Some(90000))); + let mut next = make_pes(pcs_bytes(1), Some(180000)); + next.discontinuity = true; + let out = parser.parse(&next); + assert_eq!(out.len(), 1); + assert_eq!(out[0].duration_ns, Some(1_000_000_000)); + } + + #[test] + fn segments_after_a_dropped_pcs_are_skipped_until_the_next_pcs() { + let mut short_pcs = pcs_bytes(1); + short_pcs.truncate(PCS_NUM_OBJECTS_OFFSET); + for (dropped, pts) in [(short_pcs, Some(90000)), (pcs_bytes(1), None)] { + let mut parser = PgsParser::new(); + assert!(parser.parse(&make_pes(dropped, pts)).is_empty()); + for seg in [ + vec![0x17, 0x00, 0x00], + vec![0x15, 0x00, 0x02, 0xAA, 0xBB], + vec![0x80, 0x00, 0x00], + ] { + assert!( + parser.parse(&make_pes(seg, Some(90000))).is_empty(), + "orphan segments of a lost PCS are not emitted" + ); + } + let _ = parser.parse(&make_pes(pcs_bytes(1), Some(180000))); + assert_eq!(parser.flush().len(), 1, "the next PCS resyncs"); + } + } + + #[test] + fn a_gap_resets_the_clear_scan_offset() { + let mut parser = PgsParser::new(); + let _ = parser.parse(&make_pes(pcs_bytes(0), Some(90000))); + assert_ne!(parser.clear_scan_offset, 0, "the clear PCS was walked"); + let mut gap = make_pes(vec![0x17, 0x00, 0x00], None); + gap.discontinuity = true; + let _ = parser.parse(&gap); + assert_eq!(parser.clear_scan_offset, 0); + } + #[test] fn pending_buffer_is_capped() { let mut parser = PgsParser::new(); @@ -976,6 +1050,18 @@ mod tests { } } + // The share test multiplies a disc-derived count; it must not wrap at the top of u32. + #[test] + fn the_share_test_does_not_overflow_on_huge_counts() { + assert!(demotable(facts(u32::MAX, 1), true, u32::MAX)); + assert!(demotable(facts(u32::MAX / 2, 1), true, u32::MAX)); + assert!(!demotable( + facts(DEMOTE_MIN_DISPLAY_SETS, 1), + true, + u32::MAX + )); + } + // A track that mixes forced and non-forced sets needs no sibling corroboration: the flag is // in use ON THIS TRACK. #[test] From d3534bafdd331de3ea15babe71cbabfa4b6c1b01 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:14:11 -0700 Subject: [PATCH 3/4] audio codecs: pin ADTS/PCE, DTS CRC/EXSS, LPCM tables, free-format padding, TrueHD LFE; doc and naming fixes --- src/mux/codec/ac3.rs | 6 +- src/mux/codec/adts.rs | 117 +++++++++++++++++++++++++++- src/mux/codec/audio_frames.rs | 4 +- src/mux/codec/dropgate.rs | 9 +++ src/mux/codec/dts.rs | 68 +++++++++++++++- src/mux/codec/flac.rs | 27 +++++++ src/mux/codec/lpcm.rs | 104 +++++++++++++++++++++++++ src/mux/codec/mp2_channels.rs | 24 +++--- src/mux/codec/mp2_channels_tests.rs | 27 ++++--- src/mux/codec/mpegaudio.rs | 25 ++++++ src/mux/codec/truehd.rs | 8 +- 11 files changed, 383 insertions(+), 36 deletions(-) diff --git a/src/mux/codec/ac3.rs b/src/mux/codec/ac3.rs index 5f66e183..add51f5d 100644 --- a/src/mux/codec/ac3.rs +++ b/src/mux/codec/ac3.rs @@ -492,9 +492,9 @@ impl CodecParser for Ac3Parser { ); self.acc.clear(); self.held = None; - // Advance the cadence, as the other two paths out of this block do, - // so all three cannot disagree. Defensive: no input parsing frames - // and leaving a residue this large is currently reachable. + // Advance the cadence like the other two paths out of this block. + // No known input reaches this branch; it must still drop the held + // AU and advance, or a stale HeldAu would resume after the resync. self.flush_pts_ns = frame_pts_ns; } else { self.acc.drain(keep_from); diff --git a/src/mux/codec/adts.rs b/src/mux/codec/adts.rs index 25459165..a7121af9 100644 --- a/src/mux/codec/adts.rs +++ b/src/mux/codec/adts.rs @@ -16,10 +16,10 @@ const ADTS_SAMPLE_RATE_VALID: [u32; 16] = [ 0, ]; -/// ADTS header verdict for the packet head. -// Length of the fixed ADTS header without CRC. +/// Length of the fixed ADTS header without CRC. const ADTS_HEADER_BYTES: usize = 7; +/// ADTS header verdict for the packet head. enum AdtsVerdict { /// No 12-bit ADTS sync at the head — not an ADTS frame we can validate. NoSync, @@ -474,6 +474,119 @@ mod tests { assert_eq!(&asc[2..], &[0x05, 0x04, 0x00, 0x00, 0x20, 0x00]); } + fn bits_to_bytes(bits: &str) -> Vec { + let bits: String = bits.chars().filter(|c| !c.is_whitespace()).collect(); + assert_eq!(bits.len() % 8, 0, "byte-aligned"); + (0..bits.len() / 8) + .map(|i| u8::from_str_radix(&bits[i * 8..i * 8 + 8], 2).unwrap()) + .collect() + } + + fn pad8(bits: &str) -> String { + let bits: String = bits.chars().filter(|c| !c.is_whitespace()).collect(); + let pad = (8 - bits.len() % 8) % 8; + bits + &"0".repeat(pad) + } + + // An ADTS frame with channel_configuration 0 whose payload is `block`. + fn config0_frame(block: &[u8]) -> Vec { + let mut f = adts_frame(0); + f.extend_from_slice(block); + let total = f.len() as u32; + f[2] &= !1; + f[3] = (f[3] & 0x3C) | ((total >> 11) & 3) as u8; + f[4] = (total >> 3) as u8; + f[5] = (((total & 7) << 5) as u8) | 0x1F; + f + } + + // 5.1: 2 front, 1 back, 1 LFE, all three mixdown fields, a 3-byte comment. + const PCE_51_FIELDS: &str = "0000 01 0011 0010 0000 0001 01 000 0000 \ + 1 0001 1 0010 1 101 \ + 01000 10000 10001 0000"; + const PCE_51_COMMENT: &str = "00000011 10101010 01010101 11110000"; + + fn pce_51_block() -> Vec { + bits_to_bytes(&(pad8(&format!("101{PCE_51_FIELDS}")) + &PCE_51_COMMENT.replace(' ', ""))) + } + + #[test] + fn adts_pce_repacks_every_field_to_the_asc_start() { + let expect = bits_to_bytes(&(pad8(PCE_51_FIELDS) + &PCE_51_COMMENT.replace(' ', ""))); + assert_eq!(adts_pce(&pce_51_block()).unwrap(), expect); + } + + #[test] + fn adts_pce_without_mixdown_fields_and_with_assoc_and_cc_elements() { + // 1 front, 1 assoc data, 2 coupling channels: 5*(1+2) + 4*1 element bits. + let fields = "0000 01 0100 0001 0000 0000 00 001 0010 0 0 0 \ + 10000 \ + 0000 \ + 00001 10001"; + let block = bits_to_bytes(&(pad8(&format!("101{fields}")) + "00000000")); + let expect = bits_to_bytes(&(pad8(fields) + "00000000")); + assert_eq!(adts_pce(&block).unwrap(), expect); + } + + #[test] + fn adts_pce_truncated_comment_or_wrong_id_is_none() { + let block = pce_51_block(); + assert_eq!(adts_pce(&block[..block.len() - 1]), None); + let mut not_pce = block; + not_pce[0] ^= 0x80; + assert_eq!(adts_pce(¬_pce), None); + } + + #[test] + fn channel_config_0_asc_follows_the_pce_through_truncation_upgrade_and_change() { + let (mut config, mut changes) = (None, 0); + let block = pce_51_block(); + // A PCE cut off by the frame end leaves the 2-byte ASC. + let cut = config0_frame(&block[..4]); + adts_header(&cut, &mut config, &mut changes, 0).unwrap(); + assert_eq!(config.as_deref().map(<[u8]>::len), Some(2)); + // The first frame that carries the whole PCE upgrades it; not a change. + let full = config0_frame(&block); + adts_header(&full, &mut config, &mut changes, 0).unwrap(); + let asc = config.clone().unwrap(); + assert_eq!(&asc[2..], adts_pce(&block).unwrap()); + assert_eq!(changes, 0); + // A different layout is counted and the first config is kept. + let mut other = block; + other[1] ^= 0x10; + adts_header(&config0_frame(&other), &mut config, &mut changes, 0).unwrap(); + assert_eq!(changes, 1); + assert_eq!(config, Some(asc)); + } + + #[test] + fn adts_template_encodes_the_channel_configuration_across_both_header_bytes() { + // AAC-LC, 48 kHz: ASC byte 0 = 0x11, byte 1 = (rate low bit << 7) | (channels << 3). + for channels in 1u8..=7 { + let t = adts_template(&[0x11, 0x80 | (channels << 3)]).unwrap(); + assert_eq!( + t[2], + 0x40 | (3 << 2) | (channels >> 2), + "channels {channels}" + ); + assert_eq!(t[3], (channels & 3) << 6, "channels {channels}"); + } + assert_eq!(adts_template(&[0x11, 0x80]), None, "0 needs an in-band PCE"); + assert_eq!(adts_template(&[0x11, 0x80 | (8 << 3)]), None); + } + + #[test] + fn adts_frame_length_field_holds_13_bits_and_blocks_are_1_to_4() { + let t = adts_template(&[0x11, 0x90]).unwrap(); + let max = super::adts_frame(t, &vec![0u8; (1 << 13) - 1 - 7], 1).unwrap(); + assert_eq!(max.len(), (1 << 13) - 1); + assert_eq!(adts_frame_len(&max), Some((1 << 13) - 1)); + assert_eq!(super::adts_frame(t, &vec![0u8; (1 << 13) - 7], 1), None); + assert_eq!(super::adts_frame(t, &[0; 4], 0), None); + assert_eq!(super::adts_frame(t, &[0; 4], 5), None); + assert_eq!(super::adts_frame(t, &[0; 4], 4).unwrap()[6] & 3, 3); + } + #[test] fn valid_adts_is_kept() { let mut p = AdtsParser::new(); diff --git a/src/mux/codec/audio_frames.rs b/src/mux/codec/audio_frames.rs index b997fe48..12253ab8 100644 --- a/src/mux/codec/audio_frames.rs +++ b/src/mux/codec/audio_frames.rs @@ -22,6 +22,8 @@ use super::{Frame, PesPacket}; // Most bytes held after a lock while waiting for the next timestamp (see place_lock). const MAX_HOLD: usize = 64 * 1024; +// Most bytes buffered across packets before the stream is judged corrupt and the packet dropped. +const MAX_BUFFERED: usize = 1024 * 1024; pub(super) struct Header { pub bytes: usize, @@ -182,7 +184,7 @@ impl AudioFrames { self.framed = true; // Audio PES packets are small. Refuse pathological accumulation before // copying; a corrupt size must not grow memory without bound. - if self.buf.len().saturating_add(pes.data.len()) > 1024 * 1024 { + if self.buf.len().saturating_add(pes.data.len()) > MAX_BUFFERED { self.buf.clear(); self.tally.record_drop( facts.presentation_ns().unwrap_or(self.next_pts), diff --git a/src/mux/codec/dropgate.rs b/src/mux/codec/dropgate.rs index 8a342245..7a92693a 100644 --- a/src/mux/codec/dropgate.rs +++ b/src/mux/codec/dropgate.rs @@ -168,6 +168,15 @@ mod tests { assert!(!t.is_poisoned()); } + #[test] + fn a_negative_duration_is_counted_as_zero_lost_time() { + let mut t = DropTally::new("test"); + t.record_drop(0, -5_000, 512, "bad"); + t.record_collateral_drop(0, -1, 512, "resync-forward"); + assert_eq!(t.dropped_frames(), 2); + assert_eq!(t.dropped_duration_ns(), 0); + } + #[test] fn poisons_after_min_aus_over_half_dropped() { let mut t = DropTally::new("test"); diff --git a/src/mux/codec/dts.rs b/src/mux/codec/dts.rs index ec723708..31287a3f 100644 --- a/src/mux/codec/dts.rs +++ b/src/mux/codec/dts.rs @@ -23,10 +23,12 @@ pub struct DtsParser { /// its FIRST byte, so an AU whose core arrived in an earlier PES keeps that /// core's timestamp and source offset when its extensions arrive later. acc: super::pesbuf::PesBuf, - /// PTS of the access unit currently being assembled in `buf` (the unit + /// PTS of the access unit currently being assembled in `acc` (the unit /// starting at the first buffered core sync). Captured when that core /// frame's PES first arrived; the trailing extension-substream PES /// packets carry their own (later) PTS which must NOT override it. + /// `PTS_UNSET` after a gap or a forced flush; `front_pts` falls back to it + /// only when the front packet carries no timestamp. pending_pts: i64, /// The `front_pts` of the PREVIOUS emitted access unit. When the current /// AU's `front_pts` differs, it began a new PES → re-base to it. When it is @@ -73,8 +75,8 @@ impl DtsParser { } } - /// Number of access units dropped as undecodable so far. The mux/CLI reads - /// this to surface the count ("dropped N damaged DTS frames"). + /// Number of access units dropped as undecodable so far. Only the tally's + /// end-of-stream summary logs it; no mux or CLI consumer reads this accessor. pub fn dropped_frames(&self) -> u64 { self.tally.dropped_frames() } @@ -2450,6 +2452,66 @@ mod tests { assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmRes)); } + // CPF=1 inserts a 16-bit header CRC before the PCM resolution; the gate must skip exactly + // those bits. PCMR sits at absolute bits 95..=97 without the CRC, 111..=113 with it. + #[test] + fn a_crc_protected_core_header_reads_pcmr_after_the_16_crc_bits() { + let mut crc = make_dts_core(512); + crc[4] |= 0x02; // CPF + // The CRC bits are arbitrary, and set to a reserved code where PCMR sits without it. + crc[11] |= 0x01; + crc[12] = 0xFF; + crc[13] |= 0xFE; + assert_eq!(core_header_drop_reason(&crc), None); + + // Decodable PCMR 3 whose trailing neighbours would read as reserved code 4 if the + // skip ran two bits long. + let mut d = make_dts_core(512); + d[4] |= 0x02; + d[14] |= 0xC0; + assert_eq!(core_header_drop_reason(&d), None); + + // Reserved PCMR 7 is found at the shifted position. + let mut d = make_dts_core(512); + d[4] |= 0x02; + d[13] |= 0x01; + d[14] |= 0xC0; + assert_eq!(core_header_drop_reason(&d), Some(DropReason::PcmRes)); + } + + // Substreams over 64 KiB use the long header form (12-bit header size, 20-bit Fsize). + fn long_form_exss(total: usize, static_fields: Option<(&str, &str)>) -> Vec { + let mut bits = format!( + "{}{}1{}{:020b}", + "0".repeat(8), + "00", + "0".repeat(12), + total - 1 + ); + if let Some((rate, dur)) = static_fields { + bits.push_str(&format!("1{rate}{dur}")); + } + while bits.len() % 8 != 0 { + bits.push('0'); + } + let mut d = vec![0u8; total.max(4 + bits.len() / 8)]; + d[0..4].copy_from_slice(&DTS_HD_EXT_SYNC); + for (i, chunk) in bits.as_bytes().chunks(8).enumerate() { + d[4 + i] = u8::from_str_radix(std::str::from_utf8(chunk).unwrap(), 2).unwrap(); + } + d + } + + #[test] + fn exss_long_header_form_sizes_and_times_substreams_over_64_kib() { + let d = long_form_exss(70_000, Some(("10", "001"))); + assert_eq!(exss_frame_size(&d), Some(70_000)); + assert_eq!(exss_timing(&d), Some((1024, 48_000))); + let d = long_form_exss(1_000_000, None); + assert_eq!(exss_frame_size(&d), Some(1_000_000)); + assert_eq!(exss_timing(&d), None); + } + #[test] fn legal_multichannel_amode_is_not_dropped() { // ETSI TS 102 114 §5.3.1: AMODE is 6 bits with 16 LEGAL codes (0-15), only diff --git a/src/mux/codec/flac.rs b/src/mux/codec/flac.rs index 64796faf..3a7ed143 100644 --- a/src/mux/codec/flac.rs +++ b/src/mux/codec/flac.rs @@ -252,6 +252,33 @@ mod tests { assert_eq!(p.dropped_frames(), before + 1); } + // The blocking-strategy bit (0xFFF9 = variable blocksize) is masked off the sync test. + #[test] + fn variable_blocksize_frames_are_validated_too() { + let mut p = FlacParser::new(); + let mut good = make_flac_frame(100); + good[1] = 0xF9; + let n = good.len(); + let c = crc16_ansi(&good[..n - 2]); + good[n - 2] = (c >> 8) as u8; + good[n - 1] = c as u8; + assert_eq!(p.parse(&make_pes(good.clone(), Some(0))).len(), 1); + let mut bad = good; + bad[20] ^= 0xFF; + assert!(p.parse(&make_pes(bad, Some(90_000))).is_empty()); + assert_eq!(p.dropped_frames(), 1); + } + + #[test] + fn a_set_reserved_bit_is_not_a_flac_sync() { + let mut p = FlacParser::new(); + let mut f = make_flac_frame(100); + f[1] = 0xFA; // the mandatory-0 reserved bit set + f[20] ^= 0xFF; + assert_eq!(p.parse(&make_pes(f, Some(0))).len(), 1, "passed through"); + assert_eq!(p.dropped_frames(), 0); + } + #[test] fn non_flac_packet_passes_through() { // A packet without the FLAC sync isn't a frame we can validate — never diff --git a/src/mux/codec/lpcm.rs b/src/mux/codec/lpcm.rs index 9bfc5c2a..3644e5ba 100644 --- a/src/mux/codec/lpcm.rs +++ b/src/mux/codec/lpcm.rs @@ -966,6 +966,30 @@ mod tests { assert_eq!(got, pcm); } + // Odd counts are coded with a pad channel: the payload_size field and the layout must say so, + // and the parser must read the bytes back. + #[test] + fn bd_payloads_pad_odd_channel_counts_and_round_trip() { + for channels in 1u8..=8 { + let h = bd_header(channels, 48_000, None, 24).unwrap(); + let n = usize::from(channels); + let pcm: Vec = (0..5 * n * 3).map(|i| (i % 251) as u8 + 1).collect(); + let parts = bd_payloads(&pcm, h); + assert_eq!(parts.len(), 1); + let coded = (n + n % 2) * 3; + let p = &parts[0].1; + assert_eq!(p.len(), 4 + 5 * coded, "{channels} ch"); + assert_eq!( + usize::from(u16::from_be_bytes([p[0], p[1]])), + 5 * coded, + "payload_size of {channels} ch" + ); + assert_eq!(&p[2..4], &h); + let got = LpcmParser::new().parse(&make_pes(p.clone(), Some(0))); + assert_eq!(got[0].data, pcm, "{channels} ch round trip"); + } + } + #[test] fn bd_payloads_are_5ms_pes_like_ffmpeg_blurayenc() { // 20000 stereo samples @ 48 kHz -> 83 full 240-sample PES + one of 80. @@ -1091,6 +1115,86 @@ mod tests { } } + // The channel_assignment table: count and the reorder into WAVE channel order, by value. + #[test] + fn bd_channel_assignments_map_to_wave_order() { + // (assignment, source channel values in BD order, expected WAVE-order values) + let cases: [(u8, &[u8], &[u8]); 10] = [ + (1, &[1], &[1]), + (3, &[1, 2], &[1, 2]), + (4, &[1, 2, 3], &[1, 2, 3]), + (5, &[1, 2, 3], &[1, 2, 3]), + (6, &[1, 2, 3, 4], &[1, 2, 3, 4]), + (7, &[1, 2, 3, 4], &[1, 2, 3, 4]), + (8, &[1, 2, 3, 4, 5], &[1, 2, 3, 4, 5]), + (9, &[1, 2, 3, 4, 5, 6], &[1, 2, 3, 6, 4, 5]), + (10, &[1, 2, 3, 4, 5, 6, 7], &[1, 2, 3, 5, 6, 4, 7]), + (11, &[1, 2, 3, 4, 5, 6, 7, 8], &[1, 2, 3, 8, 5, 6, 4, 7]), + ]; + for (assign, src, want) in cases { + // 16-bit, coded channels padded to even. + let mut pcm: Vec = src.iter().flat_map(|&c| [c, c]).collect(); + if src.len() % 2 == 1 { + pcm.extend_from_slice(&[0, 0]); + } + let mut p = LpcmParser::new(); + let f = p.parse(&make_pes(bd(assign, 1, &pcm), Some(0))); + let expect: Vec = want.iter().flat_map(|&c| [c, c]).collect(); + assert_eq!(f[0].data, expect, "assignment {assign}"); + } + } + + // Timeline advance across a PTS-less PES uses the header's sample rate (10 ms of stereo + // 16-bit audio per packet). + #[test] + fn a_ptsless_packet_advances_by_the_headers_sample_rate() { + for (code, hz) in [(1u8, 48_000usize), (4, 96_000), (5, 192_000)] { + let mut pes = vec![0x00, 0x00, (3 << 4) | code, 1 << 6]; + pes.extend(vec![0u8; hz / 100 * 4]); + let mut p = LpcmParser::new(); + p.parse(&make_pes(pes.clone(), Some(0))); + let f = p.parse(&make_pes(pes, None)); + assert_eq!(f[0].pts_ns, 10_000_000, "BD rate code {code} = {hz} Hz"); + } + for (freq, hz) in [(0u8, 48_000usize), (1, 96_000), (2, 44_100), (3, 32_000)] { + let mut pes = vec![0x00, (freq << 4) | 1, 0x80]; + pes.extend(vec![0u8; hz / 100 * 4]); + let mut p = LpcmParser::new_dvd(); + p.parse(&make_pes(pes.clone(), Some(0))); + let f = p.parse(&make_pes(pes, None)); + assert_eq!(f[0].pts_ns, 10_000_000, "DVD rate code {freq} = {hz} Hz"); + } + } + + // The M2TS re-mux header, against the pcm-blurayenc.c tables: assignment for each count, + // rate nibble, and the bits field. + #[test] + fn bd_header_encodes_assignment_rate_and_depth() { + for (channels, assign) in [ + (1u8, 1u8), + (2, 3), + (3, 4), + (4, 6), + (5, 8), + (6, 9), + (7, 10), + (8, 11), + ] { + for (hz, code) in [(48_000u32, 1u8), (96_000, 4), (192_000, 5)] { + assert_eq!( + bd_header(channels, hz, None, 24), + Some([(assign << 4) | code, 0xC0]), + "{channels} ch {hz} Hz" + ); + } + } + assert_eq!(bd_header(2, 48_000, None, 16).unwrap()[1], 0x40); + assert_eq!(bd_header(9, 48_000, None, 24), None); + // A source layout byte is reused only when it agrees with the count and rate. + assert_eq!(bd_header(6, 48_000, Some(0x71), 24), Some([0x91, 0xC0])); + assert_eq!(bd_header(4, 48_000, Some(0x71), 24), Some([0x71, 0xC0])); + } + #[test] fn dvd_bits_needed_is_the_smallest_lossless_depth() { assert_eq!(dvd_bits_needed(&[1, 2, 0, 3, 4, 0]), 16); diff --git a/src/mux/codec/mp2_channels.rs b/src/mux/codec/mp2_channels.rs index f198bdf2..dc4e14ee 100644 --- a/src/mux/codec/mp2_channels.rs +++ b/src/mux/codec/mp2_channels.rs @@ -541,7 +541,7 @@ struct Composite { /// What one stored frame says about its channels. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Frame { +pub(crate) enum ChannelFrame { /// No valid 11172-3 header: not a frame this module can judge. NoHeader, /// A clean base frame whose multichannel extension verified (§2.5.3.1). @@ -553,22 +553,22 @@ pub(crate) enum Frame { } /// Judges one stored frame against 11172-3 and 13818-3. -pub(crate) fn inspect(frame: &[u8]) -> Frame { +pub(crate) fn inspect(frame: &[u8]) -> ChannelFrame { inspect_within(frame, frame.len() * 8) } // As `inspect`, reading no further than bit `end` (tests cut at exact field bits). -fn inspect_within(frame: &[u8], end: usize) -> Frame { +fn inspect_within(frame: &[u8], end: usize) -> ChannelFrame { let Some(h) = Header::parse(frame) else { - return Frame::NoHeader; + return ChannelFrame::NoHeader; }; let nch = h.nch(); let base = match walk_base(frame, &h, end) { Ok(base) => base, Err(why @ (Fallback::FrameCrc | Fallback::Truncated(_))) => { - return Frame::Damaged { nch, why }; + return ChannelFrame::Damaged { nch, why }; } - Err(why) => return Frame::Base { nch, why }, + Err(why) => return ChannelFrame::Base { nch, why }, }; // The frame's own length bounds mc_extension_data_part1 (11172-3 §2.4.3.1 slots). let frame_end = h @@ -576,8 +576,8 @@ fn inspect_within(frame: &[u8], end: usize) -> Frame { .map_or(end, |n| (n * 8).min(end)) .min(frame.len() * 8); match mc_extension(frame, &h, &base, frame_end) { - Ok(mc) => Frame::Multichannel { nch, mc }, - Err(why) => Frame::Base { nch, why }, + Ok(mc) => ChannelFrame::Multichannel { nch, mc }, + Err(why) => ChannelFrame::Base { nch, why }, } } @@ -737,7 +737,7 @@ impl ChannelTracker { } self.frames += 1; match inspect(frame) { - Frame::Multichannel { nch, mc } => { + ChannelFrame::Multichannel { nch, mc } => { self.base = Some(nch); // §2.5.2.13: "'1' extension bit stream present" - the rest is not in the track. let count = if mc.ext_bit_stream_present { @@ -757,18 +757,18 @@ impl ChannelTracker { } } // A clean frame without a verified mc_header (§2.5.3.1) breaks the run. - Frame::Base { nch, why } => { + ChannelFrame::Base { nch, why } => { self.base = Some(nch); self.last = Some(why); self.run = None; } // A failed frame CRC covers the header too, so its nch is not trusted; like a // header-less frame it breaks the run, which must be consecutive. - Frame::Damaged { why, .. } => { + ChannelFrame::Damaged { why, .. } => { self.last = Some(why); self.run = None; } - Frame::NoHeader => self.run = None, + ChannelFrame::NoHeader => self.run = None, } if self.frames >= MAX_FRAMES && self.run.is_none() { self.settled = true; diff --git a/src/mux/codec/mp2_channels_tests.rs b/src/mux/codec/mp2_channels_tests.rs index 06d8307f..dfae5d7f 100644 --- a/src/mux/codec/mp2_channels_tests.rs +++ b/src/mux/codec/mp2_channels_tests.rs @@ -383,7 +383,7 @@ fn mc(centre: u8, surround: u8, lfe: bool) -> Mc { fn multichannel(f: &[u8]) -> Option { match inspect(f) { - Frame::Multichannel { mc, .. } => Some(mc), + ChannelFrame::Multichannel { mc, .. } => Some(mc), _ => None, } } @@ -730,7 +730,7 @@ fn ffmpeg_encoded_frames_walk_to_the_end_of_every_frame() { f.len() * 8 - base.mc_start ); // §2.5.3.1: no valid mc_crc_check in MPEG-1 frames, so no multichannel. - assert!(matches!(inspect(f), Frame::Base { nch: c, .. } if c == nch)); + assert!(matches!(inspect(f), ChannelFrame::Base { nch: c, .. } if c == nch)); n += 1; } } @@ -753,7 +753,10 @@ fn frame_crc_is_checked_when_protection_bit_is_zero() { }; // §2.4.3.1: header bits "starting with bit_rate_index and ending with emphasis" plus the // Table 3-B.5 audio_data bits (allocation and scfsi for Layer II). - assert!(matches!(inspect(&frame(crc)), Frame::Base { nch: 2, .. })); + assert!(matches!( + inspect(&frame(crc)), + ChannelFrame::Base { nch: 2, .. } + )); let bad = Spec { bad_frame_crc: true, ..crc @@ -761,7 +764,7 @@ fn frame_crc_is_checked_when_protection_bit_is_zero() { // "If the words are not identical, a transmission error has occured in the protected field". assert_eq!( inspect(&frame(bad)), - Frame::Damaged { + ChannelFrame::Damaged { nch: 2, why: Fallback::FrameCrc } @@ -770,7 +773,7 @@ fn frame_crc_is_checked_when_protection_bit_is_zero() { flipped[3] ^= 0x01; // emphasis bit: inside the protected header bits assert_eq!( inspect(&flipped), - Frame::Damaged { + ChannelFrame::Damaged { nch: 2, why: Fallback::FrameCrc } @@ -792,7 +795,7 @@ fn multichannel_needs_a_valid_mc_crc_check() { }; assert_eq!( inspect(&frame(bad)), - Frame::Base { + ChannelFrame::Base { nch: 2, why: Fallback::McCrc } @@ -877,7 +880,7 @@ fn centre_10_is_not_defined_and_falls_back() { // §2.5.2.13 centre: "'10' not defined". assert_eq!( inspect(&f), - Frame::Base { + ChannelFrame::Base { nch: 2, why: Fallback::UndefinedCentre } @@ -929,7 +932,7 @@ fn ext_one_mc_data_beyond_the_base_part_cannot_be_verified() { let f = frame(with_mc(STEREO_256, huge)); assert!(matches!( inspect(&f), - Frame::Base { + ChannelFrame::Base { why: Fallback::Truncated(_), .. } @@ -1198,7 +1201,7 @@ fn unsupported_frames_give_the_header_count() { ), ]; for (s, why) in cases { - assert_eq!(inspect(&frame(s)), Frame::Base { nch: 2, why }); + assert_eq!(inspect(&frame(s)), ChannelFrame::Base { nch: 2, why }); } } @@ -1256,13 +1259,13 @@ fn truncation_at_each_field_boundary_names_the_field() { f => f, }); let want = if base_field { - Frame::Damaged { nch: 2, why } + ChannelFrame::Damaged { nch: 2, why } } else { - Frame::Base { nch: 2, why } + ChannelFrame::Base { nch: 2, why } }; assert_eq!(got, want, "{field}"); } - assert_eq!(inspect(&f[..3]), Frame::NoHeader); + assert_eq!(inspect(&f[..3]), ChannelFrame::NoHeader); } /// Opus defect 1: a failed first frame must not lock in the base count. diff --git a/src/mux/codec/mpegaudio.rs b/src/mux/codec/mpegaudio.rs index 463fc2e6..860aadad 100644 --- a/src/mux/codec/mpegaudio.rs +++ b/src/mux/codec/mpegaudio.rs @@ -421,6 +421,31 @@ mod tests { } } + // A learned free-format size excludes padding: a padded frame (padding bit set) is one slot + // longer, so the next header is looked for one byte later. + #[test] + fn free_format_frames_with_alternating_padding_keep_their_exact_bytes() { + let plain = free_frame(); + let mut padded = free_frame(); + padded[2] |= 0x02; // padding bit + padded.push(0xAA); // Layer III padding is one slot + let (plain, padded) = (plain.as_slice(), padded.as_slice()); + // Starting on a padded frame learns the size from a padded spacing. + for frames in [ + [plain, padded, plain, padded, plain, padded], + [padded, plain, padded, plain, padded, plain], + ] { + let mut p = MpegAudioParser::new(); + let mut f = p.parse(&make_pes(frames.concat(), Some(0))); + f.extend(p.flush()); + assert_eq!(f.len(), 6); + for (got, want) in f.iter().zip(frames) { + assert_eq!(got.data, want); + } + assert_eq!(p.dropped_frames(), 0); + } + } + #[test] fn free_format_frame_split_across_pes_reassembles() { let data = free_frame().repeat(2); diff --git a/src/mux/codec/truehd.rs b/src/mux/codec/truehd.rs index 3079695d..f57ab6ab 100644 --- a/src/mux/codec/truehd.rs +++ b/src/mux/codec/truehd.rs @@ -36,9 +36,9 @@ const AU_DURATION_NS: i64 = 833_333; // kHz constant would run ~8.95% fast on these (rare) streams. const AU_DURATION_NS_441: i64 = 907_029; -// Hard cap on the reassembly buffer: a valid AU is well under 32 KiB, so -// past this the stream is malformed — drop and resync. Parity with the -// AC-3/DTS/PGS caps. +// Backstop on the reassembly buffer, parity with the AC-3/DTS/PGS caps. The parse loop +// already drains every complete AU, so the residue never exceeds one AU (8190 bytes) and +// this does not fire today; it bounds memory if that loop changes. const MAX_TRUEHD_BUF: usize = 256 * 1024; pub struct TrueHdParser { @@ -1495,6 +1495,8 @@ mod tests { assert_eq!(truehd_lfe(0x4B), 0, "7.0 has no LFE"); assert_eq!(truehd_lfe(0xF << 15), 1, "6ch LFE bit when 8ch is empty"); assert_eq!(truehd_lfe(0x03 | (0xF << 15)), 0, "8ch wins over 6ch"); + assert_eq!(truehd_lfe(0x1B << 15), 0, "6ch mask with every bit but LFE"); + assert_eq!(truehd_lfe(0x04 << 15), 1, "6ch LFE bit alone"); assert_eq!(truehd_lfe(0), 0); } From f49f966c46976ca761c0c0a155f81607e8b6fc47 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:14:11 -0700 Subject: [PATCH 4/4] video codecs: pin param-set re-assert, SEI, discontinuity, slice types, field pairing, reorder; warn on unparseable HEVC SPS --- src/mux/codec/h264.rs | 92 +++++++++++++++++ src/mux/codec/hevc.rs | 214 +++++++++++++++++++++++++++++++++++++-- src/mux/codec/mpeg2.rs | 60 +++++++++-- src/mux/codec/reorder.rs | 21 ++++ src/mux/codec/vc1.rs | 57 +++++++++++ 5 files changed, 428 insertions(+), 16 deletions(-) diff --git a/src/mux/codec/h264.rs b/src/mux/codec/h264.rs index f240e696..a8423e22 100644 --- a/src/mux/codec/h264.rs +++ b/src/mux/codec/h264.rs @@ -1750,6 +1750,98 @@ mod tests { types } + // The parameter sets a later bare keyframe re-asserts are the ACTIVE ones, not the first-seen + // avcC copy: a player re-applies avcC at every keyframe, so reverting would undo the change. + #[test] + fn a_bare_keyframe_reasserts_the_active_param_sets_not_the_avcc_copy() { + let nals = |fd: &[u8]| -> Vec> { + let mut out = Vec::new(); + let mut off = 0; + while off + 4 <= fd.len() { + let len = + u32::from_be_bytes([fd[off], fd[off + 1], fd[off + 2], fd[off + 3]]) as usize; + out.push(fd[off + 4..off + 4 + len].to_vec()); + off += 4 + len; + } + out + }; + let au = |sps: Option, pps: Option, slice: &[u8]| { + let mut d = Vec::new(); + if let Some(b) = sps { + d.extend_from_slice(&[0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E, b]); + } + if let Some(b) = pps { + d.extend_from_slice(&[0x00, 0x00, 0x01, 0x68, b]); + } + d.extend_from_slice(&[0x00, 0x00, 0x01]); + d.extend_from_slice(slice); + d + }; + let mut parser = H264Parser::new(); + parser.parse(&make_pes( + au(Some(0xAA), Some(0x11), &[0x65, 0x10]), + Some(0), + )); + // Mid-title redefinition of both sets, on a non-keyframe access unit. + let f = parser.parse(&make_pes( + au(Some(0xBB), Some(0x22), &[0x41, 0x9A]), + Some(3000), + )); + assert_eq!(frame_nal_types(&f[0].data), vec![7, 8, 1]); + // A bare IDR: SPS then PPS, both the redefined bodies. + let f = parser.parse(&make_pes(au(None, None, &[0x65, 0x10]), Some(6000))); + let n = nals(&f[0].data); + assert_eq!(frame_nal_types(&f[0].data), vec![7, 8, 5]); + assert_eq!(n[0].last(), Some(&0xBB), "active SPS, not the avcC copy"); + assert_eq!(n[1].last(), Some(&0x22), "active PPS, not the avcC copy"); + } + + #[test] + fn slice_types_map_to_coding_types() { + use CodingType::{B, I, P}; + let expect = [P, B, I, P, I, P, B, I, P, I]; + for (st, want) in expect.into_iter().enumerate() { + assert_eq!(h264_slice_coding_type(st as u32), Some(want), "{st}"); + } + assert_eq!(h264_slice_coding_type(10), None); + assert_eq!(h264_slice_coding_type(u32::MAX), None); + } + + #[test] + fn avcc_oversized_pps_returns_none() { + let mut data = vec![0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E, 0xAA]; + data.extend_from_slice(&[0x00, 0x00, 0x01, 0x68]); + data.extend_from_slice(&vec![0x11u8; 70_000]); + let mut parser = H264Parser::new(); + parser.parse(&make_pes(data, Some(0))); + assert!( + parser.codec_private().is_none(), + "oversized PPS must not produce a truncated avcC" + ); + } + + // ISO 14496-15 §5.3.3.1.2: every profile whose SPS carries chroma_format_idc and bit depths + // gets the 4 avcC extension bytes; Baseline/Main/Extended must not. + #[test] + fn avcc_extension_bytes_follow_exactly_the_chroma_carrying_profiles() { + for profile in [ + 100u8, 110, 122, 144, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135, + ] { + let sps = build_high_profile_sps(profile, 1, 0, 0); + let mut parser = H264Parser::new(); + feed_sps_pps(&mut parser, &sps); + let cp = parser.codec_private().expect("avcC"); + assert_eq!(cp.len(), sps.len() + 14 + 4, "profile {profile}"); + } + for profile in [66u8, 77, 88] { + let sps = build_high_profile_sps(profile, 1, 0, 0); + let mut parser = H264Parser::new(); + feed_sps_pps(&mut parser, &sps); + let cp = parser.codec_private().expect("avcC"); + assert_eq!(cp.len(), sps.len() + 14, "profile {profile}"); + } + } + // An IDR whose SPS changed but whose PPS did not: the re-asserted PPS must follow // the new SPS (§7.3.2.2: a PPS is parsed against its SPS). #[test] diff --git a/src/mux/codec/hevc.rs b/src/mux/codec/hevc.rs index f611a5de..a0bf0dee 100644 --- a/src/mux/codec/hevc.rs +++ b/src/mux/codec/hevc.rs @@ -115,8 +115,8 @@ pub struct HevcParser { /// ids are 0..=63 (H.265 §7.4.3.3); an out-of-range id is ignored. pps_num_extra: [Option; HEVC_MAX_PPS_COUNT], // Splice-aware CRA→BLA rewrite for a non-seamless BD clip boundary (first - // CRA_NUT -> BLA_W_LP so NoRaslOutput discards dangling RASL). Armed by - // `mark_clip_boundary` AND PTS-backstep auto-detect in `parse` — not dead code. + // CRA_NUT -> BLA_W_LP so NoRaslOutput discards dangling RASL). Armed by PTS-backstep + // auto-detect in `parse`, or by the public `mark_clip_boundary` hook (no in-tree caller). pending_clip_boundary: bool, // Highest PES PTS seen, on a monotonic 64-bit timeline (33-bit PTS unwrapped // across 2^33 wraps — see `pts_wrap_offset`). Auto-detects a non-seamless clip @@ -707,14 +707,20 @@ impl CodecParser for HevcParser { // chromaFormat / bit depths — parse real values from the SPS RBSP; a // hardcoded 8-bit 4:2:0 is wrong for 10-bit Main 10 UHD (nearly all UHD // content). Fall back to it only if the SPS can't be parsed. - let chroma = parse_sps_chroma(sps).unwrap_or(SpsChroma { - chroma_format_idc: 1, - bit_depth_luma_minus8: 0, - bit_depth_chroma_minus8: 0, - max_sub_layers_minus1: 0, - temporal_id_nesting_flag: 0, - max_num_reorder_pics: None, - picture_period_ticks: None, + let chroma = parse_sps_chroma(sps).unwrap_or_else(|| { + tracing::warn!( + target: "freemkv::mux::hevc", + "HEVC SPS unparseable; hvcC declares 8-bit 4:2:0" + ); + SpsChroma { + chroma_format_idc: 1, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + max_sub_layers_minus1: 0, + temporal_id_nesting_flag: 0, + max_num_reorder_pics: None, + picture_period_ticks: None, + } }); // chromaFormat (6 reserved bits set + 2-bit chroma_format_idc) record.push(0xFC | (chroma.chroma_format_idc & 0x03)); @@ -782,6 +788,7 @@ struct SpsChroma { /// Reorder depth R of an HEVC SPS NAL (2-byte header included): /// `sps_max_num_reorder_pics[sps_max_sub_layers_minus1]` (H.265 §7.3.2.2 \[I\]). +#[cfg(test)] pub(crate) fn parse_sps_reorder(sps: &[u8]) -> Option { parse_sps_chroma(sps)?.max_num_reorder_pics } @@ -3287,6 +3294,193 @@ mod tests { assert_eq!(cp[18], 0xF8 | 2); } + // PTL and the SPS fields are read off the emulation-prevention-STRIPPED RBSP; a real + // UHD SPS carries `00 00 03` inside general_constraint_indicator_flags. + #[test] + fn hvcc_reads_ptl_and_chroma_through_emulation_prevention_in_the_ptl() { + let sps = make_sps_with_chroma(1, 2, 2); + let mut rbsp = sps[2..].to_vec(); + rbsp[1] = 0x21; // profile + rbsp[2] = 0x60; // compatibility flags 0x60000000 + rbsp[6] = 0x90; // constraint flags 0x900000000000 + rbsp[12] = 0x7B; // level_idc + let escaped = emulation_prevent(&rbsp); + assert!(escaped.len() > rbsp.len(), "the zero runs were escaped"); + let mut stored = sps[..2].to_vec(); + stored.extend_from_slice(&escaped); + + assert_eq!(parse_sps_chroma(&stored).unwrap().chroma_format_idc, 1); + let cp = codec_private_from_sps(&stored); + assert_eq!(cp[1], 0x21); + assert_eq!(&cp[2..6], &[0x60, 0, 0, 0]); + assert_eq!(&cp[6..12], &[0x90, 0, 0, 0, 0, 0]); + assert_eq!(cp[12], 0x7B); + assert_eq!(cp[16], 0xFC | 1); + assert_eq!(cp[17], 0xF8 | 2); + assert_eq!(cp[18], 0xF8 | 2); + } + + // The gap flag on a PES must reach the frame (both emit paths), or the resync gate never + // arms for HEVC. + #[test] + fn pes_discontinuity_propagates_to_frame() { + for reorder in [false, true] { + for flag in [true, false] { + let mut pes = make_pes(cra_au(&[0x10]), Some(0)); + pes.discontinuity = flag; + let mut parser = HevcParser::new().with_ps_reorder(reorder); + let mut frames = parser.parse(&pes); + frames.extend(parser.flush()); + assert_eq!(frames.len(), 1, "reorder {reorder}"); + assert_eq!(frames[0].discontinuity, flag, "reorder {reorder}"); + } + } + } + + // Other SEI messages sharing the NAL (or an HDR10 message already captured) are skipped by + // their declared size, so the wanted message behind them is still found. + #[test] + fn hdr10_sei_is_found_behind_other_and_already_captured_messages() { + let idr = nal_bytes(19, &[0xEC]); + let pps = nal_bytes(NAL_PPS, &[0xC0]); + let mastering = |max_lum| { + sei_message( + SEI_MASTERING_DISPLAY_COLOUR_VOLUME, + &mastering_payload([1, 2, 3], [4, 5, 6], 7, 8, max_lum, 10), + ) + }; + let cll = sei_message(SEI_CONTENT_LIGHT_LEVEL_INFO, &cll_payload(1000, 400)); + let other = |n: usize| sei_message(1, &vec![0x5A; n]); + + let mut one_nal = HevcParser::new(); + let mut au = pps.clone(); + au.extend_from_slice(&sei_nal(&[ + other(5), + mastering(10_000_000), + other(3), + cll.clone(), + ])); + au.extend_from_slice(&idr); + one_nal.parse(&make_pes(au, Some(0))); + assert_eq!( + one_nal + .sei_mastering + .map(|m| m.max_display_mastering_luminance), + Some(10_000_000) + ); + assert!(one_nal.sei_content_light.is_some()); + + let mut later = HevcParser::new(); + let mut first = pps.clone(); + first.extend_from_slice(&sei_nal(&[mastering(10_000_000)])); + first.extend_from_slice(&idr); + later.parse(&make_pes(first, Some(0))); + assert!(later.sei_content_light.is_none()); + let mut second = pps.clone(); + second.extend_from_slice(&sei_nal(&[mastering(1), other(4), cll.clone()])); + second.extend_from_slice(&idr); + later.parse(&make_pes(second, Some(3750))); + assert_eq!( + later + .sei_mastering + .map(|m| m.max_display_mastering_luminance), + Some(10_000_000), + "the repeat is skipped, not adopted" + ); + assert!( + later.sei_content_light.is_some(), + "content light behind a skipped repeat" + ); + } + + #[test] + fn hdr10_sei_in_a_suffix_nal_is_captured() { + let mut sei = sei_nal(&[sei_message( + SEI_CONTENT_LIGHT_LEVEL_INFO, + &cll_payload(1000, 400), + )]); + sei[3] = NAL_SEI_SUFFIX << 1; + let mut au = nal_bytes(NAL_PPS, &[0xC0]); + au.extend_from_slice(&nal_bytes(19, &[0xEC])); + au.extend_from_slice(&sei); + let mut parser = HevcParser::new(); + parser.parse(&make_pes(au, Some(0))); + assert!(parser.sei_content_light.is_some()); + } + + // Only the first slice segment of a picture carries the slice_type this reads; a later + // segment's bits would otherwise be taken for one. + #[test] + fn a_non_first_slice_segment_reports_no_coding_type() { + for nal_type in [1u8, 19] { + let nal = nal_bytes(nal_type, &[0x6C, 0x00]); + // 0x6C = first_slice_segment_in_pic_flag 0, then bits that would decode as an + // I slice (no_output_of_prior_pics 1, pps id 0, slice_type 2) if the flag were ignored. + assert_eq!( + hevc_first_slice_coding_type(&nal[3..], nal_type, |_| Some(0)), + None, + "NAL {nal_type}" + ); + } + } + + #[test] + fn hvcc_oversized_vps_or_pps_returns_none() { + for oversized in [32u8, 34] { + let mut data = Vec::new(); + for t in [32u8, 33, 34] { + data.extend_from_slice(&[0x00, 0x00, 0x01]); + data.extend_from_slice(&hevc_nal_header(t)); + let n = if t == oversized { 70_000 } else { 2 }; + data.extend_from_slice(&vec![0x11u8; n]); + } + let mut parser = HevcParser::new(); + parser.parse(&make_pes(data, Some(0))); + assert!( + parser.codec_private().is_none(), + "oversized NAL type {oversized} must not produce a truncated hvcC" + ); + } + } + + // A bare IRAP re-asserts the ACTIVE parameter sets, not the first-seen hvcC copy: a player + // re-applies hvcC at every keyframe, so reverting would undo a mid-title redefinition. + #[test] + fn a_bare_keyframe_reasserts_the_active_param_sets_not_the_hvcc_copy() { + let au = |sets: Option, slice: Vec| { + let mut d = Vec::new(); + if let Some(b) = sets { + d.extend_from_slice(&nal_bytes(32, &[0xA0, b])); + d.extend_from_slice(&nal_bytes(33, &[0xB0, b])); + d.extend_from_slice(&nal_bytes(NAL_PPS, &[0xC0 | b])); + } + d.extend_from_slice(&slice); + d + }; + let bodies = |fd: &[u8]| -> Vec<(u8, u8)> { + let mut out = Vec::new(); + let mut off = 0; + while off + 4 <= fd.len() { + let len = + u32::from_be_bytes([fd[off], fd[off + 1], fd[off + 2], fd[off + 3]]) as usize; + out.push(((fd[off + 4] >> 1) & 0x3F, fd[off + 4 + len - 1])); + off += 4 + len; + } + out + }; + let mut parser = HevcParser::new(); + parser.parse(&make_pes(au(Some(1), nal_bytes(19, &[0xEC])), Some(0))); + // Redefinition of all three sets on a non-keyframe access unit. + let f = parser.parse(&make_pes(au(Some(2), nal_bytes(1, &[0xD0])), Some(3000))); + assert_eq!(bodies(&f[0].data).len(), 4); + let f = parser.parse(&make_pes(au(None, nal_bytes(19, &[0xEC])), Some(6000))); + assert_eq!( + bodies(&f[0].data), + vec![(32, 2), (33, 2), (34, 0xC2), (19, 0xEC)], + "VPS, SPS, PPS are the redefined ones, in hierarchy order" + ); + } + // --- BitReader unit tests (exp-Golomb + bit reads) --- #[test] diff --git a/src/mux/codec/mpeg2.rs b/src/mux/codec/mpeg2.rs index f2388b38..d9f7f3de 100644 --- a/src/mux/codec/mpeg2.rs +++ b/src/mux/codec/mpeg2.rs @@ -33,13 +33,12 @@ const PICTURE_TYPE_I: u8 = 1; #[cfg(test)] const MAX_AU_BUFFER: usize = 8 * 1024 * 1024; -/// Cap on frames held awaiting the first PES PTS anchor. A DVD stamps a PTS in -/// the first VOBU (~0.5 s ≈ 15 frames); this leaves generous slack. If no PTS -/// ever arrives within the cap, buffered frames are released on a 0 base. +/// Cap on frames buffered in one GOP. A DVD GOP is ~15 frames; a run reaching the +/// cap is force-flushed as its own GOP, and each split re-locks its origin and +/// display-order PTS from the frames it holds. const MAX_PENDING_FRAMES: usize = 600; -// Byte cap on frames held awaiting the first PES PTS anchor; mirrors the AC-3/DTS/PGS byte -// caps. +// Byte cap on one buffered GOP; mirrors the AC-3/DTS/PGS byte caps. const MAX_PENDING_BYTES: usize = 8 * 1024 * 1024; /// Frame rate table (index from sequence header frame_rate_code). @@ -451,7 +450,7 @@ fn picture_coding_flags(au: &[u8]) -> (bool, bool, bool, bool) { /// codec-agnostic [`CodingType`]: 1 → I, 3 → B, else (2 = P, 4 = D) → P. fn coding_type_from_raw(raw: u8) -> CodingType { match raw { - 1 => CodingType::I, + PICTURE_TYPE_I => CodingType::I, 3 => CodingType::B, _ => CodingType::P, } @@ -541,6 +540,55 @@ mod tests { } } + #[test] + fn a_second_top_field_starts_a_new_pair_instead_of_partnering_the_first() { + use crate::mux::codec::coding::FieldOrder; + let mut p = Mpeg2Parser::new(); + let mut frames = Vec::new(); + // top, top, bottom: the bottom field pairs with the SECOND top field. + for (i, (ct, e2)) in [(1u8, 0x01u8), (2, 0x01), (2, 0x02)] + .into_iter() + .enumerate() + { + let mut au = Vec::new(); + if i == 0 { + au.extend_from_slice(&make_seq_header(720, 576, 3, 3)); + } + au.extend_from_slice(&make_picture_header(ct)); + let mut ext = pic_coding_ext(0, 0, 0, false); + ext[6] = e2; + au.extend_from_slice(&ext); + frames.extend(p.parse(&make_pes(au, None))); + } + frames.extend(p.flush()); + assert_eq!(frames.len(), 3); + for f in &frames { + assert_eq!(f.coding.unwrap().field_order(), Some(FieldOrder::Tff)); + } + } + + #[test] + fn a_pts_jump_in_a_later_gop_relocks_the_timeline_origin() { + // 25 fps = 40 ms. GOP 1 anchors at 0; GOP 2 carries a PES PTS of 10 s + // (a clip boundary), so its frames follow it, not GOP 1's extrapolation. + let mut p = Mpeg2Parser::new(); + let mut g1 = make_seq_header(720, 480, 3, 3); + g1.extend_from_slice(&gop()); + g1.extend_from_slice(&make_picture_header_tr(1, 0)); + g1.extend_from_slice(&[0xAA; 10]); + g1.extend_from_slice(&make_picture_header_tr(2, 1)); + g1.extend_from_slice(&[0xBB; 10]); + let mut frames = p.parse(&make_pes(g1, Some(0))); + let mut g2 = gop(); + g2.extend_from_slice(&make_picture_header_tr(1, 0)); + g2.extend_from_slice(&[0xCC; 10]); + frames.extend(p.parse(&make_pes(g2, Some(10 * 90_000)))); + frames.extend(p.flush()); + assert_eq!(frames.len(), 3); + assert_eq!(frames[1].pts_ns, 40_000_000); + assert_eq!(frames[2].pts_ns, 10_000_000_000); + } + #[test] fn gop_header_resets_stale_lone_field() { use crate::mux::codec::coding::FieldOrder; diff --git a/src/mux/codec/reorder.rs b/src/mux/codec/reorder.rs index 04b6c289..9556667a 100644 --- a/src/mux/codec/reorder.rs +++ b/src/mux/codec/reorder.rs @@ -357,6 +357,27 @@ mod tests { assert_eq!(&got[..4], &[0, dur, 2 * dur, 3 * dur]); } + // The slots between two anchors exclude the held GOP's frames displayed before its own anchor. + #[test] + fn calibration_discounts_the_held_gops_leading_display_offset() { + use CodingType::*; + let dur = 40_000_000i64; + let mut r = SparsePtsReorder::new(); + let mut got: Vec = Vec::new(); + // Open first GOP, decode I B B P: the I displays third (slot 2), the P fourth. + for (k, ct) in [I, B, B, P].into_iter().enumerate() { + let p = (k == 0).then_some(2 * dur); + got.extend(r.push(p, frame(ct, k == 0)).iter().map(|f| f.pts_ns)); + } + // The next GOP's I is anchored two slots after the first anchor. + for (k, ct) in [I, P].into_iter().enumerate() { + let p = (k == 0).then_some(4 * dur); + got.extend(r.push(p, frame(ct, k == 0)).iter().map(|f| f.pts_ns)); + } + got.extend(r.flush().iter().map(|f| f.pts_ns)); + assert_eq!(&got[..4], &[2 * dur, 0, dur, 3 * dur]); + } + #[test] fn no_pts_collisions_within_a_gop() { use CodingType::*; diff --git a/src/mux/codec/vc1.rs b/src/mux/codec/vc1.rs index 23767858..6b19ec75 100644 --- a/src/mux/codec/vc1.rs +++ b/src/mux/codec/vc1.rs @@ -486,6 +486,63 @@ mod tests { assert!(raw_during.iter().filter(|f| f.pts_ns == 0).count() >= 4); } + // The PES PTS anchors the reconstructed display timeline: with anchors 5 frames apart the + // frame duration calibrates to a fifth of the spacing, and each GOP's frames follow. + #[test] + fn vc1_ps_reorder_stamps_display_order_pts_from_the_pes_anchors() { + let seq = [0x00, 0x00, 0x01, SC_SEQUENCE_HEADER, 0xC0, 0, 0, 0, 0, 0]; + let pic = |ptype: u8| vec![0x00, 0x00, 0x01, SC_FRAME, ptype]; + let mut p = Vc1Parser::new().with_ps_reorder(true); + let mut frames = Vec::new(); + for anchor in [0i64, 18_750] { + for (k, ptype) in [0xC0u8, 0x00, 0x80, 0x00, 0x80].into_iter().enumerate() { + let data = if k == 0 { + [&seq[..], &pic(ptype)].concat() + } else { + pic(ptype) + }; + frames.extend(p.parse(&make_pes(data, (k == 0).then_some(anchor)))); + } + } + frames.extend(p.flush()); + let d = 208_333_333 / 5; + let gop = |origin: i64| [0, 2, 1, 4, 3].map(|i| origin + i * d); + let want: Vec = gop(0).into_iter().chain(gop(208_333_333)).collect(); + let got: Vec = frames.iter().map(|f| f.pts_ns).collect(); + assert_eq!(got, want); + } + + // An advanced-profile sequence header may carry `00 00 03` emulation prevention; the + // resolution and INTERLACE bit are read from the de-escaped bytes. + #[test] + fn sequence_header_fields_are_read_through_emulation_prevention() { + // De-escaped: PROFILE 3 byte, 00, then W-1 = 0 (12 bits), H-1 = 31 (12 bits), + // then INTERLACE set at bit 41. Bytes 1..=3 are 00 00 00, so an encoder escapes them. + let sh = [ + 0x00, + 0x00, + 0x01, + SC_SEQUENCE_HEADER, + 0xC0, + 0x00, + 0x00, + 0x03, + 0x00, + 0x1F, + 0x40, + ]; + assert_eq!(parse_vc1_resolution(&sh), Some((2, 64))); + assert_eq!(parse_vc1_interlace(&sh), Some(true)); + } + + #[test] + fn a_pes_ending_in_a_bare_start_code_prefix_passes_through_without_panicking() { + let data = vec![0xAA, 0x00, 0x00, 0x01]; + let f = Vc1Parser::new().parse(&make_pes(data.clone(), Some(0))); + assert_eq!(f.len(), 1); + assert_eq!(f[0].data, data); + } + /// Build a VC-1 PES with sequence header + entry point + frame start code. fn build_vc1_iframe_pes() -> Vec { let mut data = Vec::new();