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:
@@ -115,6 +115,7 @@ impl CodecParser for Ac3Parser {
|
||||
|
||||
let duration_ns = frame_duration_ns(remaining, bsid);
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: frame_pts_ns,
|
||||
@@ -201,6 +202,7 @@ impl CodecParser for Ac3Parser {
|
||||
}
|
||||
let duration_ns = frame_duration_ns(frame, bsid);
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: self.flush_pts_ns,
|
||||
|
||||
@@ -243,6 +243,7 @@ impl CodecParser for DtsParser {
|
||||
// extensions or the next core.
|
||||
let au_pts = self.front_pts();
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: au_pts,
|
||||
@@ -299,6 +300,7 @@ impl CodecParser for DtsParser {
|
||||
let au = std::mem::take(&mut self.buf);
|
||||
self.pts_marks.clear();
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
|
||||
@@ -43,6 +43,7 @@ impl DvdSubParser {
|
||||
if force || buf.len() >= *size {
|
||||
let (pts_ns, _, data) = self.pending.take().unwrap();
|
||||
return Some(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
@@ -103,6 +104,7 @@ impl CodecParser for DvdSubParser {
|
||||
let d = ((pes.data[0] as usize) << 8) | pes.data[1] as usize;
|
||||
if d < 2 {
|
||||
out.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
@@ -116,6 +118,7 @@ impl CodecParser for DvdSubParser {
|
||||
} else {
|
||||
// Too short to carry SPU_size — pass through as a lone frame.
|
||||
out.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
|
||||
@@ -243,6 +243,9 @@ impl CodecParser for H264Parser {
|
||||
source: pes.source,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
// One access unit per PES (BD-TS aligns AUs to PES), so the gap
|
||||
// signal maps straight onto this frame.
|
||||
discontinuity: pes.discontinuity,
|
||||
data: frame_data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
|
||||
@@ -661,6 +661,9 @@ impl CodecParser for HevcParser {
|
||||
source: pes.source,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
// One access unit per PES (BD-TS aligns AUs to PES), so the gap
|
||||
// signal maps straight onto this frame.
|
||||
discontinuity: pes.discontinuity,
|
||||
data: frame_data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
|
||||
@@ -72,6 +72,7 @@ impl CodecParser for LpcmParser {
|
||||
}
|
||||
let pts_ns = pes.pts.map(pts_to_ns).unwrap_or(0);
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
|
||||
@@ -44,6 +44,16 @@ pub struct Frame {
|
||||
pub pts_ns: i64,
|
||||
/// Whether this is a keyframe (used for cue points).
|
||||
pub keyframe: bool,
|
||||
/// This frame is the FIRST coded picture after a concealed/lost gap (P3/B1):
|
||||
/// its data begins after packets the demuxer never received (an undecryptable
|
||||
/// unit concealed as NULL-TS upstream, or a continuity break in a damaged
|
||||
/// source). Inter-coded video frames carrying this flag reference data that is
|
||||
/// gone, so the consumer's `ResyncGate` arms here and drops forward to the next
|
||||
/// keyframe. Carried per-FRAME (not per-PES) because buffering parsers — MPEG-2
|
||||
/// emits whole GOPs, H.264/HEVC lag one access unit — decouple the frame from
|
||||
/// the PES that carried the gap signal. Default `false`; only ever set on the
|
||||
/// degraded/conceal path, so a clean rip leaves every frame `false`.
|
||||
pub discontinuity: bool,
|
||||
/// Frame data (elementary stream bytes).
|
||||
pub data: Vec<u8>,
|
||||
/// Optional duration in nanoseconds — only set by parsers that
|
||||
@@ -123,11 +133,15 @@ impl PassthroughParser {
|
||||
impl CodecParser for PassthroughParser {
|
||||
fn parse(&mut self, pes: &PesPacket) -> Vec<Frame> {
|
||||
let pts_ns = pes.pts.or(pes.dts).map(pts_to_ns).unwrap_or(0);
|
||||
// Passthrough emits exactly one frame per PES with no cross-PES buffering,
|
||||
// so the PES's discontinuity maps directly onto this frame. (Buffering
|
||||
// parsers must instead defer the flag to the next emitted frame.)
|
||||
vec![Frame {
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns,
|
||||
keyframe: self.keyframe,
|
||||
discontinuity: pes.discontinuity,
|
||||
data: pes.data.clone(),
|
||||
duration_ns: None,
|
||||
}]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,7 @@ impl PgsParser {
|
||||
let (start_pts, data) = self.pending.take()?;
|
||||
let duration = end_pts_ns.saturating_sub(start_pts).max(0) as u64;
|
||||
Some(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
@@ -92,6 +93,7 @@ impl CodecParser for PgsParser {
|
||||
.take()
|
||||
.map(|(start_pts, data)| {
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
@@ -119,6 +121,7 @@ impl CodecParser for PgsParser {
|
||||
let frame = match pts {
|
||||
Some(end) => self.emit_pending(end),
|
||||
None => self.pending.take().map(|(start_pts, data)| Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
@@ -142,6 +145,7 @@ impl CodecParser for PgsParser {
|
||||
// Flush any prior pending undurated and skip storing this one.
|
||||
None => {
|
||||
out.extend(self.pending.take().map(|(start_pts, data)| Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
@@ -167,6 +171,7 @@ impl CodecParser for PgsParser {
|
||||
// (A missing PTS falls through to the drop path below: a
|
||||
// bitmap with no timing reference would land at 00:00:00.)
|
||||
out.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: pts.unwrap_or(0),
|
||||
@@ -195,6 +200,7 @@ impl CodecParser for PgsParser {
|
||||
// the final on-screen subtitle (see the module doc).
|
||||
match self.pending.take() {
|
||||
Some((start_pts, data)) => vec![Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: start_pts,
|
||||
|
||||
@@ -255,6 +255,7 @@ impl CodecParser for TrueHdParser {
|
||||
}
|
||||
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
source: None,
|
||||
pts_ns: self.next_pts_ns,
|
||||
|
||||
@@ -328,6 +328,9 @@ impl CodecParser for Vc1Parser {
|
||||
source: pes.source,
|
||||
pts_ns: ts_ns,
|
||||
keyframe,
|
||||
// One frame per PES (BD-TS aligns frames to PES), so the gap signal
|
||||
// maps straight onto this frame.
|
||||
discontinuity: pes.discontinuity,
|
||||
data: frame_data,
|
||||
duration_ns: None,
|
||||
}]
|
||||
|
||||
Reference in New Issue
Block a user