mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Merge branch 'audit-lib-G7' into audit-fix-lib
This commit is contained in:
Generated
+16
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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]| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user