Merge branch 'audit-lib-G7' into audit-fix-lib

This commit is contained in:
Matthew Jackson
2026-10-01 15:12:46 -07:00
17 changed files with 1092 additions and 58 deletions
Generated
+16
View File
@@ -854,3 +854,19 @@ dependencies = [
"log",
"simd-adler32",
]
[[patch.unused]]
name = "freemkv-engine"
version = "1.7.7"
[[patch.unused]]
name = "freemkv-i18n"
version = "1.7.7"
[[patch.unused]]
name = "freemkv-keysources"
version = "1.7.7"
[[patch.unused]]
name = "libfreemkv"
version = "1.7.7"
+49
View File
@@ -545,6 +545,55 @@ mod tests {
assert!(saw, "PS PES must be demuxed and delivered");
}
// A PS input commonly ends with no program-end code: the last PES is only completed by
// flush() and must still be delivered, before the Eof, and every PES is stamped with its
// position in the whole input, across buffers.
#[test]
fn ps_tail_without_a_program_end_is_flushed_with_its_source() {
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4);
let (rc_tx, _rc_rx) = bounded::<Vec<u8>>(4);
let ps = super::super::ps::PsDemuxer::new();
let (_dt, rx) = DemuxThread::spawn_zero_copy(
pf_rx,
rc_tx,
(),
&crate::ctx::Ctx::default(),
None,
Some(ps),
)
.unwrap();
// A bounded PES (11 bytes), then, in the next buffer, an unbounded one.
let first = vec![
0x00, 0x00, 0x01, 0xE0, 0x00, 0x05, 0x80, 0x00, 0x00, 0x77, 0x88,
];
let second = vec![
0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x00, 0x00, 0x99, 0xAA,
];
pf_tx.send(Ok(first.clone())).unwrap();
pf_tx.send(Ok(second)).unwrap();
drop(pf_tx);
let batches = collect_batches(&rx, Duration::from_secs(5));
assert!(matches!(batches.last(), Some(DemuxBatch::Eof)));
let got: Vec<(Vec<u8>, u64)> = batches
.iter()
.filter_map(|b| match b {
DemuxBatch::Ps(p) => Some(p),
_ => None,
})
.flatten()
.map(|x| (x.data.clone(), x.source.expect("stamped").byte))
.collect();
assert_eq!(
got,
vec![
(vec![0x77, 0x88], 0),
(vec![0x99, 0xAA], first.len() as u64)
]
);
}
#[test]
fn no_demuxer_configured_still_recycles_and_eofs() {
// With neither ts nor ps set, the worker must still recycle buffers
+116 -5
View File
@@ -162,8 +162,7 @@ impl M2tsStream {
Repack::Adts(h)
}
DiscStream::Audio(a) if a.codec == crate::disc::Codec::Lpcm => {
let h = lpcm_bd_header(a, cp.as_deref());
if h.is_none() {
let Some(h) = lpcm_bd_header(a, cp.as_deref()) else {
tracing::warn!(
target: "mux",
track = i,
@@ -171,11 +170,11 @@ impl M2tsStream {
);
route.push(None);
continue;
}
};
// Advertise the layout actually packed, not a rejected source byte.
let depth = super::codec::lpcm::output_depth(cp.as_deref());
cp = h.map(|h| super::codec::lpcm::tagged_layout(h[0], depth));
h.map_or(Repack::Verbatim, Repack::Lpcm)
cp = Some(super::codec::lpcm::tagged_layout(h[0], depth));
Repack::Lpcm(h)
}
_ => Repack::Verbatim,
};
@@ -1176,4 +1175,116 @@ mod tests {
fn mvc_dependent_view_arriving_first_still_matches_the_base_dts() {
mvc_dependent_dts(true);
}
// An AAC track whose config cannot be signalled in ADTS is left out, not written as raw AUs.
#[test]
fn aac_without_a_usable_config_is_omitted() {
let mut title = make_title();
title.streams.push(audio(0x1100, Codec::Aac));
title.codec_privates.push(None);
let data = [fake_idr_pes_data(), vec![0x21, 0x10, 0x04]];
let (pids, ts) = mux_one_frame_each(&title, &data);
assert_eq!(pids.len(), 1, "only the video track is advertised");
assert!(
!ts.as_chunks::<192>().0.iter().any(|p| p[5] & 0x1F == 0x11),
"no packets on the AAC PID"
);
}
// Two tracks asking for one PID are not interleaved on it: the second moves.
#[test]
fn tracks_sharing_a_pid_get_distinct_pids() {
let mut title = make_title();
title.streams = vec![ac3_audio(0x1100), ac3_audio(0x1100)];
title.codec_privates = vec![None, None];
let a = vec![0x0B, 0x77, 1, 2];
let b = vec![0x0B, 0x77, 3, 4];
let (pids, ts) = mux_one_frame_each(&title, &[a.clone(), b.clone()]);
assert_ne!(pids[0], pids[1], "{pids:x?}");
let mut demux = crate::mux::ts::TsDemuxer::new(&pids);
let mut pes = demux.feed(&ts);
pes.extend(demux.flush());
let got = |pid| pes.iter().find(|p| p.pid == pid).map(|p| p.data.clone());
assert_eq!(got(pids[0]), Some(a));
assert_eq!(got(pids[1]), Some(b));
}
// An MPEG-2 multichannel extension has no BD-TS binding: never written, reported lost
// once its packets arrive.
#[test]
fn an_mp2_extension_track_is_left_out_and_reported() {
let mut title = make_title();
let DiscStream::Audio(base) = audio(0x00C0, Codec::Mp2) else {
unreachable!()
};
let ext = crate::disc::AudioStream {
pid: 0x00D0,
label: crate::disc::MP2_EXTENSION_LABEL.into(),
..base.clone()
};
title.streams.push(DiscStream::Audio(base));
title.streams.push(DiscStream::Audio(ext));
title.codec_privates = vec![None; 3];
let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let mut stream = M2tsStream::create(SharedSink(shared.clone()), &title).unwrap();
assert!(
stream.undelivered_streams().is_empty(),
"nothing arrived yet"
);
stream
.write(&frame(0, 0, true, fake_idr_pes_data()))
.unwrap();
stream
.write(&frame(2, 0, true, vec![0x7F, 0xF0, 1, 2]))
.unwrap();
assert_eq!(stream.undelivered_streams(), vec![2]);
stream.finish().unwrap();
drop(stream);
let (meta, _) = ts_after_header(&shared.lock().unwrap());
assert_eq!(meta.streams.len(), 2, "video and the base only");
}
// The PMT stream_type of each codec reads back as that codec.
#[test]
fn stream_types_read_back_as_their_codecs() {
let mut title = make_title();
let codecs = [
Codec::Ac3,
Codec::Dts,
Codec::TrueHd,
Codec::Ac3Plus,
Codec::DtsHdHr,
Codec::DtsHdMa,
];
for (k, c) in codecs.into_iter().enumerate() {
title.streams.push(audio(0x1100 + k as u16, c));
}
title
.streams
.push(DiscStream::Subtitle(crate::disc::SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
}));
title.codec_privates = vec![None; title.streams.len()];
let mut data = vec![fake_idr_pes_data()];
data.extend((0..codecs.len() + 1).map(|_| vec![0x0B, 0x77, 1, 2]));
let (_, ts) = mux_one_frame_each(&title, &data);
let got: Vec<Codec> = crate::mux::ts::scan_streams(&ts)
.expect("PAT/PMT present")
.iter()
.map(|s| match s {
DiscStream::Video(v) => v.codec,
DiscStream::Audio(a) => a.codec,
DiscStream::Subtitle(t) => t.codec,
})
.collect();
let mut want = vec![Codec::Hevc];
want.extend(codecs);
want.push(Codec::Pgs);
assert_eq!(got, want);
}
}
+49 -7
View File
@@ -111,6 +111,9 @@ pub struct MpgSink<W: Write + Send> {
writer: Option<W>,
mux: Option<Mux<W>>,
excluded: super::ps::UnstoredExtensions,
/// Clip-join PTS correction shared with the MKV muxer and the other sinks: a
/// multi-clip playlist's source PTS resets or jumps at each join.
timeline: crate::mux::timeline::TimelineContinuity,
origin_saturated: u64,
frames: u64,
finished: bool,
@@ -292,6 +295,7 @@ impl<W: Write + Send> MpgSink<W> {
.codec_privates
.get(video_track)
.and_then(|c| c.as_deref());
let video_out = route[video_track].ok_or(crate::error::Error::MpgNoVideoTrack)?;
let mut excluded = super::ps::UnstoredExtensions::new(title, "MPG");
excluded.retain(|i| route[i].is_none());
Ok(Self {
@@ -305,7 +309,7 @@ impl<W: Write + Send> MpgSink<W> {
route,
outs,
buffers,
video_out: 0,
video_out,
deriver: DtsDeriver::for_codec(codec, cp),
anchor: None,
armed: false,
@@ -318,6 +322,10 @@ impl<W: Write + Send> MpgSink<W> {
writer: Some(writer),
mux: None,
excluded,
timeline: crate::mux::timeline::TimelineContinuity::with_clips(
&title.clips,
title.content_format,
),
origin_saturated: 0,
frames: 0,
finished: false,
@@ -504,7 +512,9 @@ impl<W: Write + Send> MpgSink<W> {
Ok(())
}
// The system header and program stream map of the first pack (MS-21 §2.7.8).
// The system header and program stream map of the first pack (MS-21 §2.7.8). Side
// effects: unseen extensions are unrouted and the video buffer is sized, so it runs once,
// before `Mux::new` clones `self.buffers`.
fn first_pack_prefix(&mut self) -> Vec<u8> {
// J23: an extension with no packets in the origin window is not described; its
// packets, should they come later, are left out and reported like an orphan's.
@@ -667,11 +677,21 @@ impl<W: Write + Send> PesSink for MpgSink<W> {
return Ok(());
}
let is_video = out == self.video_out;
// Onto the continuous timeline first; `None` is material outside the clip marks.
let Some(pts_ns) = self.timeline.map(
frame.pts,
is_video,
frame.track,
is_video,
frame.source.map(|s| s.byte),
) else {
return Ok(());
};
// Drop video before the first keyframe: nothing decodes without it (tsmux's guard).
if is_video && !frame.keyframe && !self.armed {
return Ok(());
}
let rel = self.rel(frame.pts);
let rel = self.rel(pts_ns);
self.span = Some(
self.span
.map_or((rel, rel), |(lo, hi)| (lo.min(rel), hi.max(rel))),
@@ -759,10 +779,33 @@ impl<W: Write + Send> MpgSink<W> {
}
}
// Design §2.3 EOF: a short input is written, not failed; zero frames is MuxEmpty.
let seam_dropped = self.timeline.dropped_total();
if self.frames == 0 && seam_dropped > 0 {
return Err(crate::error::Error::SinkWroteNothing.into());
}
if seam_dropped > self.frames {
return Err(crate::error::Error::SeamPlanDroppedMost {
dropped: seam_dropped,
written: self.frames,
}
.into());
}
if seam_dropped > 0 {
tracing::info!(
target: "mux",
dropped = seam_dropped,
"frames outside the playlist's clip marks were dropped at clip joins"
);
}
if self.frames == 0 {
return Err(crate::error::Error::MuxEmpty.into());
}
self.maybe_set_origin(true)?;
let Some(m) = self.mux.as_mut() else {
return Err(crate::error::Error::MuxEmpty.into());
};
let finished = m.finish();
// After the final drain, so EOF-time padding and forced passes are counted.
let c = self.counters();
if c != MpgCounters::default() {
tracing::warn!(
@@ -772,14 +815,13 @@ impl<W: Write + Send> MpgSink<W> {
pstd_late_aus = c.pstd.late_aus,
pts_gap_over_0_7s = c.pstd.pts_gaps,
interleave_cap = c.pstd.interleave_cap,
padding_packs = c.pstd.padding_packs,
forced_eof = c.pstd.forced_eof,
rebased_gaps = c.pstd.rebased_gaps,
origin_saturated = c.origin_saturated,
"mpg: program stream needed corrections (counted, not refused)"
);
}
match self.mux.as_mut() {
Some(m) => m.finish(),
None => Err(crate::error::Error::MuxEmpty.into()),
}
finished
}
}
+81 -11
View File
@@ -324,17 +324,7 @@ impl<W: Write> Mux<W> {
fn commencement(s: &Stream, k: usize) -> Option<usize> {
let head = s.queue.front()?;
let q = s.queue.get(k)?;
let before: usize = if k == 0 {
0
} else {
head.au.data.len() - head.sent
+ s.queue
.iter()
.skip(1)
.take(k - 1)
.map(|q| q.au.data.len())
.sum::<usize>()
};
let before = if k == 0 { 0 } else { Self::first_byte(s, k)? };
let own = if k == 0 {
q.au.mark.checked_sub(head.sent)?
} else {
@@ -1029,6 +1019,86 @@ mod tests {
assert_eq!(clean.counters().forced_eof, 0);
}
// What no pack can ever take, even with the waits lifted, is an error at EOF, never a
// silent Ok with the AUs dropped: an LPCM AU shorter than one packing unit.
#[test]
fn eof_with_untakeable_aus_is_an_error() {
let spec = StreamSpec {
stream_id: pack::PRIVATE_STREAM_1,
payload: Payload::Lpcm {
sub_id: 0xA0,
channels: 2,
rate: 48_000,
},
buffer: 0,
sparse: false,
av: true,
};
let buf = BufferSpec {
stream_id: pack::PRIVATE_STREAM_1,
scale_1024: true,
size: 232,
};
let mut m = Mux::new(Vec::new(), vec![spec], vec![buf], Vec::new(), 25_200);
m.push(
0,
Au {
lpcm_bits: 16,
..au(9_000, 3, 0)
},
);
let e = m.finish().expect_err("an AU that cannot be packetized");
assert!(
e.to_string()
.contains(&crate::error::Error::MpgUnpacketized.to_string()),
"{e}"
);
}
// A declared audio track that never delivers is passed once the others lead it by the
// interleave cap, counted; before the cap the clock waits for it.
#[test]
fn a_silent_audio_track_is_passed_at_the_interleave_cap() {
let video = StreamSpec {
stream_id: 0xE0,
payload: Payload::Plain,
buffer: 0,
sparse: false,
av: true,
};
let audio = StreamSpec {
stream_id: 0xC0,
buffer: 1,
..video.clone()
};
let bufs = vec![
BufferSpec {
stream_id: 0xE0,
scale_1024: true,
size: 232,
},
BufferSpec {
stream_id: 0xC0,
scale_1024: false,
size: 128,
},
];
let mut m = Mux::new(Vec::new(), vec![video, audio], bufs, Vec::new(), 25_200);
m.push(0, au(90_000, 100, 0));
for k in 1..=7u64 {
m.push(0, au(90_000 + k * 90_000, 100, 0));
m.pump(false).unwrap();
}
assert_eq!(m.counters().interleave_cap, 0, "7 s: still waiting");
assert!(m.writer.is_empty(), "nothing is written while audio lags");
for k in 8..=12u64 {
m.push(0, au(90_000 + k * 90_000, 100, 0));
m.pump(false).unwrap();
}
assert_eq!(m.counters().interleave_cap, 1);
assert!(!m.writer.is_empty());
}
// B1: at EOF nothing is left behind. A zero-length AU used to wedge its stream and
// every AU after it; `finish` returned Ok with them queued.
#[test]
+120
View File
@@ -557,3 +557,123 @@ mod dar_tests {
assert_eq!(dar, None);
}
}
#[cfg(test)]
mod probe_tests {
use super::*;
// A sequence header for `w`x`h` with aspect code 1 and frame-rate code `rate`, then a
// sequence extension whose progressive_sequence bit is `progressive`.
fn video(w: u32, h: u32, rate: u8, progressive: bool) -> Vec<u8> {
vec![
0,
0,
1,
0xB3,
(w >> 4) as u8,
((w & 0xF) << 4) as u8 | (h >> 8) as u8,
h as u8,
0x10 | rate,
0,
0,
0,
0,
0,
0,
1,
0xB5,
0x14,
if progressive { 0x8A } else { 0x82 },
]
}
#[test]
fn frame_rate_codes_map_in_order() {
let want = [
FrameRate::F23_976,
FrameRate::F24,
FrameRate::F25,
FrameRate::F29_97,
FrameRate::F30,
FrameRate::F50,
FrameRate::F59_94,
FrameRate::F60,
];
for (i, rate) in want.into_iter().enumerate() {
let es = video(720, 576, i as u8 + 1, false);
assert_eq!(
probe_video(&es, Some(0x02)).unwrap().2,
rate,
"code {}",
i + 1
);
}
let es = video(720, 576, 0, false);
assert_eq!(probe_video(&es, Some(0x02)).unwrap().2, FrameRate::Unknown);
}
#[test]
fn scan_type_follows_the_progressive_bit() {
let res = |h, p| probe_video(&video(720, h, 3, p), Some(0x02)).unwrap().1;
assert_eq!(res(480, false), Resolution::R480i);
assert_eq!(res(576, false), Resolution::R576i);
assert_eq!(res(576, true), Resolution::R576p);
assert_eq!(res(1080, false), Resolution::R1080i);
assert_eq!(res(1080, true), Resolution::R1080p);
}
#[test]
fn mpeg_audio_rate_and_layer_come_from_the_header() {
let probe = |b1: u8, b2: u8| {
let (codec, _, rate) = probe_mpeg_audio(&[0xFF, b1, b2, 0xC4, 0, 0]);
(codec, rate)
};
// MPEG-1 (ID bit set): 44.1, 48, 32 kHz; Layer II is 0xFD, Layer III 0xFB.
assert_eq!(probe(0xFD, 0x00), (Codec::Mp2, SampleRate::S44_1));
assert_eq!(probe(0xFD, 0x04), (Codec::Mp2, SampleRate::S48));
assert_eq!(probe(0xFD, 0x08), (Codec::Mp2, SampleRate::Unknown));
assert_eq!(probe(0xFB, 0x00), (Codec::Mp3, SampleRate::S44_1));
assert_eq!(probe(0xFB, 0x04), (Codec::Mp3, SampleRate::S48));
// LSF (ID bit clear) never reads the MPEG-1 table.
assert_eq!(probe(0xF5, 0x00), (Codec::Mp2, SampleRate::Unknown));
assert_eq!(probe(0xF5, 0x04), (Codec::Mp2, SampleRate::Unknown));
}
#[test]
fn lpcm_rate_and_channels_come_from_the_header() {
let probe = |b: u8| probe_lpcm(&[0, b, 0]);
assert_eq!(probe(0x01), (AudioChannels::Stereo, SampleRate::S48));
assert_eq!(probe(0x11), (AudioChannels::Stereo, SampleRate::S96));
assert_eq!(probe(0x21), (AudioChannels::Stereo, SampleRate::S44_1));
assert_eq!(probe(0x31), (AudioChannels::Stereo, SampleRate::Unknown));
}
// A crafted map whose declared lengths overrun the packet is refused, never indexed.
#[test]
fn a_map_with_overrunning_lengths_is_refused() {
let ok = pack::psm(
&[],
&[pack::PsmEntry {
stream_type: 0x02,
stream_id: 0xE0,
descriptors: vec![],
}],
)
.unwrap();
assert!(parse_map(&ok).is_some());
let refuse = |at: usize, v: u8| {
let mut m = ok.clone();
m[at] = v;
let n = m.len() - 4;
let crc = pack::crc32(&m[..n]);
m[n..].copy_from_slice(&crc.to_be_bytes());
assert!(parse_map(&m).is_none(), "byte {at} = {v}");
};
// program_stream_info_length, elementary_stream_map_length, then an entry's
// info length: each past the end of the packet.
refuse(9, 0xF0);
refuse(11, 0xF0);
let info = usize::from(u16::from_be_bytes([ok[8], ok[9]]));
refuse(12 + info + 3, 0xF0);
}
}
+265
View File
@@ -2269,3 +2269,268 @@ fn native_16_bit_lpcm_frames_pack_as_16_bit() {
assert_eq!(aus.len(), 1);
assert_eq!((aus[0].lpcm_bits, aus[0].data.len()), (16, 16));
}
// A multi-clip playlist's source PTS restarts at a join: the sink must place the next clip
// after the first (the timeline corrector the MKV muxer uses), never on top of it.
#[test]
fn a_clip_join_pts_reset_continues_the_timeline() {
let fx = fixture(&Opts {
lpcm: false,
..Opts::default()
});
let mut sink = MpgSink::create(Vec::new(), &fx.title).expect("create");
for _clip in 0..2 {
for f in &fx.frames {
sink.write(f).expect("write");
}
}
sink.finish().expect("finish");
let out = sink.mux.take().expect("mux").into_writer();
let parsed = replay::parse(&out).expect("parses");
let pts: Vec<u64> = parsed
.pes
.iter()
.filter(|x| x.key == (0xE0, None))
.filter_map(|x| x.pts)
.collect();
let span = pts.iter().max().unwrap() - pts.iter().min().unwrap();
// Two 6 s clips: the output spans ~12 s, not one clip's 6 s.
assert!(span >= 11 * 90_000, "span {span} ticks");
}
// A track first seen after the origin window with a timestamp below the origin is clamped
// to 0 and counted, never wrapped through a negative-to-unsigned cast.
#[test]
fn a_timestamp_below_the_origin_is_clamped_and_counted() {
let fx = fixture(&Opts {
lpcm: false,
spu_tracks: 0,
..Opts::default()
});
let mut sink = MpgSink::create(Vec::new(), &fx.title).unwrap();
let mut f = PesFrame {
track: 1,
pts: VIDEO_START_NS,
keyframe: true,
data: vec![0x55; 1 << 20],
duration_ns: None,
discard_padding_ns: 0,
source: None,
coding: None,
};
for _ in 0..(HOLD_CAP_BYTES >> 20) + 2 {
sink.write(&f).unwrap();
}
assert_eq!(sink.counters().origin_saturated, 0);
f.pts = VIDEO_START_NS - 3_000 * MS;
f.data = vec![0x55; 64];
sink.write(&f).unwrap();
assert_eq!(sink.counters().origin_saturated, 1);
sink.finish().unwrap();
let out = sink.mux.take().unwrap().into_writer();
let parsed = replay::parse(&out).unwrap();
assert!(
parsed
.pes
.iter()
.any(|x| x.key.0 == 0xC0 && x.pts == Some(0)),
"the clamped AU is stamped 0"
);
}
// MS-5/MS-6: the system header's audio and video bounds count the carried streams, and the
// video buffer bound is vbv_buffer_size + 8 KiB in 1024-byte units.
#[test]
fn the_system_header_states_the_bounds() {
let r = run(&fixture(&Opts::default()));
let sh = &r.parsed.system_headers[0].1;
// Six audio streams (MP2 base and extension, AC-3, DTS, two LPCM); subpictures are not audio.
assert_eq!(sh[9] >> 2, 6, "audio_bound");
assert_eq!(sh[10] & 0x1F, 1, "video_bound");
let video = sh[12..].chunks(3).find(|e| e[0] == 0xE0).unwrap();
let size = u16::from(video[1] & 0x1F) << 8 | u16::from(video[2]);
assert_eq!(video[1] & 0x20, 0x20, "scale 1024");
assert_eq!(u64::from(size), (112 * 2048 + 8192) / 1024);
}
// The sequence_extension's vbv_buffer_size_extension carries the high bits of the buffer size.
#[test]
fn vbv_extension_bits_extend_the_size() {
let mut h = seq_header(720, 576, 3, 112, false);
let e = h.windows(4).position(|w| w == [0, 0, 1, 0xB5]).unwrap() + 4;
h[e + 4] = 2;
assert_eq!(vbv_bytes(&h), Some(((2u64 << 10) | 112) * 2048));
}
// MPG's first pack rate is R0: SD pictures at the SD base rate, HD at the HD one.
#[test]
fn the_base_rate_follows_the_picture_height() {
let first_rate = |res| {
let mut fx = fixture(&Opts {
secs: 2,
..Opts::default()
});
if let DiscStream::Video(v) = &mut fx.title.streams[0] {
v.resolution = res;
}
run(&fx).parsed.packs[0].rate
};
assert_eq!(first_rate(Resolution::R576i), R0_SD);
assert_eq!(first_rate(Resolution::R1080i), R0_HD);
}
// MS-22: a base layer is 11172 audio (0x03) when its header says MPEG-1, 13818-3 (0x04) when
// the ID bit marks a low-sampling-frequency stream.
#[test]
fn the_mp2_stream_type_follows_the_header_id_bit() {
let stream_type = |id_byte: u8| {
let mut fx = fixture(&Opts {
lpcm: false,
spu_tracks: 0,
secs: 2,
..Opts::default()
});
fx.title.streams.truncate(2);
for f in fx.frames.iter_mut().filter(|f| f.track == 1) {
f.data[1] = id_byte;
}
fx.frames.retain(|f| f.track <= 1);
let r = run(&fx);
let m = &r.parsed.psms[0].1;
let info = usize::from(u16::from_be_bytes([m[8], m[9]]));
let es = &m[12 + info..m.len() - 4];
let n = usize::from(u16::from_be_bytes([es[2], es[3]]));
let audio = &es[4 + n..];
assert_eq!(audio[1], 0xC0);
audio[0]
};
assert_eq!(stream_type(0xFD), 0x03);
assert_eq!(stream_type(0xF5), 0x04);
}
// EOF pads the last partial LPCM packing unit with silence rather than dropping its samples.
#[test]
fn a_partial_lpcm_unit_at_eof_is_padded_not_lost() {
let mut fx = fixture(&Opts {
secs: 2,
..Opts::default()
});
let last = fx.frames.iter().rfind(|f| f.track == 6).unwrap();
// Three stereo 24-bit sample frames: under any packing unit.
let tail = PesFrame {
pts: last.pts + 10 * MS,
data: vec![0x21; 3 * 6],
..last.clone()
};
fx.frames.push(tail);
let r = run(&fx);
let want: usize = fx
.frames
.iter()
.filter(|f| f.track == 6)
.map(|f| f.data.len())
.sum();
let mut parser = crate::mux::codec::lpcm::LpcmParser::new_dvd();
let mut got = 0;
for x in r.parsed.pes.iter().filter(|x| x.key == (0xBD, Some(0xA1))) {
let mut data = x.sub_hdr[4..7].to_vec();
data.extend_from_slice(&x.es);
let pes = crate::mux::ts::PesPacket {
source: None,
pid: 0,
pts: x.pts.map(|p| p as i64),
dts: None,
data,
discontinuity: false,
};
got += parser
.parse(&pes)
.iter()
.map(|f| f.data.len())
.sum::<usize>();
}
assert!(got >= want, "{got} bytes out of {want} in");
}
// Without a map the tracks sort video, MPEG audio, private audio by sub-stream, then
// subpictures, whatever the order the packets arrive in; 0x80-0x87 is AC-3, 0x88-0x8F DTS.
#[test]
fn an_unmapped_scan_orders_tracks_and_labels_private_audio() {
let private = |sub: u8| {
let mut d = vec![sub, 1, 0, 1];
d.resize(16, 0);
(0xBD, d)
};
let ps = scan_pack(
&[
private(0x20),
private(0x88),
private(0x80),
(0xC0, vec![0xFF, 0xFD, 0xC4, 0x00, 0, 0]),
(0xE0, scan_video()),
],
None,
);
let s = scan::scan_streams(&ps).unwrap();
let got: Vec<String> = s
.iter()
.map(|x| match x {
DiscStream::Video(_) => "video".into(),
DiscStream::Audio(a) => format!("{:?}:{:#x}", a.codec, a.pid),
DiscStream::Subtitle(t) => format!("{:?}", t.codec),
})
.collect();
assert_eq!(
got,
["video", "Mp2:0xc0", "Ac3:0xbd80", "Dts:0xbd88", "DvdSub"]
);
}
// An extension whose map pairs it with a base other than 0xC0|n is left out, and so is one
// whose base is itself an extension.
#[test]
fn a_map_pairing_an_extension_with_the_wrong_base_drops_it() {
let entry = |stream_type, stream_id, descriptors| pack::PsmEntry {
stream_type,
stream_id,
descriptors,
};
let e = [
entry(0x02, 0xE0, vec![]),
entry(
0x04,
0xC0,
pack::hierarchy_descriptor(pack::HIERARCHY_BASE, 0, 0).to_vec(),
),
entry(
0x04,
0xC1,
pack::hierarchy_descriptor(pack::HIERARCHY_BASE, 2, 0).to_vec(),
),
// 0xD1 embeds layer 0 (base 0xC0), but the IR pairs 0xD1 only with 0xC1.
entry(
0x04,
0xD1,
pack::hierarchy_descriptor(pack::HIERARCHY_EXTENSION, 3, 0).to_vec(),
),
];
let m = pack::psm(&[], &e).unwrap();
let ps = scan_pack(
&[
(0xE0, scan_video()),
(0xC0, vec![0xFF, 0xFD, 0xC4, 0x00, 0, 0]),
(0xC1, vec![0xFF, 0xFD, 0xC4, 0x00, 0, 0]),
(0xD1, vec![0x7F, 0xF1, 0x23, 0x45]),
],
Some(&m),
);
let s = scan::scan_streams(&ps).unwrap();
let pids: Vec<u16> = s
.iter()
.filter_map(|x| match x {
DiscStream::Audio(a) => Some(a.pid),
_ => None,
})
.collect();
assert_eq!(pids, vec![0xC0, 0xC1]);
}
+53
View File
@@ -750,6 +750,59 @@ mod tests {
assert_eq!(n, 1);
}
// `finish_incomplete` arms the reset itself; a sender that never reaches its drop (or a
// receiver waiting on the close) still sees the abort mark.
#[test]
fn finish_incomplete_resets_the_connection_at_once() {
use crate::pes::PesSink as _;
let (addr, handle) = spawn_ending_reader();
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
.unwrap()
.meta(&sample_title());
writer.write(&one_frame()).unwrap();
let linger = |w: &NetworkStream| match &w.mode {
Mode::Write { writer, .. } => {
socket2::SockRef::from(writer.get_ref()).linger().unwrap()
}
Mode::Read { .. } => unreachable!(),
};
assert_eq!(linger(&writer), None);
writer.finish_incomplete().unwrap();
assert_eq!(linger(&writer), Some(std::time::Duration::ZERO));
drop(writer);
let _ = handle.join().unwrap();
}
// A timing set for a stream the title does not have is an error, not an out-of-range
// index; one set after the header is on the wire is refused, not silently dropped.
#[test]
fn track_timing_is_range_and_header_checked() {
use crate::pes::PesSink as _;
let timing = crate::pes::TrackTiming {
codec_delay_ns: 1,
seek_preroll_ns: 2,
};
let mut timings = Vec::new();
assert!(set_timing(&mut timings, 1, timing, 2).is_ok());
assert_eq!(timings, vec![Default::default(), timing]);
assert!(set_timing(&mut timings, 2, timing, 2).is_err());
let (addr, handle) = spawn_ending_reader();
let mut writer = NetworkStream::connect_vetted(&addr.to_string(), false)
.unwrap()
.meta(&sample_title());
writer.write(&one_frame()).unwrap();
let e = writer.set_track_timing(0, timing).unwrap_err();
assert!(
e.to_string()
.contains(&crate::error::Error::StreamHeaderWritten.to_string()),
"{e}"
);
writer.finish().unwrap();
drop(writer);
let _ = handle.join().unwrap();
}
// A connection cut inside a frame (even with a clean FIN) is an error.
#[test]
fn a_connection_cut_mid_frame_is_an_error_at_the_receiver() {
+40
View File
@@ -872,6 +872,46 @@ mod tests {
);
}
/// A parser that holds one inter frame, flagged discontinuous, until flush.
struct HeldDanglingParser;
impl CodecParser for HeldDanglingParser {
fn parse(&mut self, _: &PesPacket) -> Vec<super::super::codec::Frame> {
vec![]
}
fn flush(&mut self) -> Vec<super::super::codec::Frame> {
vec![super::super::codec::Frame {
coding: None,
source: None,
pts_ns: 0,
keyframe: false,
discontinuity: true,
data: b"P".to_vec(),
duration_ns: None,
}]
}
fn codec_private(&self) -> Option<Vec<u8>> {
None
}
}
// B1 at EOF: a final inter frame held by the parser and flagged discontinuous goes
// through the same gate: it is dropped and counted, not written with dangling references.
#[test]
fn b1_gate_applies_to_the_frame_flushed_at_eof() {
let title = video_title(false);
let parsers: Vec<(u16, Box<dyn CodecParser>)> =
vec![(0x1011, Box::new(HeldDanglingParser))];
let (mut stream, tx) = make_stream(title, parsers, vec![(0x1011u16, 0usize)]);
tx.send(DemuxBatch::Ts(vec![ts_pes(0x1011, vec![1])]))
.unwrap();
tx.send(DemuxBatch::Eof).unwrap();
assert!(
stream.read().unwrap().is_none(),
"the dangling frame is dropped"
);
assert_eq!(stream.errors(), 1, "and counted");
}
/// 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.
+2 -1
View File
@@ -41,7 +41,8 @@ pub struct PsPacket {
/// PES stream ID (0xE0 for video, 0xC0 for audio, 0xBD for private, etc.).
pub stream_id: u8,
/// Sub-stream ID for private stream 1 (AC3: 0x80-0x87, DTS: 0x88-0x8F,
/// LPCM: 0xA0-0xA7, subtitles: 0x20-0x3F).
/// LPCM: 0xA0-0xA7, E-AC-3: 0xC0-0xCF, subtitles: 0x20-0x3F); for extended stream
/// id 0xFD, the PES `stream_id_extension`.
pub sub_stream_id: Option<u8>,
/// Presentation timestamp in 90kHz ticks.
pub pts: Option<u64>,
+12 -13
View File
@@ -543,18 +543,12 @@ where
};
let title = disc.titles[idx].clone();
let format = disc.content_format;
// Pass `DecryptKeys::None` to the decrypt decorator when --raw is set —
// the read stack still flows through the same producer+demux+parse
// pipeline, just without the AACS/CSS step. One highway for ISO reads.
let effective_keys = if opts.raw {
crate::decrypt::DecryptKeys::None
} else {
keys
};
// `--raw` keys are already `None`: the read stack still flows through the same
// producer+demux+parse pipeline, just without the AACS/CSS step.
let stream = build_iso_pipeline(
reader,
title,
effective_keys,
keys,
ISO_MUX_BATCH_SECTORS,
format,
opts.raw,
@@ -563,7 +557,7 @@ where
Ok(stream)
}
// `image_input` over the rip's key set (KU §3.1, §3.5): the gate is `check_decryptable`,
// `image_input_scanned` over the rip's key set (KU §3.1, §3.5): the gate is `check_decryptable`,
// and the mux reads through the set's reader. No lookup, no banking.
fn keyed_image_input<S>(
reader: S,
@@ -925,7 +919,7 @@ pub(crate) fn build_sector_pipeline<S: SectorSource + Send + 'static>(
// on CSS/unencrypted (per-2048-byte-sector) would reject any extent
// whose sector count isn't a multiple of 3.
let unit_align: u16 = match &keys {
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
crate::decrypt::DecryptKeys::Aacs { .. } => AACS_UNIT_SECTORS,
_ => 1,
};
let full_extents = extents.clone();
@@ -998,10 +992,12 @@ pub(crate) fn build_keyed_pipeline<S: SectorSource + Send + 'static>(
)
.map_err(io::Error::from)?;
let plan = IsoPlan {
extents: set.key_map().read_plan(&full_extents, 3),
extents: set
.key_map()
.read_plan(&full_extents, u32::from(AACS_UNIT_SECTORS)),
full_extents,
policy,
unit_align: 3,
unit_align: AACS_UNIT_SECTORS,
format: set.content_format(),
};
iso_pipeline_tail(decrypting, plan, title, ctx)
@@ -1167,6 +1163,9 @@ fn build_ps_pipeline(
iso_pipeline_tail(stage, plan, title, ctx).map(|p| p.with_video_stream_id(scan.video_id))
}
// Sectors in one AACS aligned unit: the `unit_align` of every keyed read plan.
const AACS_UNIT_SECTORS: u16 = crate::aacs::content::ALIGNED_UNIT_SECTORS as u16;
// Sectors per m2ts read: whole AACS units, ~1 MiB (the stage's byte-view refill).
const M2TS_READ_SECTORS: u16 = 510;
+36
View File
@@ -475,4 +475,40 @@ mod tests {
"the added base is logged"
);
}
// The pairing is per index: extension 0xD1 belongs to base 0xC1, never to 0xC0.
#[test]
fn an_mp2_extension_pairs_with_the_base_of_its_own_index() {
let mp2 = |pid: u16, label: &str| {
Stream::Audio(AudioStream {
pid,
codec: Codec::Mp2,
channels: AudioChannels::Stereo,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: label.into(),
})
};
let keep = |audio: Vec<u16>| {
let mut t = DiscTitle::empty();
t.streams = vec![
video(0x00E0),
mp2(0x00C0, ""),
mp2(0x00C1, ""),
mp2(0x00D1, crate::disc::MP2_EXTENSION_LABEL),
];
StreamSelection {
audio: PidFilter::Only(audio),
subtitle: PidFilter::All,
}
.apply(&mut t)
.unwrap();
pids(&t)
};
assert_eq!(keep(vec![0x00C1]), vec![0x00E0, 0x00C1, 0x00D1]);
assert_eq!(keep(vec![0x00C0]), vec![0x00E0, 0x00C0]);
assert_eq!(keep(vec![0x00D1]), vec![0x00E0, 0x00C1, 0x00D1]);
}
}
+56
View File
@@ -156,6 +156,62 @@ mod tests {
assert!(s.read().unwrap().is_none());
}
struct Rich(Frames);
impl PesSource for Rich {
fn read(&mut self) -> std::io::Result<Option<PesFrame>> {
self.0.read()
}
fn info(&self) -> &DiscTitle {
self.0.info()
}
fn track_timing(&self, track: usize) -> TrackTiming {
TrackTiming {
codec_delay_ns: track as u64 * 10,
seek_preroll_ns: 0,
}
}
fn headers_ready(&self) -> bool {
false
}
fn config_changes(&self) -> Vec<(usize, u64)> {
vec![(1, 4), (2, 7)]
}
fn errors(&self) -> u64 {
5
}
fn lost_bytes(&self) -> u64 {
9
}
}
// Per-track answers are asked of the inner source by its own index and come back under the
// kept numbering; the source-wide counters and readiness pass straight through.
#[test]
fn inner_answers_are_translated_and_counters_pass_through() {
let mut title = DiscTitle::empty();
title.streams = vec![audio(1), audio(2), audio(3)];
let src = Rich(Frames(title, Default::default()));
let sel = StreamSelection {
audio: PidFilter::Only(vec![3]),
subtitle: PidFilter::All,
};
let s = SelectedSource::wrap(Box::new(src), &sel).unwrap();
assert_eq!(
s.track_timing(0).codec_delay_ns,
20,
"inner track 2's timing"
);
assert_eq!(s.track_timing(1), TrackTiming::default());
assert_eq!(
s.config_changes(),
vec![(0, 7)],
"renumbered, unkept dropped"
);
assert_eq!((s.errors(), s.lost_bytes()), (5, 9));
assert!(!s.headers_ready());
}
// An unknown PID refuses at open, as on a disc title.
#[test]
fn an_unknown_pid_refuses() {
+45
View File
@@ -392,6 +392,51 @@ mod tests {
assert!(r.read().unwrap().is_none());
}
// A zero-frame title still puts the header on the wire: the reader sees the title.
#[test]
fn finish_without_frames_still_writes_the_header() {
let out = Shared::default();
let mut w = StdioStream::from_writer(&title_with_codec_privates(), Box::new(out.clone()));
w.finish().unwrap();
let mut r = reader(out.0.lock().unwrap().clone());
r.prime().unwrap();
assert_eq!(r.info().playlist, "StdioTitle");
assert!(r.read().unwrap().is_none());
}
// Frame framing follows the header: with no timing set (v1) frames carry no padding field
// and read back whole; with a timing set (v2) the DiscardPadding survives.
#[test]
fn frame_padding_follows_the_header_version() {
let round_trip = |timed: bool, padding: i64| {
let out = Shared::default();
let mut w =
StdioStream::from_writer(&title_with_codec_privates(), Box::new(out.clone()));
if timed {
let timing = crate::pes::TrackTiming {
codec_delay_ns: 5,
seek_preroll_ns: 9,
};
w.set_track_timing(0, timing).unwrap();
}
let mut f = frame(&[1, 2, 3]);
f.discard_padding_ns = padding;
w.write(&f).unwrap();
w.write(&frame(&[4, 5])).unwrap();
w.finish().unwrap();
let mut r = reader(out.0.lock().unwrap().clone());
let a = r.read().unwrap().unwrap();
let b = r.read().unwrap().unwrap();
assert!(r.read().unwrap().is_none());
(a.discard_padding_ns, a.data, b.data)
};
assert_eq!(round_trip(false, 0), (0, vec![1, 2, 3], vec![4, 5]));
assert_eq!(
round_trip(true, -2_500_000),
(-2_500_000, vec![1, 2, 3], vec![4, 5])
);
}
// prime() reads the header up front: info() is the stream's title before any frame,
// and the first read still returns the first frame.
#[test]
+89 -7
View File
@@ -169,13 +169,7 @@ impl PesAssembler {
bytes = self.buffer.len(),
"PES buffer cap exceeded; dropping partial PES and resyncing on next PUSI",
);
self.buffer.clear();
self.active = false;
self.header_remaining = 0;
// A dropped partial PES is a gap in the elementary stream — flag
// it so the NEXT completed PES carries a discontinuity, matching
// every other partial-drop path in this file (`drop_partial`).
self.pending_discontinuity = true;
self.drop_partial();
return;
}
// Grow by doubling but never past `cap`, so the allocation honours the share too.
@@ -1255,6 +1249,66 @@ mod tests {
assert!(out[1].data.starts_with(b"BBBB") && out[1].data.ends_with(&[b'b'; 184]));
}
// A packet repeating the previous CC with other data means a whole CC cycle (16
// packets) was lost: a continuation drops the open PES, a PUSI flags the new one.
#[test]
fn same_cc_with_other_data_is_a_gap() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
let mut out = demux.feed(&ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
out.extend(demux.feed(&ts_payload_packet(pid, false, 0, b"XXXX")));
out.extend(demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"CCCC"))));
out.extend(demux.flush());
assert_eq!(out.len(), 1, "A is dropped, not spliced with X: {out:?}");
assert!(out[0].data.starts_with(b"CCCC") && out[0].discontinuity);
let mut demux = TsDemuxer::new(&[pid]);
let mut out = demux.feed(&ts_payload_packet(pid, true, 3, &pes_start(b"AAAA")));
out.extend(demux.feed(&ts_payload_packet(pid, true, 3, &pes_start(b"BBBB"))));
out.extend(demux.flush());
assert_eq!(out.len(), 2, "{out:?}");
assert!(!out[0].discontinuity);
assert!(out[1].discontinuity, "the lost cycle flags the new PES");
}
// A header-only PES holds no ES bytes: no empty packet reaches the codec parsers.
#[test]
fn a_header_only_pes_is_not_emitted() {
let pid = 0x1011;
let exact = |pusi: bool, cc: u8, es: &[u8]| {
let mut p = es_packet_exact(pid, pusi, &pes_start(es));
p[7] |= cc;
p
};
let mut demux = TsDemuxer::new(&[pid]);
let mut out = demux.feed(&exact(true, 0, &[]));
out.extend(demux.feed(&exact(true, 1, b"BBBB")));
out.extend(demux.flush());
assert_eq!(out.len(), 1, "{out:?}");
assert_eq!(out[0].data, b"BBBB");
let mut demux = TsDemuxer::new(&[pid]);
let mut out = demux.feed(&exact(true, 0, &[]));
out.extend(demux.flush());
assert!(out.is_empty(), "{out:?}");
}
// A zero-filled gap that ends mid-packet leaves its partial tail in the remainder, so the
// next feed completes it and the packet after the gap stays on the grid.
#[test]
fn a_gap_ending_mid_packet_keeps_the_next_feed_aligned() {
let pid = 0x1011;
let mut demux = TsDemuxer::new(&[pid]);
let mut first = ts_payload_packet(pid, true, 0, &pes_start(b"AAAA"));
first.extend(vec![0u8; 3 * BD_SOURCE_PACKET_BYTES + 50]);
let mut second = vec![0u8; BD_SOURCE_PACKET_BYTES - 50];
second.extend(ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
let mut out = demux.feed(&first);
out.extend(demux.feed(&second));
out.extend(demux.flush());
assert_eq!(out.len(), 2, "{out:?}");
assert!(out[1].data.starts_with(b"BBBB"));
}
// A PUSI whose payload is not a PES start leaves no PES open: the continuations after it
// are not emitted as a headless frame, and the next PES is flagged.
#[test]
@@ -2672,6 +2726,34 @@ mod tests {
assert_eq!(v.resolution, Resolution::R1080i, "MPEG-2 defaults to 1080i");
}
// Program-level descriptors (a non-zero program_info_length) are skipped, not read as ES
// entries.
#[test]
fn scan_streams_skips_program_descriptors() {
let pmt_pid = 0x0100u16;
let mut data = pat_packet(pmt_pid);
let mut body = [0xFFu8; 184];
body[0] = 0x00;
let s = 1;
body[s] = 0x02;
// 9 fixed + 6 descriptor + 5 ES entry + 4 CRC.
let section_length: usize = 9 + 6 + 5 + 4;
body[s + 1] = 0xB0;
body[s + 2] = section_length as u8;
body[s + 3..s + 12].copy_from_slice(&[0, 1, 0xC1, 0, 0, 0xE0, 0, 0xF0, 6]);
// HDMV registration descriptor.
body[s + 12..s + 18].copy_from_slice(&[0x05, 0x04, b'H', b'D', b'M', b'V']);
let p = s + 18;
body[p..p + 5].copy_from_slice(&[0x1B, 0xE0 | 0x10, 0x11, 0xF0, 0x00]);
data.extend(bdts_packet(body, pmt_pid, true));
data.extend(pat_packet(pmt_pid));
let streams = scan_streams(&data).expect("the PMT parses");
assert!(
matches!(&streams[..], [crate::disc::Stream::Video(v)] if v.pid == 0x1011 && v.codec == crate::disc::Codec::H264),
"{streams:?}"
);
}
#[test]
fn scan_streams_oversized_prog_info_len_does_not_panic() {
// Regression: a PMT with prog_info_len larger than the section body must
+42 -14
View File
@@ -107,8 +107,8 @@ pub struct TsMuxer<W: Write> {
/// the borrow of the converted bytes does not conflict with the `&mut self`
/// the writer needs.
annex_b: Vec<u8>,
/// Per-track arrival-time copy of `params_written` for the pre-keyframe drop guard,
/// which runs before the start-up hold (design §2.3 tick-domain rule 1).
/// Per-track: a video frame has arrived past the pre-keyframe drop guard, which runs
/// before the start-up hold (design §2.3 tick-domain rule 1).
arrival_armed: Vec<bool>,
/// Per video track: the online DTS deriver, built at its first frame.
dts: Vec<Option<DtsDeriver>>,
@@ -506,8 +506,8 @@ impl<W: Write> TsMuxer<W> {
self.write_psi_if_due(pts_90k)?;
// NAL video (HEVC/H.264): convert length-prefixed NALUs to Annex B and prepend
// codec_private params on the first keyframe only (arm `params_written` even if
// data is empty, else later non-key frames fail the drop guard). Others pass through.
// codec_private params on the first keyframe only (`params_written` latches even if
// data is empty, so no later keyframe re-prepends them). Others pass through.
let mut annex_b = std::mem::take(&mut self.annex_b);
let convert = is_video && self.is_nal_video(track);
if convert {
@@ -715,8 +715,8 @@ impl<W: Write> TsMuxer<W> {
c
}
/// Flush the underlying writer. BD-TS needs no stream trailer, so this
/// only drains buffering; the muxer remains usable afterwards.
/// End the stream: resolve the DTS start-up windows, drain the hold and flush the
/// writer. BD-TS needs no stream trailer. Call once, after the last frame.
///
/// Returns [`Error::MuxEmpty`](crate::error::Error::MuxEmpty) when not a
/// single frame was emitted: an `m2ts://` sink that wrote only the FMKV
@@ -1047,10 +1047,10 @@ mod tests {
}
#[test]
fn empty_data_keyframe_arms_params_so_later_frames_survive() {
// An empty-data keyframe must still arm params_written; otherwise
// every subsequent non-key frame would be dropped by the
// pre-keyframe guard and the track would emit no real frames.
fn empty_data_keyframe_arms_the_guard_so_later_frames_survive() {
// An empty-data keyframe must still arm the pre-keyframe guard; otherwise
// every subsequent non-key frame would be dropped and the track would
// emit no real frames.
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = TsMuxer::new(&mut sink, &[VIDEO_PID]);
@@ -1350,11 +1350,11 @@ mod tests {
);
}
// A non-NAL video track must still arm `params_written`, or every later
// non-keyframe fails the drop guard and silently vanishes — same class of
// bug `empty_data_keyframe_arms_params_so_later_frames_survive` guards.
// A non-NAL video keyframe must still arm the pre-keyframe guard, or every later
// non-keyframe silently vanishes — same class of bug
// `empty_data_keyframe_arms_the_guard_so_later_frames_survive` guards.
#[test]
fn non_nal_video_keyframe_arms_params_so_later_frames_survive() {
fn non_nal_video_keyframe_arms_the_guard_so_later_frames_survive() {
let key: Vec<u8> = vec![0x00, 0x00, 0x01, 0xB3, 0xAA, 0xBB];
let non_key: Vec<u8> = vec![0x00, 0x00, 0x01, 0xB6, 0xCC, 0xDD];
@@ -1438,6 +1438,34 @@ mod tests {
assert_eq!(pats, 13);
}
// The PES stream_id follows the declared stream_type: the MPEG audio types (13818-1
// Table 2-22) take an audio id, everything else private_stream_1.
#[test]
fn mpeg_audio_stream_types_get_an_audio_stream_id() {
let types = [0x03u8, 0x04, 0x0F, 0x81, 0x06];
let pids: Vec<u16> = (0..types.len() as u16).map(|k| AUDIO_PID + k).collect();
let mut sink: Vec<u8> = Vec::new();
{
let mut mux = TsMuxer::new(&mut sink, &pids);
mux.set_program(types.to_vec()).unwrap();
for t in 0..types.len() {
mux.write_frame(t, 0, true, &[0xFF, 0xF1, 0x4C, 0x80])
.unwrap();
}
mux.finish().unwrap();
}
let packets = parse_bd_ts(&sink);
let ids: Vec<u8> = pids
.iter()
.map(|&pid| {
let first = packets.iter().find(|p| p.pid == pid && p.pusi).unwrap();
assert_eq!(first.payload[..3], [0, 0, 1]);
first.payload[3]
})
.collect();
assert_eq!(ids, [0xC0, 0xC0, 0xC0, 0xBD, 0xBD]);
}
// A PES header's 33-bit PTS and DTS (DTS = PTS when absent), 90 kHz.
fn pes_pts_dts(payload: &[u8]) -> (u64, u64) {
let ts = |b: &[u8]| {
+21
View File
@@ -404,6 +404,27 @@ mod tests {
t
}
// An explicit aspect wins; SD without one is unknown; HD falls back to the coded size.
#[test]
fn display_aspect_ratio_falls_back_by_resolution() {
let t = video_title(
Codec::Hevc,
Resolution::R1080p,
FrameRate::F24,
ColorSpace::Bt709,
);
let DiscStream::Video(mut v) = t.streams[0].clone() else {
unreachable!()
};
assert_eq!(display_aspect_ratio(&v, 1920, 1080), (1920, 1080));
assert_eq!(display_aspect_ratio(&v, 0, 0), (0, 1));
v.display_aspect = Some((16, 9));
assert_eq!(display_aspect_ratio(&v, 1920, 1080), (16, 9));
v.display_aspect = None;
v.resolution = Resolution::R576i;
assert_eq!(display_aspect_ratio(&v, 720, 576), (0, 1));
}
fn src(medium: Medium, path: &str, title: usize) -> SourceInfo {
SourceInfo {
medium,