mux: B1 drop-to-keyframe resync after a concealed gap
Pairs with A2 (read-path NULL-TS concealment). When the demux assembler sees a TS continuity gap it now stamps `discontinuity` on the next completed PES; the codec-parse stage carries that onto a per-track ResyncGate. After a gap on an inter-coded video track the gate drops forward to the next IRAP/IDR keyframe so no frame with a dangling reference reaches the muxer (an ffmpeg deep scan would otherwise report a missing-reference / non-existing-PPS error). Audio and subtitle tracks have no cross-frame references, so the gate is a no-op there. - ts.rs: PesPacket gains `discontinuity`; PesAssembler tracks a sticky pending_discontinuity flag set on CC gap / discontinuity_indicator and carried to the next completed/flushed PES. - resync.rs (new): ResyncGate — per-track arm-on-gap, drop non-keyframes until the next keyframe disarms and resumes. Logs the resync + drop count once at the keyframe. - pipelined_stream.rs: precompute per-track is_video, apply the gate in consume_ts. Out-of-range track index emits as-is (defensive). Tests: ResyncGate unit tests; ts.rs gap-stamps-discontinuity; end-to-end B1 video-drops-to-keyframe and audio-never-drops through PipelinedPesStream.
This commit is contained in:
@@ -450,6 +450,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: None,
|
dts: None,
|
||||||
data: vec![],
|
data: vec![],
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
assert!(parser.parse(&pes).is_empty());
|
assert!(parser.parse(&pes).is_empty());
|
||||||
}
|
}
|
||||||
@@ -464,6 +465,7 @@ mod tests {
|
|||||||
pts: Some(90000),
|
pts: Some(90000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data: frame_data.clone(),
|
data: frame_data.clone(),
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
@@ -483,6 +485,7 @@ mod tests {
|
|||||||
pts: Some(90000),
|
pts: Some(90000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data: frame_data[..mid].to_vec(),
|
data: frame_data[..mid].to_vec(),
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames1 = parser.parse(&pes1);
|
let frames1 = parser.parse(&pes1);
|
||||||
assert!(frames1.is_empty(), "partial frame should not emit");
|
assert!(frames1.is_empty(), "partial frame should not emit");
|
||||||
@@ -494,6 +497,7 @@ mod tests {
|
|||||||
pts: Some(93000),
|
pts: Some(93000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data: frame_data[mid..].to_vec(),
|
data: frame_data[mid..].to_vec(),
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames2 = parser.parse(&pes2);
|
let frames2 = parser.parse(&pes2);
|
||||||
assert_eq!(frames2.len(), 1);
|
assert_eq!(frames2.len(), 1);
|
||||||
@@ -512,6 +516,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
@@ -535,6 +540,7 @@ mod tests {
|
|||||||
pts: Some(90000),
|
pts: Some(90000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data: pes1_data,
|
data: pes1_data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames1 = parser.parse(&pes1);
|
let frames1 = parser.parse(&pes1);
|
||||||
assert_eq!(frames1.len(), 1, "first complete frame emitted");
|
assert_eq!(frames1.len(), 1, "first complete frame emitted");
|
||||||
@@ -548,6 +554,7 @@ mod tests {
|
|||||||
pts: Some(93000),
|
pts: Some(93000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data: pes2_data,
|
data: pes2_data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames2 = parser.parse(&pes2);
|
let frames2 = parser.parse(&pes2);
|
||||||
assert_eq!(frames2.len(), 1, "split-sync frame must be recovered");
|
assert_eq!(frames2.len(), 1, "split-sync frame must be recovered");
|
||||||
@@ -574,6 +581,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
assert!(frames.is_empty());
|
assert!(frames.is_empty());
|
||||||
@@ -602,6 +610,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
assert!(parser.parse(&pes).is_empty());
|
assert!(parser.parse(&pes).is_empty());
|
||||||
assert_eq!(parser.buf, vec![0x0B], "lone trailing 0x0B retained");
|
assert_eq!(parser.buf, vec![0x0B], "lone trailing 0x0B retained");
|
||||||
@@ -639,6 +648,7 @@ mod tests {
|
|||||||
pts: Some(90000),
|
pts: Some(90000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let f = parser.parse(&pes);
|
let f = parser.parse(&pes);
|
||||||
assert_eq!(f.len(), 1, "frame 1 emitted in parse");
|
assert_eq!(f.len(), 1, "frame 1 emitted in parse");
|
||||||
@@ -671,6 +681,7 @@ mod tests {
|
|||||||
pts: Some(90000),
|
pts: Some(90000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let f = parser.parse(&pes);
|
let f = parser.parse(&pes);
|
||||||
assert_eq!(f.len(), 2);
|
assert_eq!(f.len(), 2);
|
||||||
@@ -714,6 +725,7 @@ mod tests {
|
|||||||
pts: Some(90000),
|
pts: Some(90000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
assert_eq!(frames.len(), 1, "only the real AC-3 frame is emitted");
|
assert_eq!(frames.len(), 1, "only the real AC-3 frame is emitted");
|
||||||
@@ -1179,6 +1191,7 @@ mod tests {
|
|||||||
pts: Some(90000),
|
pts: Some(90000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -482,6 +482,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -537,6 +537,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,6 +924,7 @@ mod tests {
|
|||||||
pts: Some(180000), // 2 seconds (presentation)
|
pts: Some(180000), // 2 seconds (presentation)
|
||||||
dts: Some(90000), // 1 second (decode)
|
dts: Some(90000), // 1 second (decode)
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
@@ -1232,6 +1234,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: Some(90000),
|
dts: Some(90000),
|
||||||
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
|
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let f = parser.parse(&pes);
|
let f = parser.parse(&pes);
|
||||||
assert_eq!(f.len(), 1);
|
assert_eq!(f.len(), 1);
|
||||||
@@ -1247,6 +1250,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: None,
|
dts: None,
|
||||||
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
|
data: vec![0x00, 0x00, 0x01, 0x41, 0x10],
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let f = parser.parse(&pes);
|
let f = parser.parse(&pes);
|
||||||
assert_eq!(f.len(), 1);
|
assert_eq!(f.len(), 1);
|
||||||
|
|||||||
@@ -973,6 +973,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2243,6 +2244,7 @@ mod tests {
|
|||||||
pts: Some(180000), // 2 s (presentation)
|
pts: Some(180000), // 2 s (presentation)
|
||||||
dts: Some(90000), // 1 s (decode)
|
dts: Some(90000), // 1 s (decode)
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
@@ -2850,6 +2852,7 @@ mod tests {
|
|||||||
d.extend_from_slice(&[0x10, 0x20]);
|
d.extend_from_slice(&[0x10, 0x20]);
|
||||||
d
|
d
|
||||||
},
|
},
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let f = parser.parse(&pes);
|
let f = parser.parse(&pes);
|
||||||
assert_eq!(f.len(), 1);
|
assert_eq!(f.len(), 1);
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -707,6 +707,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let mut p = Mpeg2Parser::new();
|
let mut p = Mpeg2Parser::new();
|
||||||
let mut frames = Vec::new();
|
let mut frames = Vec::new();
|
||||||
@@ -822,6 +823,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1415,6 +1417,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: Some(90000),
|
dts: Some(90000),
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let f = parse_then_flush(&mut parser, &pes);
|
let f = parse_then_flush(&mut parser, &pes);
|
||||||
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
|
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
|
||||||
@@ -1428,6 +1431,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: None,
|
dts: None,
|
||||||
data: data2,
|
data: data2,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let f2 = parse_then_flush(&mut parser2, &pes2);
|
let f2 = parse_then_flush(&mut parser2, &pes2);
|
||||||
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
|
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -451,6 +451,7 @@ mod tests {
|
|||||||
pts,
|
pts,
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +758,7 @@ mod tests {
|
|||||||
pts: Some(180000), // presentation
|
pts: Some(180000), // presentation
|
||||||
dts: Some(90000), // decode
|
dts: Some(90000), // decode
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let frames = parser.parse(&pes);
|
let frames = parser.parse(&pes);
|
||||||
assert_eq!(frames.len(), 1);
|
assert_eq!(frames.len(), 1);
|
||||||
@@ -1007,6 +1009,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: Some(90000),
|
dts: Some(90000),
|
||||||
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
|
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let f = parser.parse(&pes);
|
let f = parser.parse(&pes);
|
||||||
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
|
assert_eq!(f[0].pts_ns, 1_000_000_000, "DTS fallback");
|
||||||
@@ -1018,6 +1021,7 @@ mod tests {
|
|||||||
pts: None,
|
pts: None,
|
||||||
dts: None,
|
dts: None,
|
||||||
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
|
data: vec![0x00, 0x00, 0x01, SC_FRAME, 0x55],
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
let f2 = parser2.parse(&pes2);
|
let f2 = parser2.parse(&pes2);
|
||||||
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
|
assert_eq!(f2[0].pts_ns, 0, "no PTS/DTS → 0");
|
||||||
|
|||||||
@@ -723,6 +723,8 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
pts: ps.pts.map(|p| p as i64),
|
pts: ps.pts.map(|p| p as i64),
|
||||||
dts: ps.dts.map(|d| d as i64),
|
dts: ps.dts.map(|d| d as i64),
|
||||||
data: ps.data.clone(),
|
data: ps.data.clone(),
|
||||||
|
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid)
|
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid)
|
||||||
{
|
{
|
||||||
@@ -846,6 +848,8 @@ impl crate::pes::Stream for DiscStream {
|
|||||||
pts: ps.pts.map(|p| p as i64),
|
pts: ps.pts.map(|p| p as i64),
|
||||||
dts: ps.dts.map(|d| d as i64),
|
dts: ps.dts.map(|d| d as i64),
|
||||||
data: ps.data.clone(),
|
data: ps.data.clone(),
|
||||||
|
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||||
|
|||||||
@@ -2665,6 +2665,7 @@ mod tests {
|
|||||||
pts: Some(gop_pts),
|
pts: Some(gop_pts),
|
||||||
dts: None,
|
dts: None,
|
||||||
data: es,
|
data: es,
|
||||||
|
discontinuity: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
frames.extend(parser.flush());
|
frames.extend(parser.flush());
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ pub(crate) mod mkvstream;
|
|||||||
pub(crate) mod network;
|
pub(crate) mod network;
|
||||||
pub(crate) mod null;
|
pub(crate) mod null;
|
||||||
pub(crate) mod ps;
|
pub(crate) mod ps;
|
||||||
|
pub(crate) mod resync;
|
||||||
pub(crate) mod stdio;
|
pub(crate) mod stdio;
|
||||||
/// Shared clip-boundary timeline-continuity corrector (used by the MKV muxer
|
/// Shared clip-boundary timeline-continuity corrector (used by the MKV muxer
|
||||||
/// and the `demux://` sink).
|
/// and the `demux://` sink).
|
||||||
|
|||||||
@@ -68,6 +68,13 @@ pub struct PipelinedPesStream {
|
|||||||
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
||||||
/// and summarised once at EOF.
|
/// and summarised once at EOF.
|
||||||
dropped_nav_packets: u64,
|
dropped_nav_packets: u64,
|
||||||
|
/// Per-track (by stream index) B1 drop-to-keyframe gate. After a TS gap on a
|
||||||
|
/// video track, drop inter-coded frames until the next IRAP so the muxed
|
||||||
|
/// stream stays decode-clean across an upstream concealed loss (P3/B1).
|
||||||
|
resync: Vec<super::resync::ResyncGate>,
|
||||||
|
/// Per-track "is inter-coded video" flag (only video has cross-frame
|
||||||
|
/// references the gate must protect). Indexed by stream index.
|
||||||
|
is_video: Vec<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PipelinedPesStream {
|
impl PipelinedPesStream {
|
||||||
@@ -87,6 +94,14 @@ impl PipelinedPesStream {
|
|||||||
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
parsers: Vec<(u16, Box<dyn CodecParser>)>,
|
||||||
pid_to_track: Vec<(u16, usize)>,
|
pid_to_track: Vec<(u16, usize)>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let is_video: Vec<bool> = title
|
||||||
|
.streams
|
||||||
|
.iter()
|
||||||
|
.map(|s| matches!(s, crate::disc::Stream::Video(_)))
|
||||||
|
.collect();
|
||||||
|
let resync = (0..title.streams.len())
|
||||||
|
.map(|_| super::resync::ResyncGate::new())
|
||||||
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
title,
|
title,
|
||||||
parsers,
|
parsers,
|
||||||
@@ -98,6 +113,8 @@ impl PipelinedPesStream {
|
|||||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||||
decrypt_loss: None,
|
decrypt_loss: None,
|
||||||
dropped_nav_packets: 0,
|
dropped_nav_packets: 0,
|
||||||
|
resync,
|
||||||
|
is_video,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +181,32 @@ impl PipelinedPesStream {
|
|||||||
} else if let Some((_, parser)) =
|
} else if let Some((_, parser)) =
|
||||||
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
|
||||||
{
|
{
|
||||||
|
let is_video = self.is_video.get(track).copied().unwrap_or(false);
|
||||||
|
let discontinuity = pes.discontinuity;
|
||||||
for frame in parser.parse(&pes) {
|
for frame in parser.parse(&pes) {
|
||||||
|
// B1: after a TS gap on a video track, drop forward to the
|
||||||
|
// next keyframe so no frame with a dangling reference is
|
||||||
|
// emitted. Audio/subtitle always admit (independent frames).
|
||||||
|
// A track with no gate (out-of-range index) emits as-is.
|
||||||
|
let emit = match self.resync.get_mut(track) {
|
||||||
|
Some(gate) => {
|
||||||
|
let was_armed = gate.is_armed();
|
||||||
|
let dropped = gate.dropped_in_run();
|
||||||
|
let admit = gate.admit(is_video, discontinuity, frame.keyframe);
|
||||||
|
if admit && was_armed {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "mux",
|
||||||
|
track,
|
||||||
|
pid = pes.pid,
|
||||||
|
dropped,
|
||||||
|
"B1: resynced at keyframe after concealed gap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
admit
|
||||||
|
}
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
if emit {
|
||||||
self.pending_frames
|
self.pending_frames
|
||||||
.push_back(PesFrame::from_codec_frame(track, frame));
|
.push_back(PesFrame::from_codec_frame(track, frame));
|
||||||
}
|
}
|
||||||
@@ -172,6 +214,7 @@ impl PipelinedPesStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn consume_ps(&mut self, packets: Vec<super::ps::PsPacket>) {
|
fn consume_ps(&mut self, packets: Vec<super::ps::PsPacket>) {
|
||||||
for ps in packets {
|
for ps in packets {
|
||||||
@@ -217,6 +260,8 @@ impl PipelinedPesStream {
|
|||||||
pts: ps.pts.map(|p| p as i64),
|
pts: ps.pts.map(|p| p as i64),
|
||||||
dts: ps.dts.map(|d| d as i64),
|
dts: ps.dts.map(|d| d as i64),
|
||||||
data: ps.data,
|
data: ps.data,
|
||||||
|
// PS (DVD/CSS) path: no AACS conceal → no continuity-gap flag.
|
||||||
|
discontinuity: false,
|
||||||
};
|
};
|
||||||
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
|
||||||
for frame in parser.parse(&pes) {
|
for frame in parser.parse(&pes) {
|
||||||
@@ -411,6 +456,7 @@ mod tests {
|
|||||||
pts: Some(90_000),
|
pts: Some(90_000),
|
||||||
dts: None,
|
dts: None,
|
||||||
data,
|
data,
|
||||||
|
discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +544,125 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A parser that emits exactly one frame per PES, marking it a keyframe iff
|
||||||
|
/// the PES payload's first byte is `b'K'`. Lets a test script a precise
|
||||||
|
/// keyframe/inter-frame sequence to exercise the B1 resync gate.
|
||||||
|
struct KeyframeParser;
|
||||||
|
impl CodecParser for KeyframeParser {
|
||||||
|
fn parse(&mut self, pes: &PesPacket) -> Vec<super::super::codec::Frame> {
|
||||||
|
vec![super::super::codec::Frame {
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
pts_ns: pes.pts.unwrap_or(0),
|
||||||
|
keyframe: pes.data.first() == Some(&b'K'),
|
||||||
|
data: pes.data.clone(),
|
||||||
|
duration_ns: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> Vec<super::super::codec::Frame> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
fn codec_private(&self) -> Option<Vec<u8>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ts_pes_disc(pid: u16, data: Vec<u8>, discontinuity: bool) -> PesPacket {
|
||||||
|
PesPacket {
|
||||||
|
source: None,
|
||||||
|
pid,
|
||||||
|
pts: Some(90_000),
|
||||||
|
dts: None,
|
||||||
|
data,
|
||||||
|
discontinuity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B1 end-to-end: after a TS discontinuity on a VIDEO track the consumer
|
||||||
|
/// must DROP every inter-coded frame until the next keyframe, so no frame
|
||||||
|
/// with a dangling reference reaches the muxer (an ffmpeg deep-scan would
|
||||||
|
/// otherwise report a missing reference). The frame carrying the
|
||||||
|
/// discontinuity and the inter frames behind it are dropped; the stream
|
||||||
|
/// resumes cleanly at the next keyframe.
|
||||||
|
#[test]
|
||||||
|
fn b1_video_drops_to_keyframe_after_discontinuity() {
|
||||||
|
let title = video_title(false); // one HEVC video stream, PID 0x1011
|
||||||
|
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(0x1011, Box::new(KeyframeParser))];
|
||||||
|
let pid_to_track = vec![(0x1011u16, 0usize)];
|
||||||
|
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
|
||||||
|
|
||||||
|
// K0,P1 clean → emit. P2 carries the gap (inter frame referencing the
|
||||||
|
// lost data) → arms the gate; P2,P3 drop. K4 is the next keyframe →
|
||||||
|
// resync + emit. P5 then admits cleanly.
|
||||||
|
tx.send(DemuxBatch::Ts(vec![
|
||||||
|
ts_pes_disc(0x1011, b"K0".to_vec(), false),
|
||||||
|
ts_pes_disc(0x1011, b"P1".to_vec(), false),
|
||||||
|
ts_pes_disc(0x1011, b"P2".to_vec(), true),
|
||||||
|
ts_pes_disc(0x1011, b"P3".to_vec(), false),
|
||||||
|
ts_pes_disc(0x1011, b"K4".to_vec(), false),
|
||||||
|
ts_pes_disc(0x1011, b"P5".to_vec(), false),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(DemuxBatch::Eof).unwrap();
|
||||||
|
|
||||||
|
let mut emitted = Vec::new();
|
||||||
|
while let Some(f) = stream.read().unwrap() {
|
||||||
|
emitted.push(f.data);
|
||||||
|
}
|
||||||
|
// P2 (gap) and P3 (still no keyframe) are dropped; the rest survive in
|
||||||
|
// order. Crucially the FIRST frame after the gap that we emit is the
|
||||||
|
// keyframe K4 — never a dangling-reference inter frame.
|
||||||
|
assert_eq!(
|
||||||
|
emitted,
|
||||||
|
vec![
|
||||||
|
b"K0".to_vec(),
|
||||||
|
b"P1".to_vec(),
|
||||||
|
b"K4".to_vec(),
|
||||||
|
b"P5".to_vec()
|
||||||
|
],
|
||||||
|
"post-gap inter frames dropped, stream resumes at the keyframe"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counterpart to B1: a discontinuity on a NON-video track must NOT drop
|
||||||
|
/// frames — audio/subtitle access units are independent, so the gate admits
|
||||||
|
/// every frame the parser still produces.
|
||||||
|
#[test]
|
||||||
|
fn b1_audio_does_not_drop_on_discontinuity() {
|
||||||
|
let mut title = DiscTitle::empty();
|
||||||
|
title.streams.push(crate::disc::Stream::Audio(AudioStream {
|
||||||
|
pid: 0x1100,
|
||||||
|
codec: Codec::Ac3,
|
||||||
|
channels: AudioChannels::Surround51,
|
||||||
|
language: "eng".into(),
|
||||||
|
sample_rate: SampleRate::S48,
|
||||||
|
secondary: false,
|
||||||
|
purpose: LabelPurpose::Normal,
|
||||||
|
label: String::new(),
|
||||||
|
}));
|
||||||
|
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(0x1100, Box::new(KeyframeParser))];
|
||||||
|
let pid_to_track = vec![(0x1100u16, 0usize)];
|
||||||
|
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
|
||||||
|
|
||||||
|
tx.send(DemuxBatch::Ts(vec![
|
||||||
|
ts_pes_disc(0x1100, b"a0".to_vec(), false),
|
||||||
|
ts_pes_disc(0x1100, b"a1".to_vec(), true), // gap — but audio is independent
|
||||||
|
ts_pes_disc(0x1100, b"a2".to_vec(), false),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
tx.send(DemuxBatch::Eof).unwrap();
|
||||||
|
|
||||||
|
let mut emitted = Vec::new();
|
||||||
|
while let Some(f) = stream.read().unwrap() {
|
||||||
|
emitted.push(f.data);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
emitted,
|
||||||
|
vec![b"a0".to_vec(), b"a1".to_vec(), b"a2".to_vec()],
|
||||||
|
"audio frames are never dropped on a discontinuity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// At EOF the consumer must call `flush()` on every parser and emit the
|
/// At EOF the consumer must call `flush()` on every parser and emit the
|
||||||
/// buffered tail frames — a parser that holds the final access unit (e.g.
|
/// buffered tail frames — a parser that holds the final access unit (e.g.
|
||||||
/// DTS-HD) must NOT have it dropped. Without the flush the last frame is
|
/// DTS-HD) must NOT have it dropped. Without the flush the last frame is
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//! B1 drop-to-IRAP gate — keep a muxed elementary stream decode-clean across a
|
||||||
|
//! mid-stream gap (e.g. an undecryptable unit the mux concealed as NULL TS,
|
||||||
|
//! P3/A2).
|
||||||
|
//!
|
||||||
|
//! When packets are lost, the affected access unit is already dropped at the TS
|
||||||
|
//! layer (the assembler drops the partial PES on the continuity gap). But for
|
||||||
|
//! INTER-CODED video the frames that follow reference the lost frame (and each
|
||||||
|
//! other) until the next IRAP/IDR keyframe — emitting them yields an ffmpeg
|
||||||
|
//! "missing reference / non-existing PPS" deep-scan error and visibly broken
|
||||||
|
//! decode. So after a gap on a video track we DROP FORWARD to the next keyframe
|
||||||
|
//! and resume cleanly there. The gap rounds up to (at most) one GOP — the price
|
||||||
|
//! of never emitting a dangling reference; it is logged.
|
||||||
|
//!
|
||||||
|
//! Audio and subtitle frames are independent (no inter-frame references), so a
|
||||||
|
//! gap there costs only the single already-dropped frame; the gate is a no-op
|
||||||
|
//! for non-video tracks (it always admits).
|
||||||
|
|
||||||
|
/// Per-track keyframe-resync state. One gate per elementary stream; a video
|
||||||
|
/// track's gate stays "armed" from a discontinuity until the next keyframe.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct ResyncGate {
|
||||||
|
/// True while dropping post-gap inter-coded frames until the next keyframe.
|
||||||
|
armed: bool,
|
||||||
|
/// Count of frames dropped while armed (for a single summary log on resync).
|
||||||
|
dropped: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResyncGate {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
armed: false,
|
||||||
|
dropped: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether a parsed frame should be EMITTED (`true`) or DROPPED
|
||||||
|
/// (`false`).
|
||||||
|
///
|
||||||
|
/// * `is_video` — inter-coded video track (the only kind with cross-frame
|
||||||
|
/// references); `false` for audio/subtitle, which always admit.
|
||||||
|
/// * `discontinuity` — this frame's source PES followed a TS continuity gap.
|
||||||
|
/// * `keyframe` — this frame is a self-contained IRAP/IDR.
|
||||||
|
///
|
||||||
|
/// A non-video track always admits. A video track arms on a discontinuity
|
||||||
|
/// and then drops every non-keyframe until (and excluding the drop of) the
|
||||||
|
/// next keyframe, which disarms and is emitted.
|
||||||
|
pub(crate) fn admit(&mut self, is_video: bool, discontinuity: bool, keyframe: bool) -> bool {
|
||||||
|
if !is_video {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if discontinuity {
|
||||||
|
self.armed = true;
|
||||||
|
}
|
||||||
|
if self.armed {
|
||||||
|
if keyframe {
|
||||||
|
self.armed = false;
|
||||||
|
self.dropped = 0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
self.dropped += 1;
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frames dropped so far in the CURRENT armed run (0 when not armed / just
|
||||||
|
/// resynced). Lets the consumer log the resync cost once at the keyframe.
|
||||||
|
pub(crate) fn dropped_in_run(&self) -> u64 {
|
||||||
|
self.dropped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the gate is currently dropping frames (armed, awaiting keyframe).
|
||||||
|
pub(crate) fn is_armed(&self) -> bool {
|
||||||
|
self.armed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_video_always_admits_even_on_discontinuity() {
|
||||||
|
let mut g = ResyncGate::new();
|
||||||
|
// Audio/subtitle: a gap drops only the (already TS-dropped) frame; every
|
||||||
|
// frame the parser still emits is independent and must pass.
|
||||||
|
assert!(g.admit(false, true, false));
|
||||||
|
assert!(g.admit(false, true, false));
|
||||||
|
assert!(!g.is_armed(), "non-video never arms");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn video_drops_inter_frames_until_next_keyframe() {
|
||||||
|
let mut g = ResyncGate::new();
|
||||||
|
// Clean run: everything admits.
|
||||||
|
assert!(g.admit(true, false, true)); // IDR
|
||||||
|
assert!(g.admit(true, false, false)); // P
|
||||||
|
// Gap arrives on the next frame (a P referencing lost data) → drop it
|
||||||
|
// and every inter frame until the next keyframe.
|
||||||
|
assert!(!g.admit(true, true, false), "post-gap P dropped");
|
||||||
|
assert!(!g.admit(true, false, false), "still dropping (no key yet)");
|
||||||
|
assert!(!g.admit(true, false, false));
|
||||||
|
assert_eq!(g.dropped_in_run(), 3);
|
||||||
|
// Next keyframe resyncs and is emitted.
|
||||||
|
assert!(g.admit(true, false, true), "keyframe resumes the stream");
|
||||||
|
assert!(!g.is_armed());
|
||||||
|
assert_eq!(g.dropped_in_run(), 0);
|
||||||
|
// Back to a clean run.
|
||||||
|
assert!(g.admit(true, false, false), "post-resync P admits");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discontinuity_landing_on_a_keyframe_emits_immediately() {
|
||||||
|
let mut g = ResyncGate::new();
|
||||||
|
// If the first surviving frame after the gap is itself an IRAP, there is
|
||||||
|
// nothing to drop — it is self-contained.
|
||||||
|
assert!(g.admit(true, true, true), "gap+keyframe emits, no drop");
|
||||||
|
assert!(!g.is_armed());
|
||||||
|
assert_eq!(g.dropped_in_run(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,15 @@ pub struct PesPacket {
|
|||||||
/// from the producer's known stream offset. `None` when the demuxer was fed
|
/// from the producer's known stream offset. `None` when the demuxer was fed
|
||||||
/// without a base offset (callers that don't need provenance).
|
/// without a base offset (callers that don't need provenance).
|
||||||
pub source: Option<crate::pes::SourcePos>,
|
pub source: Option<crate::pes::SourcePos>,
|
||||||
|
/// True when a TS continuity gap (a CC discontinuity, or an adaptation-field
|
||||||
|
/// discontinuity_indicator) was seen on this PID since the previous PES
|
||||||
|
/// completed — i.e. one or more packets for this stream were lost (e.g. the
|
||||||
|
/// mux replaced an undecryptable unit with NULL TS packets, P3/A2). This is
|
||||||
|
/// the FIRST surviving PES after the gap, so for inter-coded video it (and
|
||||||
|
/// every later frame up to the next IRAP/IDR) may reference data that is now
|
||||||
|
/// gone. The codec-parse consumer uses it to drop forward to the next
|
||||||
|
/// keyframe (B1) instead of emitting frames with dangling references.
|
||||||
|
pub discontinuity: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-PID PES reassembly state.
|
/// Per-PID PES reassembly state.
|
||||||
@@ -58,6 +67,11 @@ struct PesAssembler {
|
|||||||
/// Stamped at PES start, emitted on the completed packet — provenance is
|
/// Stamped at PES start, emitted on the completed packet — provenance is
|
||||||
/// carried, never reconstructed downstream.
|
/// carried, never reconstructed downstream.
|
||||||
pes_source: Option<crate::pes::SourcePos>,
|
pes_source: Option<crate::pes::SourcePos>,
|
||||||
|
/// Sticky "a continuity gap occurred on this PID" flag. Set whenever a CC
|
||||||
|
/// gap or an explicit discontinuity_indicator is seen; carried onto the NEXT
|
||||||
|
/// completed PES (which is the first surviving frame after the loss) and then
|
||||||
|
/// cleared. Drives B1 drop-to-keyframe in the codec consumer.
|
||||||
|
pending_discontinuity: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initial capacity for a fresh PES buffer. Sized to cover the
|
/// Initial capacity for a fresh PES buffer. Sized to cover the
|
||||||
@@ -92,6 +106,7 @@ impl PesAssembler {
|
|||||||
header_remaining: 0,
|
header_remaining: 0,
|
||||||
last_cc: None,
|
last_cc: None,
|
||||||
pes_source: None,
|
pes_source: None,
|
||||||
|
pending_discontinuity: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +120,15 @@ impl PesAssembler {
|
|||||||
source: Option<crate::pes::SourcePos>,
|
source: Option<crate::pes::SourcePos>,
|
||||||
) -> Option<PesPacket> {
|
) -> Option<PesPacket> {
|
||||||
let completed = if self.active && !self.buffer.is_empty() {
|
let completed = if self.active && !self.buffer.is_empty() {
|
||||||
|
let discontinuity = self.pending_discontinuity;
|
||||||
|
self.pending_discontinuity = false;
|
||||||
Some(PesPacket {
|
Some(PesPacket {
|
||||||
pid: self.pid,
|
pid: self.pid,
|
||||||
pts: self.pts,
|
pts: self.pts,
|
||||||
dts: self.dts,
|
dts: self.dts,
|
||||||
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(PES_BUFFER_INIT_CAP)),
|
data: std::mem::replace(&mut self.buffer, Vec::with_capacity(PES_BUFFER_INIT_CAP)),
|
||||||
source: self.pes_source,
|
source: self.pes_source,
|
||||||
|
discontinuity,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
self.buffer.clear();
|
self.buffer.clear();
|
||||||
@@ -151,12 +169,15 @@ impl PesAssembler {
|
|||||||
fn flush(&mut self) -> Option<PesPacket> {
|
fn flush(&mut self) -> Option<PesPacket> {
|
||||||
if self.active && !self.buffer.is_empty() {
|
if self.active && !self.buffer.is_empty() {
|
||||||
self.active = false;
|
self.active = false;
|
||||||
|
let discontinuity = self.pending_discontinuity;
|
||||||
|
self.pending_discontinuity = false;
|
||||||
Some(PesPacket {
|
Some(PesPacket {
|
||||||
pid: self.pid,
|
pid: self.pid,
|
||||||
pts: self.pts,
|
pts: self.pts,
|
||||||
dts: self.dts,
|
dts: self.dts,
|
||||||
data: std::mem::take(&mut self.buffer),
|
data: std::mem::take(&mut self.buffer),
|
||||||
source: self.pes_source,
|
source: self.pes_source,
|
||||||
|
discontinuity,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -391,6 +412,16 @@ impl TsDemuxer {
|
|||||||
None => false,
|
None => false,
|
||||||
};
|
};
|
||||||
asm.last_cc = Some(cc);
|
asm.last_cc = Some(cc);
|
||||||
|
// Any continuity gap — at a PUSI boundary or mid-PES — means packets for
|
||||||
|
// this stream were lost (an upstream NULL-TS conceal, a damaged source).
|
||||||
|
// Mark it sticky so the NEXT completed PES carries `discontinuity` and the
|
||||||
|
// codec consumer can drop forward to the next keyframe (B1). The partial
|
||||||
|
// PES is still dropped below only for a NON-PUSI continuation (a hole in
|
||||||
|
// the middle of the current frame); a gap landing exactly on a PUSI starts
|
||||||
|
// a clean new frame, but it is still the first frame after the loss.
|
||||||
|
if discontinuity_flag || cc_gap {
|
||||||
|
asm.pending_discontinuity = true;
|
||||||
|
}
|
||||||
if !pusi && (discontinuity_flag || cc_gap) && asm.active {
|
if !pusi && (discontinuity_flag || cc_gap) && asm.active {
|
||||||
tracing::trace!(
|
tracing::trace!(
|
||||||
target: "mux",
|
target: "mux",
|
||||||
@@ -916,6 +947,50 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B1 plumbing: a continuity gap must STAMP `discontinuity = true` on the
|
||||||
|
/// next completed PES so the codec consumer can drop forward to the next
|
||||||
|
/// keyframe. A clean in-sequence PES carries `discontinuity = false`. We
|
||||||
|
/// open A (cc=0), flush it cleanly via B's PUSI (cc=1), then jump the CC
|
||||||
|
/// (cc 1 -> 5) on C's PUSI: the gap is sticky and lands on the PES flushed
|
||||||
|
/// at that boundary (B — the frame whose tail packets were the lost ones).
|
||||||
|
#[test]
|
||||||
|
fn continuity_gap_stamps_discontinuity_on_next_pes() {
|
||||||
|
let pid = 0x1011;
|
||||||
|
let mut demux = TsDemuxer::new(&[pid]);
|
||||||
|
|
||||||
|
// Open A (cc=0): nothing completed yet.
|
||||||
|
let out = demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
|
||||||
|
assert!(out.is_empty());
|
||||||
|
|
||||||
|
// B's PUSI (cc=1, in sequence) flushes A. A saw no gap → clean.
|
||||||
|
let out = demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
|
||||||
|
assert_eq!(out.len(), 1, "A completes");
|
||||||
|
assert_eq!(&out[0].data[..4], b"AAAA");
|
||||||
|
assert!(
|
||||||
|
!out[0].discontinuity,
|
||||||
|
"in-sequence PES is not a discontinuity"
|
||||||
|
);
|
||||||
|
|
||||||
|
// C's PUSI jumps cc 1 -> 5: packets were lost. The gap is sticky and is
|
||||||
|
// attributed to the PES flushed here (B), which lost its tail packets.
|
||||||
|
let out = demux.feed(&ts_payload_packet(pid, true, 5, &pes_start(b"CCCC")));
|
||||||
|
assert_eq!(out.len(), 1, "B completes");
|
||||||
|
assert_eq!(&out[0].data[..4], b"BBBB");
|
||||||
|
assert!(
|
||||||
|
out[0].discontinuity,
|
||||||
|
"the PES at the continuity gap must be flagged so B1 can resync"
|
||||||
|
);
|
||||||
|
|
||||||
|
// C itself was opened clean (after the gap) and carries no new gap.
|
||||||
|
let out = demux.flush();
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
assert_eq!(&out[0].data[..4], b"CCCC");
|
||||||
|
assert!(
|
||||||
|
!out[0].discontinuity,
|
||||||
|
"post-gap PES with no further gap is clean"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// In-sequence continuation (cc 0 -> 1) must still splice normally — the
|
/// In-sequence continuation (cc 0 -> 1) must still splice normally — the
|
||||||
/// continuity check must not break the happy path.
|
/// continuity check must not break the happy path.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user