mux: make B1 concealment decode-clean on every gap shape

Closes the three residual holes where a concealed/lost gap could still let
a dangling-reference frame reach the muxer (degraded/undecryptable-disc
path only; clean rips are byte-identical and untouched). Root cause: the
discontinuity signal was reconstructed from the 4-bit continuity counter
and applied per-PES, both of which are lossy.

Three coordinated changes:

1. CC-INDEPENDENT marker. fill_null_ts_unit now tags its NULL packets with
   an adaptation-field discontinuity_indicator; the demuxer recognises a
   0x1FFF packet carrying it as a concealed gap and forces a discontinuity
   on every tracked PID (the lost unit's PID is unknowable). This survives
   a loss that is an exact multiple of 16 packets (CC aliases to in-sequence
   — hole 3) and a loss at a PID's very start (no prior CC — hole 4); it
   also drops any open, potentially-truncated partial PES.

2. PUSI ATTRIBUTION. A gap landing on a PES boundary now flags the PES
   STARTING after it, not the one flushed at the boundary (hole 1) —
   stamping the pre-gap frame could arm-then-disarm the gate on a keyframe
   and admit the real post-gap inter frame.

3. PER-FRAME signal. codec::Frame gains `discontinuity`; each parser
   propagates it onto the first post-gap frame. MPEG-2 buffers whole GOPs
   asynchronously, so it associates the gap by ES OFFSET (like PTS/source),
   landing it on the exact post-gap picture mid-GOP (hole 2) — a per-PES
   flag stamped the previous picture. consume_ts (and the EOF flush drain)
   gate on frame.discontinuity.

Tests: CC-independent marker with in-sequence CC + leading-loss; PUSI
attribution flags the post-gap PES; MPEG-2 offset-mark stamps the post-gap
picture through GOP reorder, not the previous one. Existing B1 gate + EOF
tests still green (2270 lib tests).
This commit is contained in:
Matthew Jackson
2026-06-29 09:39:03 -07:00
parent 71b4b09c93
commit 789b699f95
15 changed files with 396 additions and 82 deletions
+78
View File
@@ -129,6 +129,15 @@ pub struct Mpeg2Parser {
/// each GOP's first PES PTS so video stays in sync with the PES-timestamped
/// audio. None until the first PES timestamp is seen.
origin_pts_ns: Option<i64>,
/// B1: absolute ES offsets at which a concealed/lost-gap PES began, parallel
/// to `pts_marks`/`source_marks` and drained by the SAME mark-drain invariant.
/// MPEG-2 emits whole GOPs asynchronously, so a per-PES flag can't ride
/// through to the right frame (the PES that carries the gap completes the
/// PREVIOUS picture); associating by OFFSET instead stamps `discontinuity` on
/// the access unit whose own bytes begin after the gap — the first post-gap
/// picture — surviving GOP buffering + temporal reorder. The consumer's
/// ResyncGate then arms at that exact picture, mid-GOP if need be.
disc_marks: VecDeque<u64>,
}
/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
@@ -165,6 +174,7 @@ impl Mpeg2Parser {
gop_buf: Vec::new(),
emitted_fields: 0,
origin_pts_ns: None,
disc_marks: VecDeque::new(),
}
}
@@ -223,6 +233,13 @@ impl Mpeg2Parser {
break;
}
}
while let Some(&off) = self.disc_marks.front() {
if off < cutoff {
self.disc_marks.pop_front();
} else {
break;
}
}
}
break;
};
@@ -316,6 +333,11 @@ impl Mpeg2Parser {
if gop_boundary && !self.gop_buf.is_empty() {
self.flush_gop(&mut out);
}
// A concealed-gap mark inside this AU's range [start, end_abs) means
// this picture's own bytes begin after the gap — the first post-gap
// AU. Same front-mark invariant as PTS/source. Carries through GOP
// buffering/reorder to the ResyncGate (which arms at this picture).
let discontinuity = self.disc_marks.front().is_some_and(|&off| off < end_abs);
self.gop_buf.push(BufferedPicture {
tr,
info,
@@ -323,6 +345,7 @@ impl Mpeg2Parser {
frame: Frame {
pts_ns: 0,
keyframe,
discontinuity,
data,
duration_ns: None,
coding: Some(info),
@@ -352,6 +375,13 @@ impl Mpeg2Parser {
break;
}
}
while let Some(&off) = self.disc_marks.front() {
if off < end_abs {
self.disc_marks.pop_front();
} else {
break;
}
}
}
// EOF: emit the final (possibly incomplete) GOP so nothing is dropped.
if force {
@@ -429,6 +459,12 @@ impl CodecParser for Mpeg2Parser {
if let Some(src) = pes.source {
self.source_marks.push_back((off, src));
}
// A concealed/lost gap on this PES marks the access unit its bytes begin —
// associated by offset (like PTS/source) so it lands on the first post-gap
// picture, not the previous one that completes when this PES arrives.
if pes.discontinuity {
self.disc_marks.push_back(off);
}
self.buf.extend_from_slice(&pes.data);
self.drain_complete_aus(false)
}
@@ -997,6 +1033,48 @@ mod tests {
assert!(!frames[1].keyframe);
}
/// B1 hole-2 regression: MPEG-2 buffers a GOP and emits asynchronously, so a
/// concealed gap must be associated by OFFSET (like PTS), landing on the
/// picture whose own bytes begin after the gap — NOT the previous picture
/// that completes when the discontinuity PES arrives. pic1 (I) is pre-gap;
/// pic2 (P), carried by a `discontinuity` PES, is the first post-gap AU.
#[test]
fn discontinuity_offset_mark_stamps_post_gap_picture_not_previous() {
let mut parser = Mpeg2Parser::new();
let mut pic1 = make_picture_header(PICTURE_TYPE_I);
pic1.extend_from_slice(&[0x11; 100]);
let mut pic2 = make_picture_header(2); // P
pic2.extend_from_slice(&[0x22; 100]);
// pic1 on a clean PES; nothing emits (same GOP, buffered).
assert!(parser.parse(&make_pes(pic1.clone(), Some(0))).is_empty());
// pic2 on a PES flagged discontinuity (a concealed gap preceded it).
// parse() of this PES completes pic1's AU (the PREVIOUS picture) — which
// must stay clean — while pic2 keeps buffering.
let pes2 = PesPacket {
source: None,
pid: 0x1011,
pts: Some(90000),
dts: None,
data: pic2.clone(),
discontinuity: true,
};
assert!(parser.parse(&pes2).is_empty(), "same GOP — still buffered");
let frames = parser.flush();
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].data, pic1);
assert!(
!frames[0].discontinuity,
"the previous (pre-gap) I picture must NOT be flagged"
);
assert_eq!(frames[1].data, pic2);
assert!(
frames[1].discontinuity,
"the post-gap P picture (the discontinuity PES's own AU) IS flagged"
);
}
#[test]
fn picture_coding_extension_stays_with_its_picture() {
// Regression for `ignoring pic cod ext after 0`: the picture coding