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:
Matthew Jackson
2026-06-28 23:01:47 -07:00
parent 9a7be7a1a5
commit d715a0943a
17 changed files with 405 additions and 2 deletions
+123
View File
@@ -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);
}
}