Files
libfreemkv/src/mux/pipelined_stream.rs
T
Matthew Jackson c81a6e05cd audit: fix AU mark-field loss, VTI tie determinism, and mark/perf issues
Round-4 findings from the 10-phase release audit (the first fully clean
round; it dug into the new #22/#18 refactor code):

- AuAssembler closed each AU from only the FRONT mark's fields, so when
  one PES fragment carried the source and a later fragment of the same AU
  carried the PTS, the second field was dropped — a regression vs the old
  separate pts/source mark deques. Now merge the first Some of each field
  across all in-range marks.
- parse_vti_clip_order picked the largest residue bucket with
  HashMap::into_values().max_by_key(), nondeterministic on a size tie
  (randomized HashMap iteration) — could select a different clip table
  run-to-run. Break ties by smallest offset.
- Bound the marks/disc_marks deques (MAX_MARKS): the buf-size cap prunes
  marks only when bytes accumulate, so a run of zero-length timed
  fragments could grow them without bound on hostile input.
- Add push_owned so the PS path moves the PES payload into a passthrough
  AU with no copy (MPEG-2 video + all audio), removing a per-PES
  malloc+memcpy the refactor had introduced on the DVD path.
- Back-patch the MKV duration from the block END (start + its own
  duration) so it covers the final frame instead of understating by one.
- Add direct tests for the MKB record-framing walker; drop a stale
  drain_complete_aus doc comment left on process_au.
2026-07-09 17:30:41 -07:00

1255 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `PipelinedPesStream` — the read-side of the freemkv mux
//! highway.
//!
//! Given a [`crate::mux::demux_thread::DemuxThread`] (which has the
//! producer + demux workers already spawned), a set of codec
//! parsers, and the title metadata, this struct implements
//! [`crate::pes::Stream`] by running codec parse on the caller's
//! thread and emitting `PesFrame`s one at a time.
//!
//! The pipeline runs three threads in parallel:
//!
//! ```text
//! Thread A: read + decrypt (PrefetchedSectorSource / BytePrefetcher)
//! Thread B: M2TS demux (DemuxThread)
//! Thread C: codec parse (this struct, on the caller's thread)
//! ```
//!
//! Communication between A→B and B→C is via bounded channels with
//! recycled buffer pools — no allocations or memcpys in the steady-
//! state hot loop.
//!
//! This is the *only* read-side `Stream` impl in tree. Both the ISO
//! file mux and the BD-TS (`m2ts://`) file mux input paths are built by
//! [`crate::mux::resolve`] (`build_iso_pipeline` / the m2ts pipeline
//! builder) and hand back a `PipelinedPesStream`; the differences are
//! in how the producer thread (A) is configured — sector-aligned reads
//! with AACS decrypt for ISO, raw byte reads for M2TS.
//! ([`crate::mux::M2tsStream`] itself is a write-only sink and does not
//! construct this type.)
use super::codec::CodecParser;
use super::demux_thread::{DemuxBatch, DemuxThread};
use super::ts::PesPacket;
use crate::disc::DiscTitle;
use crate::pes::{PesFrame, Stream};
use crossbeam_channel::Receiver;
use std::io;
/// Stream impl that consumes pre-demuxed `PesPacket` batches from a
/// [`DemuxThread`] and runs codec parse on the caller's thread.
pub struct PipelinedPesStream {
title: DiscTitle,
parsers: Vec<(u16, Box<dyn CodecParser>)>,
pid_to_track: Vec<(u16, usize)>,
demux_rx: Receiver<DemuxBatch>,
/// Kept alive so dropping this stream joins the demux + producer
/// workers deterministically. Never poked directly after spawn.
#[allow(dead_code)]
demux_thread: DemuxThread,
pending_frames: std::collections::VecDeque<PesFrame>,
eof: bool,
/// Cached `FREEMKV_SKIP_PARSE` profiling flag. Read once in `new()`
/// — the env var cannot change for the life of the stream, and
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
/// per-poll reads it replaces were needless hot-path overhead.
skip_parse: bool,
/// Cumulative bytes of scrambled AACS units the producer's decrypt step
/// could not decrypt — silent decrypt loss the demux drops without a sync.
/// Shared with the producer thread's [`DecryptingSectorSource`]
/// (`crate::sector::DecryptingSectorSource::decrypt_loss`). Surfaced through
/// [`Stream::lost_bytes`] so the file-backed mux abort gate sees a partial
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
/// are expected on every disc; instead of a per-packet WARN they're tallied
/// and summarised once at EOF.
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>,
/// Per-track access-unit assembler. On the PS path a program-stream video AU
/// is split across many fixed-size PES fragments; this reassembles them to the
/// codec's AU boundary so the parser sees AU-complete PES — the same shape the
/// TS demuxer already delivers via PUSI. Self-framing codecs (MPEG-2, audio)
/// use passthrough, so every track runs through it uniformly. Indexed by
/// stream index. (TS titles are AU-complete already, so this is a passthrough
/// there too — `consume_ts` does not use it.)
au_asm: Vec<super::au_assembly::AuAssembler>,
}
/// The `Codec` of a stream, for configuring its [`AuAssembler`].
fn stream_codec(s: &crate::disc::Stream) -> crate::disc::Codec {
use crate::disc::Stream;
match s {
Stream::Video(v) => v.codec,
Stream::Audio(a) => a.codec,
Stream::Subtitle(sub) => sub.codec,
}
}
impl PipelinedPesStream {
/// Wire up the stream. Caller has already spawned the
/// `DemuxThread` (which in turn owns the producer); we take the
/// receiver end + the join handle bundle so cleanup is bounded
/// on drop.
///
/// `pub(crate)`: the signature takes the internal `DemuxThread` /
/// `DemuxBatch` / `CodecParser` types, so external callers reach this
/// stream via [`super::resolve::input`] / `build_iso_pipeline`
/// instead.
pub(crate) fn new(
demux_thread: DemuxThread,
demux_rx: Receiver<DemuxBatch>,
title: DiscTitle,
parsers: Vec<(u16, Box<dyn CodecParser>)>,
pid_to_track: Vec<(u16, usize)>,
) -> 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();
let au_asm = title
.streams
.iter()
.map(|s| super::au_assembly::AuAssembler::for_codec(stream_codec(s)))
.collect();
Self {
title,
parsers,
pid_to_track,
demux_rx,
demux_thread,
pending_frames: std::collections::VecDeque::new(),
eof: false,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
decrypt_loss: None,
dropped_nav_packets: 0,
resync,
is_video,
au_asm,
}
}
/// Attach the producer's decrypt-loss counter so [`Stream::lost_bytes`]
/// reports bytes of scrambled AACS units that could not be decrypted (and
/// were therefore silently dropped downstream). Obtained from the
/// producer's `DecryptingSectorSource::decrypt_loss()` before it is moved
/// into the prefetch thread. The M2TS / no-decrypt pipelines leave this
/// unset.
pub(crate) fn with_decrypt_loss(
mut self,
loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
) -> Self {
self.decrypt_loss = Some(loss);
self
}
/// Pull one batch of `PesPacket`s from the demux thread, run
/// codec parse on each, enqueue resulting `PesFrame`s on
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
/// EOF (channel closed cleanly), Err on demuxer error.
fn pump_one_batch(&mut self) -> io::Result<bool> {
match self.demux_rx.recv() {
Ok(DemuxBatch::Ts(packets)) => {
self.consume_ts(packets);
Ok(true)
}
Ok(DemuxBatch::Ps(packets)) => {
self.consume_ps(packets);
Ok(true)
}
Ok(DemuxBatch::Err(e)) => Err(e),
// Explicit clean-completion sentinel from the demux worker.
Ok(DemuxBatch::Eof) => Ok(false),
// The channel disconnected WITHOUT the worker first sending
// an `Eof` (or `Err`) sentinel — the worker panicked or was
// dropped mid-stream. Surface this as an error so a parser /
// demux panic is never reported to the caller as a clean
// end-of-stream (which would silently truncate output).
Err(_) => Err(crate::error::Error::DemuxThreadPanicked.into()),
}
}
fn consume_ts(&mut self, packets: Vec<PesPacket>) {
let skip_parse = self.skip_parse;
for pes in packets {
if let Some((_, track)) = self
.pid_to_track
.iter()
.find(|(pid, _)| *pid == pes.pid)
.copied()
{
if skip_parse {
// Profiling escape hatch — bypass codec parser.
self.pending_frames.push_back(PesFrame {
coding: None,
source: None,
track,
pts: pes.pts.map(super::codec::pts_to_ns).unwrap_or(0),
keyframe: false,
data: pes.data,
duration_ns: None,
});
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
let is_video = self.is_video.get(track).copied().unwrap_or(false);
for frame in parser.parse(&pes) {
// 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. The signal is read PER-FRAME
// (`frame.discontinuity`), not per-PES: buffering parsers
// (MPEG-2 GOPs, H.264/HEVC AU lag) stamp the exact post-gap
// picture, so only it arms the gate — not a whole PES of
// frames. 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, frame.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
.push_back(PesFrame::from_codec_frame(track, frame));
}
}
}
}
}
}
fn consume_ps(&mut self, packets: Vec<super::ps::PsPacket>) {
for ps in packets {
// Route by the REAL DVD PID (matching the PIDs that
// `scan_dvd_titles` assigns) rather than a synthetic track
// index. The old `(sub_id & 0x1F) + 1` heuristic collided
// subtitle sub-id 0x20+j with audio track j+1, feeding
// VobSub PES into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else {
if ps.is_nav() {
// Expected DVD navigation packet (PCI/DSI) — tally, no WARN.
self.dropped_nav_packets += 1;
} else {
// Unexpected unmappable stream_id — a possibly-dropped real
// stream. Keep the individual WARN: its repetition is signal.
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
}
continue;
};
let Some((_, track)) = self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
else {
tracing::warn!(
target: "mux",
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
pid,
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
// Carry the PS demuxer's byte-exact source stamp through to the codec
// parser, exactly as the TS path does — provenance must survive the
// PsPacket → PesPacket seam so the frame's `source` reaches the
// mux/index (FVI `src`), never reconstructed.
let (pts_i64, dts_i64, src) = (
ps.pts.map(|p| p as i64),
ps.dts.map(|d| d as i64),
ps.source,
);
// Reassemble the PS fragments into AU-complete PES for this track
// (passthrough for self-framing codecs — MPEG-2/audio), so the parser
// sees exactly the AU-complete shape a transport stream delivers. The
// AU-start PTS/source survive the reassembly. A track with no assembler
// (only reachable via a hand-built `pid_to_track` outrunning the stream
// list) passes the fragment straight through. (PS path: no AACS conceal
// → no continuity-gap flag.)
let pkts: Vec<PesPacket> = match self.au_asm.get_mut(track) {
Some(asm) => asm
.push_owned(ps.data, pts_i64, dts_i64, src, false)
.into_iter()
.map(|au| PesPacket {
source: au.source,
pid,
pts: au.pts,
dts: au.dts,
data: au.data,
discontinuity: au.discontinuity,
})
.collect(),
None => vec![PesPacket {
source: src,
pid,
pts: pts_i64,
dts: dts_i64,
data: ps.data,
discontinuity: false,
}],
};
for pes in &pkts {
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
for frame in parser.parse(pes) {
self.pending_frames
.push_back(PesFrame::from_codec_frame(track, frame));
}
}
}
}
}
}
impl Stream for PipelinedPesStream {
fn read(&mut self) -> io::Result<Option<PesFrame>> {
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
if self.eof {
return Ok(None);
}
loop {
match self.pump_one_batch()? {
true => {
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
// Batch contained no trackable packets — pull again.
}
false => {
self.eof = true;
if self.dropped_nav_packets > 0 {
tracing::debug!(
target: "mux",
"dropped {} DVD navigation packets (private_stream_2/0xBF) — expected, carry no elementary stream",
self.dropped_nav_packets
);
}
// Drain any access unit a parser buffered past the last
// PES (e.g. DTS-HD's final core+extension unit, or MPEG-2's
// final GOP). These 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 (MPEG-2 final-GOP corner) would
// bypass the resync. Disjoint field borrows so the gate +
// is_video reads coexist with the mutable parser drain.
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;
let au_asm = &mut self.au_asm;
for (pid, parser) in self.parsers.iter_mut() {
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
continue;
};
// First: the trailing access unit(s) the PS assembler
// buffered past the final fragment (the last AU has no
// following boundary). Parse them, THEN drain the parser's
// own internal buffer (MPEG-2 final GOP, DTS-HD tail).
let mut frames = Vec::new();
let tail = au_asm.get_mut(track).map(|a| a.flush()).unwrap_or_default();
for au in tail {
let pes = PesPacket {
source: au.source,
pid: *pid,
pts: au.pts,
dts: au.dts,
data: au.data,
discontinuity: au.discontinuity,
};
frames.extend(parser.parse(&pes));
}
frames.extend(parser.flush());
for frame in frames {
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(PesFrame::from_codec_frame(track, frame));
}
}
}
// A gate still armed at EOF dropped post-gap frames that never
// reached a keyframe (e.g. 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());
}
}
}
}
fn write(&mut self, _: &PesFrame) -> io::Result<()> {
Err(crate::error::Error::StreamReadOnly.into())
}
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.title
}
fn headers_ready(&self) -> bool {
// Match the previous DiscStream semantics: video tracks need
// codec_private before the consumer can write the container
// header. FREEMKV_SKIP_PARSE forces ready (no parser ever
// populates codec_private in that mode).
if self.skip_parse {
return true;
}
for (idx, s) in self.title.streams.iter().enumerate() {
if let crate::disc::Stream::Video(v) = s {
if !v.secondary && self.codec_private(idx).is_none() {
return false;
}
}
}
true
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(p, _)| *p)?;
self.parsers
.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
fn lost_bytes(&self) -> u64 {
// The file-backed highway has no read-error zero-fill term (resolve
// tracks read loss separately), but the producer's decrypt step can
// pass scrambled units through undecrypted — silent loss the demux
// drops. Surface that so the mux abort gate sees a partial AACS/CSS
// decrypt failure rather than reporting a perfect rip.
self.decrypt_loss
.as_ref()
.map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
.unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disc::{
AudioChannels, AudioStream, Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat,
LabelPurpose, Resolution, SampleRate, VideoStream,
};
use crate::mux::demux_thread::{DemuxBatch, DemuxThread};
use crate::mux::ps::PsPacket;
use crate::mux::ts::PesPacket;
use crossbeam_channel::{Sender, bounded};
/// Build a real, cleanly-exiting `DemuxThread` whose own receiver we
/// discard. The worker exits immediately (its prefetch sender is dropped)
/// and joins on drop — it exists only to satisfy `new()`'s ownership of a
/// `DemuxThread`. The caller controls the SEPARATE `demux_rx` we hand to
/// `PipelinedPesStream::new`, so we can inject any `DemuxBatch` sequence
/// (or a bare disconnect) independent of the dummy worker.
fn dummy_demux_thread() -> DemuxThread {
let (_pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(1);
let (rec_tx, _rec_rx) = bounded::<Vec<u8>>(2);
// No TS/PS demuxer; the worker just drains (nothing) and exits Eof.
let (dt, _own_rx) =
DemuxThread::spawn_zero_copy(pf_rx, rec_tx, (), None, None, None).expect("spawn");
dt
}
/// Assemble a `PipelinedPesStream` over a caller-controlled demux channel.
/// Returns the stream plus the `Sender` so the test drives batches/EOF.
fn make_stream(
title: DiscTitle,
parsers: Vec<(u16, Box<dyn CodecParser>)>,
pid_to_track: Vec<(u16, usize)>,
) -> (PipelinedPesStream, Sender<DemuxBatch>) {
let (tx, rx) = bounded::<DemuxBatch>(8);
let stream =
PipelinedPesStream::new(dummy_demux_thread(), rx, title, parsers, pid_to_track);
(stream, tx)
}
/// A parser that emits exactly `n` frames per PES, with a fixed
/// codec_private. Lets tests assert routing/flush without depending on a
/// real codec's byte parsing.
struct CountingParser {
per_pes: usize,
flush_n: usize,
cp: Option<Vec<u8>>,
}
impl CodecParser for CountingParser {
fn parse(&mut self, pes: &PesPacket) -> Vec<super::super::codec::Frame> {
(0..self.per_pes)
.map(|i| super::super::codec::Frame {
coding: None,
source: None,
pts_ns: pes.pts.unwrap_or(0) + i as i64,
keyframe: i == 0,
discontinuity: false,
data: pes.data.clone(),
duration_ns: None,
})
.collect()
}
fn flush(&mut self) -> Vec<super::super::codec::Frame> {
(0..self.flush_n)
.map(|_| super::super::codec::Frame {
coding: None,
source: None,
pts_ns: 0,
keyframe: false,
discontinuity: false,
data: vec![0xEE],
duration_ns: None,
})
.collect()
}
fn codec_private(&self) -> Option<Vec<u8>> {
self.cp.clone()
}
}
fn ts_pes(pid: u16, data: Vec<u8>) -> PesPacket {
PesPacket {
source: None,
pid,
pts: Some(90_000),
dts: None,
data,
discontinuity: false,
}
}
/// CLEAN EOF: the demux worker sends the explicit `Eof` sentinel. The
/// consumer must return Ok(None) — a normal end-of-stream — and stay
/// Ok(None) on subsequent reads. (DemuxBatch::Eof doc: "explicit
/// clean-completion sentinel".)
#[test]
fn eof_sentinel_yields_clean_none() {
let (mut stream, tx) = make_stream(DiscTitle::empty(), vec![], vec![]);
tx.send(DemuxBatch::Eof).unwrap();
assert!(stream.read().unwrap().is_none(), "Eof → Ok(None)");
// The eof flag latches: a further read is still Ok(None), not an error.
assert!(stream.read().unwrap().is_none());
}
/// PANIC / BARE DISCONNECT: the channel closes WITHOUT an Eof (or Err)
/// sentinel — exactly what happens when the demux worker panics and drops
/// its sender. The consumer MUST surface DemuxThreadPanicked, never a
/// clean Ok(None) (which would silently truncate the output). This is the
/// truncation guard the module docstring promises.
#[test]
fn bare_disconnect_is_error_not_silent_eof() {
let (mut stream, tx) = make_stream(DiscTitle::empty(), vec![], vec![]);
drop(tx); // sender gone, no Eof sent → RecvError on the consumer side
let err = stream.read().expect_err("bare disconnect must be an error");
// E_DEMUX_THREAD_PANICKED (9013) maps to ErrorKind::Other.
assert_eq!(err.kind(), std::io::ErrorKind::Other);
let e = crate::error::Error::DemuxThreadPanicked;
assert!(
err.to_string().contains(&e.code().to_string()),
"error must carry the DemuxThreadPanicked code, got: {err}"
);
}
/// A `DemuxBatch::Err` from the worker (underlying reader error) is
/// terminal and must propagate to the caller verbatim, not be masked as
/// EOF.
#[test]
fn demux_err_propagates() {
let (mut stream, tx) = make_stream(DiscTitle::empty(), vec![], vec![]);
tx.send(DemuxBatch::Err(std::io::Error::from(
std::io::ErrorKind::PermissionDenied,
)))
.unwrap();
let err = stream.read().expect_err("Err batch must propagate");
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
}
/// consume_ts must route a PES to the track mapped to its PID and emit
/// the parser's frames in order. A PES whose PID is NOT in pid_to_track
/// must be dropped (no frame), never mis-attributed to another track.
#[test]
fn ts_routing_maps_pid_to_track_and_drops_untracked() {
let title = DiscTitle::empty();
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
0x1100,
Box::new(CountingParser {
per_pes: 2,
flush_n: 0,
cp: None,
}),
)];
let pid_to_track = vec![(0x1100u16, 3usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
// One tracked PES (PID 0x1100) and one untracked (PID 0x2222).
tx.send(DemuxBatch::Ts(vec![
ts_pes(0x1100, vec![0xAA, 0xBB]),
ts_pes(0x2222, vec![0xCC]),
]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
// Tracked PES → 2 frames on track 3, in order; untracked → nothing.
let f0 = stream.read().unwrap().expect("frame 0");
assert_eq!(f0.track, 3, "routed to the PID's mapped track");
assert_eq!(f0.data, vec![0xAA, 0xBB]);
let f1 = stream.read().unwrap().expect("frame 1");
assert_eq!(f1.track, 3);
// Only the two frames from the tracked PES exist, then clean EOF.
assert!(
stream.read().unwrap().is_none(),
"untracked PES dropped, EOF"
);
}
/// 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'),
// Propagate so the B1 gate can be driven end-to-end in tests.
discontinuity: pes.discontinuity,
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
/// 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
/// silently truncated.
#[test]
fn flush_tail_emitted_at_eof() {
let title = DiscTitle::empty();
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
0x1100,
Box::new(CountingParser {
per_pes: 0, // parse emits nothing; everything comes from flush
flush_n: 1,
cp: None,
}),
)];
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(0x1100, vec![0x01])]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
// No frames from parse; the single flush() frame must surface at EOF.
let tail = stream.read().unwrap().expect("flush tail frame at EOF");
assert_eq!(tail.track, 0);
assert_eq!(tail.data, vec![0xEE], "flush() tail, not dropped");
assert!(stream.read().unwrap().is_none());
}
/// A flush parser whose PID is not in pid_to_track must be skipped at EOF
/// (the `continue` guard) — no panic, no frame attributed to a phantom
/// track.
#[test]
fn flush_skips_parser_with_unmapped_pid() {
let title = DiscTitle::empty();
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
0x9999, // PID present as a parser but absent from pid_to_track
Box::new(CountingParser {
per_pes: 0,
flush_n: 5,
cp: None,
}),
)];
let pid_to_track = vec![]; // nothing mapped
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
tx.send(DemuxBatch::Eof).unwrap();
// The unmapped parser's 5 flush frames must be discarded, not emitted.
assert!(
stream.read().unwrap().is_none(),
"flush frames for an unmapped PID are skipped"
);
}
/// consume_ps must route by the REAL DVD PID (via PsPacket::dvd_pid).
/// An audio private-stream-1 packet (stream_id 0xBD, sub-id 0x80 → PID
/// 0xBD80) routes to the track mapped to 0xBD80. A packet with an
/// unmappable (stream_id, sub_id) is dropped, never mis-routed.
#[test]
fn ps_routing_uses_dvd_pid_and_drops_unmappable() {
let title = DiscTitle::empty();
// PID for AC-3 sub-id 0x80 is 0xBD00 | 0x80 = 0xBD80.
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
0xBD80,
Box::new(CountingParser {
per_pes: 1,
flush_n: 0,
cp: None,
}),
)];
let pid_to_track = vec![(0xBD80u16, 1usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
let mappable = PsPacket {
source: None,
stream_id: 0xBD,
sub_stream_id: Some(0x80),
pts: Some(90_000),
dts: None,
data: vec![0x12, 0x34],
};
// stream_id 0xC0 (MPEG audio) has no DVD PID mapping → dropped.
let unmappable = PsPacket {
source: None,
stream_id: 0xC0,
sub_stream_id: None,
pts: None,
dts: None,
data: vec![0xFF],
};
tx.send(DemuxBatch::Ps(vec![mappable, unmappable])).unwrap();
tx.send(DemuxBatch::Eof).unwrap();
let f = stream.read().unwrap().expect("one routed PS frame");
assert_eq!(f.track, 1, "routed by dvd_pid to track 1");
assert_eq!(f.data, vec![0x12, 0x34]);
assert!(stream.read().unwrap().is_none(), "unmappable PS dropped");
}
/// Build a single-video-stream title on `codec`, a [`CountingParser`] (1 frame
/// per PES it is handed), and feed three 0xE0 program-stream fragments that
/// together form TWO H.264 access units (AUD-delimited); only AU-start
/// fragments carry a PTS. Returns every emitted frame.
fn run_ps_fragments(codec: Codec) -> Vec<crate::pes::PesFrame> {
let mut title = DiscTitle::empty();
title.streams.push(crate::disc::Stream::Video(VideoStream {
pid: crate::mux::ps::DVD_VIDEO_PID,
codec,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
}));
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
crate::mux::ps::DVD_VIDEO_PID,
Box::new(CountingParser {
per_pes: 1,
flush_n: 0,
cp: None,
}),
)];
let pid_to_track = vec![(crate::mux::ps::DVD_VIDEO_PID, 0usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
let frag = |pts, data: &[u8]| PsPacket {
source: None,
stream_id: 0xE0,
sub_stream_id: None,
pts,
dts: None,
data: data.to_vec(),
};
tx.send(DemuxBatch::Ps(vec![
frag(Some(9_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA]), // AU1: AUD + slice head
frag(None, &[0xBB, 0xCC]), // AU1: slice tail (no PTS)
frag(Some(18_000), &[0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD]), // AU2 opener (AUD closes AU1)
]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
let mut out = Vec::new();
while let Some(f) = stream.read().unwrap() {
out.push(f);
}
out
}
/// PS-path integration: an H.264 access unit split across several fixed-size
/// PES fragments (only the first with a PTS) must be REJOINED so the parser
/// sees one AU-complete PES with the AU-START pts — not one bogus per-fragment
/// frame each with pts 0 (the HD-DVD truncation/corruption bug). The
/// `CountingParser` makes it observable: 3 fragments forming 2 AUs → 2 frames.
#[test]
fn ps_h264_au_split_across_fragments_reassembles_to_one_frame() {
let frames = run_ps_fragments(Codec::H264);
assert_eq!(
frames.len(),
2,
"3 fragments → 2 access units, not 3 frames"
);
assert_eq!(frames[0].track, 0);
assert_eq!(
frames[0].data,
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xAA, 0xBB, 0xCC],
"AU1 = fragment1 + fragment2 rejoined"
);
assert_eq!(
frames[0].pts, 9_000,
"AU carries its START pts, not the mid-fragment None→0"
);
assert_eq!(
frames[1].data,
vec![0, 0, 1, 0x09, 0xF0, 0, 0, 1, 0x65, 0xDD],
"AU2 flushed at EOF (no following boundary)"
);
assert_eq!(frames[1].pts, 18_000);
}
/// Contrast: a self-framing codec (MPEG-2 reassembles in its own parser) uses
/// a Passthrough assembler — the SAME three fragments pass straight through as
/// three frames, byte-identical to the pre-assembler behaviour. This proves the
/// reassembly is gated by codec and does not disturb the DVD/MPEG-2 path.
#[test]
fn ps_self_framing_codec_is_not_reassembled() {
let frames = run_ps_fragments(Codec::Mpeg2);
assert_eq!(
frames.len(),
3,
"MPEG-2 passthrough: one frame per fragment"
);
assert_eq!(frames[0].pts, 9_000);
assert_eq!(
frames[1].pts, 0,
"mid-fragment has no PTS under passthrough"
);
assert_eq!(frames[2].pts, 18_000);
}
/// A batch with no trackable packets must NOT terminate the stream early:
/// pump_one_batch loops to the next batch. Here an empty-but-untracked
/// batch is followed by a real frame batch — the consumer must skip the
/// first and deliver the second (not return Ok(None) prematurely).
#[test]
fn empty_batch_does_not_end_stream_early() {
let title = DiscTitle::empty();
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
0x1100,
Box::new(CountingParser {
per_pes: 1,
flush_n: 0,
cp: None,
}),
)];
let pid_to_track = vec![(0x1100u16, 0usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
// First batch: only an untracked PID → yields zero frames.
tx.send(DemuxBatch::Ts(vec![ts_pes(0x4444, vec![0x00])]))
.unwrap();
// Second batch: tracked PID → one frame.
tx.send(DemuxBatch::Ts(vec![ts_pes(0x1100, vec![0x55])]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
let f = stream.read().unwrap().expect("frame from the second batch");
assert_eq!(f.data, vec![0x55], "did not stop on the empty first batch");
}
/// write() on the read-only pipeline must return StreamReadOnly
/// (E9000 → Unsupported) — the highway is input-only.
#[test]
fn write_is_read_only_error() {
let (mut stream, _tx) = make_stream(DiscTitle::empty(), vec![], vec![]);
let frame = PesFrame {
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: false,
data: vec![1],
duration_ns: None,
};
let err = stream.write(&frame).expect_err("write must error");
assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
}
fn video_title(secondary: bool) -> DiscTitle {
let mut t = DiscTitle::empty();
t.streams.push(crate::disc::Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt2020,
display_aspect: None,
secondary,
label: String::new(),
measured_cicp: None,
}));
t
}
/// headers_ready() is false for a PRIMARY video track until its parser
/// produces codec_private — MKV can't write the container header without
/// init data, so the consumer must keep buffering.
#[test]
fn headers_not_ready_when_primary_video_lacks_codec_private() {
let title = video_title(false);
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
0x1011,
Box::new(CountingParser {
per_pes: 0,
flush_n: 0,
cp: None, // no codec_private yet
}),
)];
let pid_to_track = vec![(0x1011u16, 0usize)];
let (stream, _tx) = make_stream(title, parsers, pid_to_track);
assert!(
!stream.headers_ready(),
"primary video w/o codec_private not ready"
);
}
/// headers_ready() flips true once the primary video parser exposes
/// codec_private.
#[test]
fn headers_ready_when_primary_video_has_codec_private() {
let title = video_title(false);
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
0x1011,
Box::new(CountingParser {
per_pes: 0,
flush_n: 0,
cp: Some(vec![0x01, 0x02, 0x03]),
}),
)];
let pid_to_track = vec![(0x1011u16, 0usize)];
let (stream, _tx) = make_stream(title, parsers, pid_to_track);
assert!(stream.headers_ready(), "codec_private present → ready");
// codec_private(track) resolves track→PID→parser and returns the data.
assert_eq!(
stream.codec_private(0).as_deref(),
Some(&[0x01, 0x02, 0x03][..])
);
}
/// A SECONDARY video track without codec_private must NOT block
/// headers_ready() — the `!v.secondary` guard means PiP/secondary video
/// is exempt from the init-data gate.
#[test]
fn headers_ready_ignores_secondary_video_without_codec_private() {
let title = video_title(true); // secondary = true
let parsers: Vec<(u16, Box<dyn CodecParser>)> = vec![(
0x1011,
Box::new(CountingParser {
per_pes: 0,
flush_n: 0,
cp: None,
}),
)];
let pid_to_track = vec![(0x1011u16, 0usize)];
let (stream, _tx) = make_stream(title, parsers, pid_to_track);
assert!(
stream.headers_ready(),
"secondary video is exempt from the codec_private gate"
);
}
/// codec_private(track) returns None for a track index not present in
/// pid_to_track — no panic, no wrong-track data.
#[test]
fn codec_private_none_for_unmapped_track() {
let (stream, _tx) = make_stream(DiscTitle::empty(), vec![], vec![]);
assert_eq!(stream.codec_private(7), None);
}
/// An audio-only title (no video streams) is always headers_ready — the
/// codec_private gate only applies to primary video.
#[test]
fn headers_ready_true_for_audio_only_title() {
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 (stream, _tx) = make_stream(title, vec![], vec![]);
assert!(stream.headers_ready(), "no video → always ready");
}
/// finish() on the read-only pipeline is a no-op that returns Ok — the
/// consumer drives termination via read() returning None.
#[test]
fn finish_is_ok_noop() {
let (mut stream, _tx) = make_stream(DiscTitle::empty(), vec![], vec![]);
assert!(stream.finish().is_ok());
}
// --- DVD highway: keyframe flag must survive the PS → parser → frame path ---
/// Build a minimal MPEG-2 720x480/29.97 sequence header.
fn m2_seq_header() -> Vec<u8> {
let (w, h, aspect, fr): (u16, u16, u8, u8) = (720, 480, 2, 4);
let mut hdr = vec![0x00, 0x00, 0x01, 0xB3u8];
hdr.push((w >> 4) as u8);
hdr.push((((w & 0x0F) as u8) << 4) | (((h >> 8) & 0x0F) as u8));
hdr.push((h & 0xFF) as u8);
hdr.push((aspect << 4) | (fr & 0x0F));
hdr.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0x00]);
hdr
}
fn m2_gop() -> Vec<u8> {
vec![0x00, 0x00, 0x01, 0xB8u8, 0x00, 0x00, 0x00, 0x00]
}
/// Frame-picture AU: picture header (coding_type, temporal_reference) +
/// coding extension (frame picture, 2 fields) + slice.
fn m2_pic(coding_type: u8, tr: u16) -> Vec<u8> {
let b4 = ((tr >> 2) & 0xFF) as u8;
let b5 = (((tr & 0x03) as u8) << 6) | ((coding_type & 0x07) << 3);
let mut au = vec![0x00, 0x00, 0x01, 0x00u8, b4, b5, 0x00, 0x00];
au.extend_from_slice(&[0x00, 0x00, 0x01, 0xB5u8, 0x80, 0x00, 0x03, 0x00, 0x80]);
au.extend_from_slice(&[0xAA; 32]);
au
}
/// DVD seek-index regression at the HIGHWAY level. The real CLI mux runs
/// `PsDemuxer → PipelinedPesStream (codec parse) → frame out`, NOT the
/// codec parser straight into the muxer. The keyframe flag and per-frame
/// duration the `Mpeg2Parser` sets on each `Frame` must survive that path
/// (`from_codec_frame`) so the muxer's cluster/cue logic — which opens a
/// cluster + pushes a cue on `keyframe && track 0` — actually fires. If the
/// highway dropped the keyframe flag, every video I-frame would arrive as a
/// non-keyframe and the DVD MKV would get thousands of clusters with ZERO
/// cues (chapter-seek only, no scrub).
#[test]
fn dvd_highway_preserves_video_keyframe_and_duration() {
use crate::mux::codec::mpeg2::Mpeg2Parser;
let mut title = DiscTitle::empty();
title.streams.push(crate::disc::Stream::Video(VideoStream {
pid: crate::mux::ps::DVD_VIDEO_PID,
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
frame_rate: FrameRate::F29_97,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: Some((4, 3)),
secondary: false,
label: String::new(),
measured_cicp: None,
}));
let parsers: Vec<(u16, Box<dyn CodecParser>)> =
vec![(crate::mux::ps::DVD_VIDEO_PID, Box::new(Mpeg2Parser::new()))];
let pid_to_track = vec![(crate::mux::ps::DVD_VIDEO_PID, 0usize)];
let (mut stream, tx) = make_stream(title, parsers, pid_to_track);
// 6 GOPs × 12 frames, each GOP one PS batch with one PTS-stamped video
// PES (stream_id 0xE0 → DVD_VIDEO_PID). Decode order I + P/B.
let field_ns = 1_000_000_000i64 * 1001 / 30000 / 2;
let frame_ns = 2 * field_ns;
let gop_len = 12u16;
for g in 0..6i64 {
let mut es = m2_seq_header();
es.extend_from_slice(&m2_gop());
es.extend_from_slice(&m2_pic(1, 0)); // I-frame, keyframe
for tr in 1..gop_len {
let ct = if tr % 3 == 0 { 2 } else { 3 };
es.extend_from_slice(&m2_pic(ct, tr));
}
let gop_pts = (g * gop_len as i64 * frame_ns * 90_000 / 1_000_000_000) as u64;
tx.send(DemuxBatch::Ps(vec![PsPacket {
source: None,
stream_id: 0xE0,
sub_stream_id: None,
pts: Some(gop_pts),
dts: None,
data: es,
}]))
.unwrap();
}
tx.send(DemuxBatch::Eof).unwrap();
// Drain every frame THROUGH the highway's read().
let mut frames = Vec::new();
while let Some(f) = stream.read().unwrap() {
frames.push(f);
}
assert!(!frames.is_empty(), "highway produced no frames");
let keyframes = frames.iter().filter(|f| f.keyframe).count();
let dur_some = frames.iter().filter(|f| f.duration_ns.is_some()).count();
assert_eq!(
keyframes, 6,
"the 6 GOP-opening I-frames must arrive as keyframes THROUGH the \
highway (one per GOP); got {keyframes} — if 0, the keyframe flag \
is being lost in the pipelined path and the DVD seek index dies"
);
assert_eq!(
dur_some,
frames.len(),
"every DVD VFR frame must carry its duration through the highway \
(BlockGroup path)"
);
assert!(
frames.iter().all(|f| f.track == 0),
"video routed to track 0 (cluster/cue open requires track 0)"
);
}
}