mux: add the B1 drop-to-keyframe resync gate to DiscStream

DiscStream (the live-drive single-pass path) enables AACS decrypt-loss
concealment (NULL-TS fill on an undecryptable unit) but, unlike the file-backed
PipelinedPesStream, had no B1 resync gate — so after a concealed gap it forwarded
inter-coded video frames referencing the now-missing data, producing
decode-broken MKV output (dangling-reference frames).

Mirror PipelinedPesStream: add per-stream ResyncGate + is_video, and route every
codec-frame emit on the TS path (in-stream parse, demuxer EOF flush, parser EOF
flush) through gate.admit(is_video, frame.discontinuity, frame.keyframe) so a
video track drops forward to the next keyframe after a concealment event. Warn
once if a gate is still armed at EOF (a concealed gap in the final GOP with no
trailing keyframe). Audio/subtitle always admit.
This commit is contained in:
Matthew Jackson
2026-06-29 22:27:52 -07:00
parent 8c38cb0918
commit 7a0ef5412c
+94 -7
View File
@@ -195,6 +195,16 @@ pub struct DiscStream {
/// timestamp cost or `prof_active()` env-var lookup (which takes a
/// process-wide lock).
profiling: bool,
/// B1 drop-to-keyframe resync gate, one per stream — mirrors
/// `PipelinedPesStream`. After an AACS-concealed (undecryptable) gap stamps
/// a frame `discontinuity`, the gate drops forward inter-coded video frames
/// until the next keyframe so no dangling-reference frame reaches the muxer.
/// Without this the live-drive single-pass path emitted decode-broken output
/// on an undecryptable unit (the file-backed highway path is already gated).
resync: Vec<super::resync::ResyncGate>,
/// Whether each stream (by track index) is video — audio/subtitle frames are
/// independent and always admit.
is_video: Vec<bool>,
}
impl DiscStream {
@@ -287,6 +297,18 @@ impl DiscStream {
// clones an Arc per frame on the mux hot path.
let decrypt_loss = reader.decrypt_loss();
// B1 resync gates: one per stream, video flagged so the gate only
// drop-to-keyframes video (audio/subtitle always admit). Computed before
// `title` is moved into the struct below.
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 {
reader,
decrypt_loss,
@@ -315,6 +337,8 @@ impl DiscStream {
pid_to_track,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
resync,
is_video,
}
}
@@ -673,10 +697,25 @@ impl crate::pes::Stream for DiscStream {
if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
let resync = &mut self.resync;
let is_video = &self.is_video;
let pending = &mut self.pending_frames;
for frame in parser.parse(pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(*track, frame),
);
// Same B1 gate — a concealed gap can leave a
// post-gap frame in the demuxer's final flush.
let emit = match resync.get_mut(*track) {
Some(gate) => gate.admit(
is_video.get(*track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(
*track, frame,
));
}
}
}
}
@@ -741,12 +780,42 @@ impl crate::pes::Stream for DiscStream {
// PES boundaries).
let pid_to_track = &self.pid_to_track;
let pending = &mut self.pending_frames;
let resync = &mut self.resync;
let is_video = &self.is_video;
for (pid, parser) in self.parsers.iter_mut() {
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
continue;
};
// Flush frames carry their own per-frame `discontinuity` (a
// post-gap picture buffered at EOF was stamped by the parser),
// so route them through the SAME B1 gate the in-stream path
// uses — otherwise a trailing dangling-reference frame would
// bypass the resync.
for frame in parser.flush() {
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
}
}
}
// A gate still armed at EOF dropped post-gap frames that never
// reached a keyframe (a concealed gap in the final GOP). Surface
// it once so the loss is visible, not silent.
for (track, gate) in self.resync.iter().enumerate() {
if gate.is_armed() {
tracing::warn!(
target: "mux",
track,
dropped = gate.dropped_in_run(),
"B1: stream ended while dropping to a keyframe after a concealed gap (no trailing keyframe)"
);
}
}
return Ok(self.pending_frames.pop_front());
@@ -794,10 +863,28 @@ impl crate::pes::Stream for DiscStream {
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
// Disjoint field borrows: the resync gate + is_video
// reads coexist with the mutable `self.parsers` drain.
let resync = &mut self.resync;
let is_video = &self.is_video;
let pending = &mut self.pending_frames;
for frame in parser.parse(&pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(track, frame),
);
// B1: after a concealed/lost gap, drop forward to
// the next keyframe on a video track so no frame
// with a dangling reference is emitted.
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(
track, frame,
));
}
}
}
}