Stop the live rip path muxing Blu-ray 3D differently from the ISO path
Five defects, four of them the same shape: a local reimplementation of logic the crate already had, which had drifted from it. Each is now fixed by calling the canonical version rather than by patching the copy. DiscStream::new — the live disc:// path — built every parser through the plain codec lookup and never asked whether a video stream was an MVC dependent view, though resolve::build_demux_state does. The same 3D disc therefore muxed correctly from an ISO and incorrectly ripped live. The open-coded loop is gone; both paths now call build_demux_state. collect_psi_section reimplemented the continuity-counter gap test and disagreed with process_packet in the same file: it tolerated neither a duplicate packet nor an adaptation-field-only packet, which per ISO/IEC 13818-1 §2.4.3.3 does not increment the counter. A spec-legal PMT continuation was read as desync and the title's stream list came back empty. Both callers now share one `cc_is_gap`, and a duplicate packet's payload is no longer appended twice — doing so would have corrupted the section the check exists to protect. The json:// sink called the channel-count and sample-rate accessors unconditionally, and both fabricate a concrete value for Unknown, so it reported a confident 5.1 at 48 kHz for audio whose format was unknown while its own neighbouring string fields said "unknown". The keys are now omitted, matching mkv.rs. This matters more than it did: a sample-rate ladder fixed earlier in this audit means Unknown now reaches consumers that used to receive a wrong-but-concrete value. For an audio:// or sub:// sink the reference video track's output is filtered out, so its first PTS was never recorded and every delay was computed against zero — baking a wrong DELAY into the filename. The reference is now recorded whenever a frame is on the reference track, independent of whether that track has an output, so a normal title gets a correct delay; where no reference is ever observed the tag is omitted rather than guessed. A third copy of the channel/sample-rate mapping exists in src/diag.rs and was left alone as outside the confirmed set. It is the same drift shape and is recorded for the next round.
This commit is contained in:
+118
-5
@@ -660,6 +660,15 @@ pub struct DemuxSink {
|
|||||||
/// Index = track id; `None` for unselected tracks.
|
/// Index = track id; `None` for unselected tracks.
|
||||||
tracks: Vec<Option<TrackOut>>,
|
tracks: Vec<Option<TrackOut>>,
|
||||||
ref_video_track: Option<usize>,
|
ref_video_track: Option<usize>,
|
||||||
|
/// First PTS observed on `ref_video_track`, recorded in `write()` REGARDLESS
|
||||||
|
/// of whether that track has a `TrackOut`. The DELAY reference cannot live in
|
||||||
|
/// `TrackOut::first_pts_ns`: `audio://` / `sub://` filter the video track's
|
||||||
|
/// output away in `create()`, so no `TrackOut` exists to record it, and the
|
||||||
|
/// old `unwrap_or(0)` fallback then measured every delay against a reference
|
||||||
|
/// of zero and baked a plausible-looking wrong `DELAY` into the filename.
|
||||||
|
/// `None` = no reference seen → no delay is emitted at all (see
|
||||||
|
/// `apply_delays`).
|
||||||
|
ref_first_pts_ns: Option<i64>,
|
||||||
timeline: TimelineContinuity,
|
timeline: TimelineContinuity,
|
||||||
finished: bool,
|
finished: bool,
|
||||||
}
|
}
|
||||||
@@ -732,6 +741,7 @@ impl DemuxSink {
|
|||||||
opts: opts.clone(),
|
opts: opts.clone(),
|
||||||
tracks,
|
tracks,
|
||||||
ref_video_track,
|
ref_video_track,
|
||||||
|
ref_first_pts_ns: None,
|
||||||
timeline: TimelineContinuity::new(),
|
timeline: TimelineContinuity::new(),
|
||||||
finished: false,
|
finished: false,
|
||||||
})
|
})
|
||||||
@@ -758,11 +768,19 @@ impl DemuxSink {
|
|||||||
if self.opts.delay_mode == DelayMode::None {
|
if self.opts.delay_mode == DelayMode::None {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let ref_pts = self
|
// No video reference (audio-only title, or the reference track never
|
||||||
.ref_video_track
|
// produced a frame) → there is nothing to measure a delay against.
|
||||||
.and_then(|t| self.tracks.get(t).and_then(|o| o.as_ref()))
|
// OMIT the delay entirely rather than fall back to a reference of zero:
|
||||||
.and_then(|t| t.first_pts_ns)
|
// a filename claiming `DELAY 600ms` when the real offset is unknown is a
|
||||||
.unwrap_or(0);
|
// silently wrong number that downstream muxers will act on, while a
|
||||||
|
// missing tag is simply "no delay information", which is the truth.
|
||||||
|
let Some(ref_pts) = self.ref_first_pts_ns else {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "mux",
|
||||||
|
"demux sink: no reference video PTS observed; omitting audio DELAY metadata"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
let mut sidecar_lines = String::new();
|
let mut sidecar_lines = String::new();
|
||||||
|
|
||||||
@@ -849,6 +867,12 @@ impl Stream for DemuxSink {
|
|||||||
// non-video epoch driver would ratchet the frontier on sparse/lagging PTS.
|
// non-video epoch driver would ratchet the frontier on sparse/lagging PTS.
|
||||||
let drives = Some(frame.track) == self.ref_video_track;
|
let drives = Some(frame.track) == self.ref_video_track;
|
||||||
let pts = self.timeline.adjust(frame.pts, drives);
|
let pts = self.timeline.adjust(frame.pts, drives);
|
||||||
|
if drives {
|
||||||
|
// Delay reference: recorded here, not in the track's `TrackOut`, so
|
||||||
|
// it survives the `audio://` / `sub://` kind filter dropping the
|
||||||
|
// video output.
|
||||||
|
self.ref_first_pts_ns.get_or_insert(pts);
|
||||||
|
}
|
||||||
if let Some(Some(t)) = self.tracks.get_mut(frame.track) {
|
if let Some(Some(t)) = self.tracks.get_mut(frame.track) {
|
||||||
t.first_pts_ns.get_or_insert(pts);
|
t.first_pts_ns.get_or_insert(pts);
|
||||||
t.writer.write_frame(&mut t.w, frame, pts)?;
|
t.writer.write_frame(&mut t.w, frame, pts)?;
|
||||||
@@ -967,6 +991,95 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `audio://` filters the video track's file out, but the video track is
|
||||||
|
/// still the DELAY reference. The delay must be measured against the video's
|
||||||
|
/// actual first PTS, not against an assumed zero — a filename that says
|
||||||
|
/// `DELAY 600ms` when the true audio offset is 100ms is worse than no tag.
|
||||||
|
#[test]
|
||||||
|
fn audio_only_sink_delays_against_filtered_video_reference() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let title = title_with(
|
||||||
|
vec![video_stream(Codec::Mpeg2), audio_stream(Codec::Ac3, "eng")],
|
||||||
|
vec![None, None],
|
||||||
|
);
|
||||||
|
let opts = DemuxOptions {
|
||||||
|
base: "Ao".to_string(),
|
||||||
|
kind_filter: Some(TrackKind::Audio),
|
||||||
|
export_chapters: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||||||
|
// Video starts at 500ms, audio at 600ms → true delay is +100ms.
|
||||||
|
sink.write(&PesFrame {
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
track: 0,
|
||||||
|
pts: 500_000_000,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![0x00, 0x00, 0x01, 0xB3],
|
||||||
|
duration_ns: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
sink.write(&PesFrame {
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
track: 1,
|
||||||
|
pts: 600_000_000,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![0x0B, 0x77],
|
||||||
|
duration_ns: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
sink.finish().unwrap();
|
||||||
|
|
||||||
|
let names: Vec<String> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().any(|n| n == "Ao t01 eng AC3 DELAY 100ms.ac3"),
|
||||||
|
"audio delay must be relative to the filtered video reference \
|
||||||
|
(500ms), got {names:?}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With no video reference at all (audio-only title), there is nothing to
|
||||||
|
/// measure the delay against. Omit the DELAY tag rather than emit one
|
||||||
|
/// computed against a fabricated zero reference.
|
||||||
|
#[test]
|
||||||
|
fn no_video_reference_omits_delay_tag() {
|
||||||
|
let dir = tempdir();
|
||||||
|
let title = title_with(vec![audio_stream(Codec::Ac3, "eng")], vec![None]);
|
||||||
|
let opts = DemuxOptions {
|
||||||
|
base: "NoRef".to_string(),
|
||||||
|
export_chapters: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut sink = DemuxSink::create(&dir, &title, &opts).unwrap();
|
||||||
|
sink.write(&PesFrame {
|
||||||
|
coding: None,
|
||||||
|
source: None,
|
||||||
|
track: 0,
|
||||||
|
pts: 600_000_000,
|
||||||
|
keyframe: true,
|
||||||
|
data: vec![0x0B, 0x77],
|
||||||
|
duration_ns: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
sink.finish().unwrap();
|
||||||
|
|
||||||
|
let names: Vec<String> = std::fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
names.iter().all(|n| !n.to_lowercase().contains("delay")),
|
||||||
|
"no video reference → no DELAY tag, got {names:?}"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Annex-B reframing ────────────────────────────────────────────────────
|
// ── Annex-B reframing ────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set
|
// The length-prefixed → Annex-B conversion and the hvcC/avcC param-set
|
||||||
|
|||||||
+128
-28
@@ -265,34 +265,17 @@ impl DiscStream {
|
|||||||
// for non-DVD or when the probe yields nothing.
|
// for non-DVD or when the probe yields nothing.
|
||||||
crate::disc::dvd_audio_probe::probe_and_remap(&mut reader, &mut title);
|
crate::disc::dvd_audio_probe::probe_and_remap(&mut reader, &mut title);
|
||||||
|
|
||||||
let mut pids = Vec::new();
|
// Parser table + PID map + demuxer come from the CANONICAL builder shared
|
||||||
let mut parsers = Vec::new();
|
// with the file-backed highway (`resolve::build_demux_state`). This used
|
||||||
let mut pid_to_track = Vec::new();
|
// to be an open-coded copy of the same loop, which drifted: it built every
|
||||||
for (idx, s) in title.streams.iter().enumerate() {
|
// parser with plain `parser_for_codec` and so never routed a Blu-ray 3D
|
||||||
let (pid, codec) = match s {
|
// MVC dependent (right-eye) view to the param-set-passthrough parser
|
||||||
crate::disc::Stream::Video(v) => (v.pid, v.codec),
|
// (ISO/IEC 14496-10 Annex H) — the same 3D disc muxed correctly from an
|
||||||
crate::disc::Stream::Audio(a) => (a.pid, a.codec),
|
// ISO and incorrectly from a live drive. Call the canonical builder rather
|
||||||
crate::disc::Stream::Subtitle(s) => (s.pid, s.codec),
|
// than repeating its dispatch, so a future rule added there cannot go
|
||||||
};
|
// missing on the `disc://` path again.
|
||||||
pids.push(pid);
|
let (parsers, pid_to_track, ts_demuxer, ps_demuxer) =
|
||||||
pid_to_track.push((pid, idx));
|
super::resolve::build_demux_state(&title, content_format);
|
||||||
let is_dvd_ps = matches!(content_format, crate::disc::ContentFormat::MpegPs);
|
|
||||||
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut ts_demuxer = None;
|
|
||||||
let mut ps_demuxer = None;
|
|
||||||
match content_format {
|
|
||||||
crate::disc::ContentFormat::MpegPs => {
|
|
||||||
ps_demuxer = Some(super::ps::PsDemuxer::new());
|
|
||||||
}
|
|
||||||
crate::disc::ContentFormat::BdTs => {
|
|
||||||
let ts_pids: Vec<u16> = pids.clone();
|
|
||||||
if !ts_pids.is_empty() {
|
|
||||||
ts_demuxer = Some(super::ts::TsDemuxer::new(&ts_pids));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AACS decrypts whole 6144-byte (3-sector) units keyed off each read
|
// AACS decrypts whole 6144-byte (3-sector) units keyed off each read
|
||||||
// buffer's first 16 bytes, so reads/skips must stay 3-sector aligned.
|
// buffer's first 16 bytes, so reads/skips must stay 3-sector aligned.
|
||||||
@@ -1139,6 +1122,123 @@ mod tests {
|
|||||||
assert_eq!(frames, 0);
|
assert_eq!(frames, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A Blu-ray 3D title: an AVC base view plus the MVC dependent (right-eye)
|
||||||
|
/// view, marked by `MVC_DEPENDENT_LABEL`.
|
||||||
|
fn mvc_title() -> DiscTitle {
|
||||||
|
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
|
||||||
|
let view = |pid: u16, label: &str| {
|
||||||
|
crate::disc::Stream::Video(VideoStream {
|
||||||
|
pid,
|
||||||
|
codec: Codec::H264,
|
||||||
|
resolution: Resolution::R1080p,
|
||||||
|
frame_rate: FrameRate::F23_976,
|
||||||
|
hdr: HdrFormat::Sdr,
|
||||||
|
color_space: ColorSpace::Bt709,
|
||||||
|
display_aspect: None,
|
||||||
|
secondary: false,
|
||||||
|
label: label.to_string(),
|
||||||
|
measured_cicp: None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let mut t = synthetic_title(8);
|
||||||
|
t.streams = vec![
|
||||||
|
view(0x1011, ""), // base view
|
||||||
|
view(0x1012, crate::disc::MVC_DEPENDENT_LABEL), // dependent view
|
||||||
|
];
|
||||||
|
t.content_format = ContentFormat::BdTs;
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dependent-view access unit: PPS (NAL 8) + coded-slice-extension (NAL 20),
|
||||||
|
/// no IDR. The base-view parser strips the PPS from a non-keyframe AU; the
|
||||||
|
/// MVC passthrough parser keeps every parameter set in-band so each dependent
|
||||||
|
/// frame is a self-contained access unit (ISO/IEC 14496-10 Annex H).
|
||||||
|
fn mvc_dependent_au_pes(pid: u16) -> crate::mux::ts::PesPacket {
|
||||||
|
let nal = |t: u8, body: &[u8]| {
|
||||||
|
let mut v = vec![0x00, 0x00, 0x01, t];
|
||||||
|
v.extend_from_slice(body);
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let mut data = Vec::new();
|
||||||
|
data.extend_from_slice(&nal(0x68, &[0xCE, 0x01])); // PPS (8)
|
||||||
|
data.extend_from_slice(&nal(0x74, &[0x11, 0x22])); // slice extension (20)
|
||||||
|
crate::mux::ts::PesPacket {
|
||||||
|
source: None,
|
||||||
|
pid,
|
||||||
|
pts: Some(90_000),
|
||||||
|
dts: None,
|
||||||
|
data,
|
||||||
|
discontinuity: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a parser's output for a dependent-view AU still carries the PPS —
|
||||||
|
/// the observable signature of the MVC param-set-passthrough parser.
|
||||||
|
fn keeps_pps_inband(parser: &mut dyn super::super::codec::CodecParser, pid: u16) -> bool {
|
||||||
|
let frames = parser.parse(&mvc_dependent_au_pes(pid));
|
||||||
|
assert_eq!(frames.len(), 1, "one access unit in, one frame out");
|
||||||
|
// Frame payload is length-prefixed NALs (4-byte BE length + NAL).
|
||||||
|
let d = &frames[0].data;
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 4 <= d.len() {
|
||||||
|
let len = u32::from_be_bytes([d[i], d[i + 1], d[i + 2], d[i + 3]]) as usize;
|
||||||
|
i += 4;
|
||||||
|
if i + len > d.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if len > 0 && d[i] & 0x1F == 8 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
i += len;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The LIVE `disc://` path (`DiscStream::new`) must dispatch the Blu-ray 3D
|
||||||
|
/// MVC dependent view to the param-set-passthrough parser, exactly as the
|
||||||
|
/// file-backed ISO path (`resolve::build_demux_state`) does. Before this was
|
||||||
|
/// shared, the live path built every parser with plain `parser_for_codec`, so
|
||||||
|
/// the same 3D disc muxed correctly from an ISO and incorrectly from a drive.
|
||||||
|
#[test]
|
||||||
|
fn live_path_dispatches_mvc_dependent_view_to_passthrough_parser() {
|
||||||
|
let title = mvc_title();
|
||||||
|
let mut stream = DiscStream::new(
|
||||||
|
Box::new(ZeroReader { capacity: 8 }),
|
||||||
|
title.clone(),
|
||||||
|
crate::decrypt::DecryptKeys::None,
|
||||||
|
8,
|
||||||
|
ContentFormat::BdTs,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let idx_of = |parsers: &Vec<(u16, Box<dyn super::super::codec::CodecParser>)>, pid: u16| {
|
||||||
|
parsers.iter().position(|(p, _)| *p == pid).unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live path: dependent view keeps its PPS in-band, base view does not.
|
||||||
|
let dep = idx_of(&stream.parsers, 0x1012);
|
||||||
|
assert!(
|
||||||
|
keeps_pps_inband(stream.parsers[dep].1.as_mut(), 0x1012),
|
||||||
|
"live disc:// path must give the MVC dependent view the passthrough parser"
|
||||||
|
);
|
||||||
|
let base = idx_of(&stream.parsers, 0x1011);
|
||||||
|
assert!(
|
||||||
|
!keeps_pps_inband(stream.parsers[base].1.as_mut(), 0x1011),
|
||||||
|
"base view keeps the ordinary parser (discriminator is real, not vacuous)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it must agree with the ISO path, which is the canonical builder.
|
||||||
|
let (mut iso_parsers, _, _, _) =
|
||||||
|
crate::mux::resolve::build_demux_state(&title, ContentFormat::BdTs);
|
||||||
|
let iso_dep = idx_of(&iso_parsers, 0x1012);
|
||||||
|
assert!(
|
||||||
|
keeps_pps_inband(iso_parsers[iso_dep].1.as_mut(), 0x1012),
|
||||||
|
"ISO path reference behaviour"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `is_halted()` must observe a cancellation signal installed via
|
/// `is_halted()` must observe a cancellation signal installed via
|
||||||
/// `with_halt(Halt)` — flipping the token must cause the next
|
/// `with_halt(Halt)` — flipping the token must cause the next
|
||||||
/// `fill_extents` retry boundary to bail.
|
/// `fill_extents` retry boundary to bail.
|
||||||
|
|||||||
+54
-2
@@ -178,12 +178,26 @@ fn stream_json(s: &DiscStream) -> serde_json::Value {
|
|||||||
"pid": a.pid,
|
"pid": a.pid,
|
||||||
"language": a.language,
|
"language": a.language,
|
||||||
"channels": a.channels.to_string(),
|
"channels": a.channels.to_string(),
|
||||||
"channel_count": a.channels.count(),
|
|
||||||
"sample_rate": a.sample_rate.to_string(),
|
"sample_rate": a.sample_rate.to_string(),
|
||||||
"sample_rate_hz": a.sample_rate.hz(),
|
|
||||||
"secondary": a.secondary,
|
"secondary": a.secondary,
|
||||||
"purpose": purpose_id(a.purpose),
|
"purpose": purpose_id(a.purpose),
|
||||||
});
|
});
|
||||||
|
// `AudioChannels::count()` and `SampleRate::hz()` FABRICATE a concrete
|
||||||
|
// value for the `Unknown` variant (6 channels / 48000 Hz), so calling
|
||||||
|
// them unconditionally reported a confident 5.1 / 48 kHz for audio
|
||||||
|
// whose format is genuinely unknown — contradicting the neighbouring
|
||||||
|
// `channels` / `sample_rate` strings, which honestly say "unknown".
|
||||||
|
// Omit the numeric key entirely instead: the same guard `mkv.rs`
|
||||||
|
// applies before writing Channels / SamplingFrequency (there it emits
|
||||||
|
// 0 so the EBML serializer drops the element). Kept as a guard here
|
||||||
|
// rather than fixed in `count()`/`hz()` because those return
|
||||||
|
// non-optional scalars that other callers rely on.
|
||||||
|
if !matches!(a.channels, crate::disc::AudioChannels::Unknown) {
|
||||||
|
o["channel_count"] = json!(a.channels.count());
|
||||||
|
}
|
||||||
|
if !matches!(a.sample_rate, crate::disc::SampleRate::Unknown) {
|
||||||
|
o["sample_rate_hz"] = json!(a.sample_rate.hz());
|
||||||
|
}
|
||||||
if !a.label.is_empty() {
|
if !a.label.is_empty() {
|
||||||
o["label"] = json!(a.label);
|
o["label"] = json!(a.label);
|
||||||
}
|
}
|
||||||
@@ -346,6 +360,44 @@ mod tests {
|
|||||||
assert_eq!(v["chapters"][1]["name"], "2");
|
assert_eq!(v["chapters"][1]["name"], "2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An audio stream whose channel layout / sample rate are genuinely unknown
|
||||||
|
/// must not be reported with a fabricated 5.1 / 48 kHz. `AudioChannels::count()`
|
||||||
|
/// maps `Unknown` to 6 and `SampleRate::hz()` maps `Unknown` to 48000, so the
|
||||||
|
/// numeric fields must be omitted rather than computed — otherwise the JSON
|
||||||
|
/// contradicts its own `channels` / `sample_rate` strings ("unknown").
|
||||||
|
#[test]
|
||||||
|
fn unknown_audio_layout_omits_fabricated_numeric_fields() {
|
||||||
|
use crate::disc::{AudioChannels, AudioStream, Codec, DiscTitle};
|
||||||
|
use crate::disc::{LabelPurpose, SampleRate, Stream as DiscStream};
|
||||||
|
let mut t = DiscTitle::empty();
|
||||||
|
t.streams = vec![DiscStream::Audio(AudioStream {
|
||||||
|
pid: 0x1100,
|
||||||
|
codec: Codec::DtsHdMa,
|
||||||
|
channels: AudioChannels::Unknown,
|
||||||
|
language: "eng".into(),
|
||||||
|
sample_rate: SampleRate::Unknown,
|
||||||
|
secondary: false,
|
||||||
|
purpose: LabelPurpose::Normal,
|
||||||
|
label: String::new(),
|
||||||
|
})];
|
||||||
|
let v = title_json(&t);
|
||||||
|
let a = &v["streams"][0];
|
||||||
|
// The honest string fields.
|
||||||
|
assert_eq!(a["channels"], "unknown");
|
||||||
|
assert_eq!(a["sample_rate"], "unknown");
|
||||||
|
// The numeric fields must not assert a value the scan never resolved.
|
||||||
|
assert!(
|
||||||
|
a["channel_count"].is_null(),
|
||||||
|
"unknown channel layout must not report a channel_count, got {}",
|
||||||
|
a["channel_count"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
a["sample_rate_hz"].is_null(),
|
||||||
|
"unknown sample rate must not report a sample_rate_hz, got {}",
|
||||||
|
a["sample_rate_hz"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn video_json_carries_resolution_and_hdr() {
|
fn video_json_carries_resolution_and_hdr() {
|
||||||
use crate::disc::Codec;
|
use crate::disc::Codec;
|
||||||
|
|||||||
+2
-2
@@ -720,7 +720,7 @@ pub fn output(
|
|||||||
/// table (keyed by PID), the PID-to-track index map, and an initial
|
/// table (keyed by PID), the PID-to-track index map, and an initial
|
||||||
/// `TsDemuxer` / `PsDemuxer` (whichever the content format calls
|
/// `TsDemuxer` / `PsDemuxer` (whichever the content format calls
|
||||||
/// for).
|
/// for).
|
||||||
type DemuxState = (
|
pub(crate) type DemuxState = (
|
||||||
Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
Vec<(u16, Box<dyn super::codec::CodecParser>)>,
|
||||||
Vec<(u16, usize)>,
|
Vec<(u16, usize)>,
|
||||||
Option<super::ts::TsDemuxer>,
|
Option<super::ts::TsDemuxer>,
|
||||||
@@ -729,7 +729,7 @@ type DemuxState = (
|
|||||||
|
|
||||||
/// Build the title's codec parser table + initial `TsDemuxer` /
|
/// Build the title's codec parser table + initial `TsDemuxer` /
|
||||||
/// `PsDemuxer`. Used by both the ISO and M2TS pipeline builders.
|
/// `PsDemuxer`. Used by both the ISO and M2TS pipeline builders.
|
||||||
fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
pub(crate) fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
|
||||||
let mut pids = Vec::new();
|
let mut pids = Vec::new();
|
||||||
let mut parsers = Vec::new();
|
let mut parsers = Vec::new();
|
||||||
let mut pid_to_track = Vec::new();
|
let mut pid_to_track = Vec::new();
|
||||||
|
|||||||
+187
-22
@@ -487,14 +487,7 @@ impl TsDemuxer {
|
|||||||
// adaptation == 0x02 (AF only) already returned above, so only 0x03
|
// adaptation == 0x02 (AF only) already returned above, so only 0x03
|
||||||
// (AF + payload) can carry an adaptation field here.
|
// (AF + payload) can carry an adaptation field here.
|
||||||
let discontinuity_flag = adaptation == 0x03 && ts[4] > 0 && (ts[5] & 0x80) != 0;
|
let discontinuity_flag = adaptation == 0x03 && ts[4] > 0 && (ts[5] & 0x80) != 0;
|
||||||
// A gap is a CC that is neither the expected `(prev + 1) & 0xf` nor a
|
let cc_gap = cc_is_gap(asm.last_cc, cc);
|
||||||
// duplicate `prev` (ISO 13818-1 permits a packet to repeat its CC; a
|
|
||||||
// duplicate is not a loss). Anything else means one or more packets for
|
|
||||||
// this PID were dropped.
|
|
||||||
let cc_gap = match asm.last_cc {
|
|
||||||
Some(prev) => cc != ((prev + 1) & 0x0f) && cc != prev,
|
|
||||||
None => false,
|
|
||||||
};
|
|
||||||
asm.last_cc = Some(cc);
|
asm.last_cc = Some(cc);
|
||||||
// A continuity gap means packets for THIS PID were lost (a damaged source,
|
// A continuity gap means packets for THIS PID were lost (a damaged source,
|
||||||
// or — for the conceal path — a loss that the CC-independent NULL-TS marker
|
// or — for the conceal path — a loss that the CC-independent NULL-TS marker
|
||||||
@@ -657,6 +650,31 @@ fn parse_timestamp(data: &[u8]) -> Option<i64> {
|
|||||||
Some(((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1)
|
Some(((b0 >> 1) & 0x07) << 30 | b1 << 22 | (b2 >> 1) << 15 | b3 << 7 | b4 >> 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Canonical continuity-counter gap test (ISO/IEC 13818-1 §2.4.3.3).
|
||||||
|
///
|
||||||
|
/// The 4-bit `continuity_counter` increments by one for each TS packet of a PID
|
||||||
|
/// that CARRIES PAYLOAD — a packet with adaptation field only does not increment
|
||||||
|
/// it, so such packets must be excluded by the caller rather than diffed here.
|
||||||
|
/// A packet MAY legally repeat the previous counter (the spec's duplicate
|
||||||
|
/// packet, whose payload is identical); that is not a loss. Anything else means
|
||||||
|
/// one or more packets for the PID were dropped.
|
||||||
|
///
|
||||||
|
/// `last_cc` is `None` before the first payload packet of a PID, where there is
|
||||||
|
/// nothing to diff against and no gap can be asserted.
|
||||||
|
///
|
||||||
|
/// Single source of truth for BOTH users in this file: the PES assembler
|
||||||
|
/// (`process_packet`) and the PSI section reassembler (`collect_psi_section`).
|
||||||
|
/// `collect_psi_section` used to reimplement it as a strict `cc != expected`,
|
||||||
|
/// which rejected legal duplicates and legal AF-only packets — a spec-conformant
|
||||||
|
/// PMT continuation was then reported as desync and the title's stream list came
|
||||||
|
/// back empty.
|
||||||
|
fn cc_is_gap(last_cc: Option<u8>, cc: u8) -> bool {
|
||||||
|
match last_cc {
|
||||||
|
Some(prev) => cc != ((prev + 1) & 0x0f) && cc != prev,
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Stream scanning (PAT/PMT → stream list)
|
// Stream scanning (PAT/PMT → stream list)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -765,13 +783,18 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec
|
|||||||
section.truncate(total);
|
section.truncate(total);
|
||||||
return Some(section);
|
return Some(section);
|
||||||
}
|
}
|
||||||
// Need continuation packets: same PID, no PUSI, with a
|
// Need continuation packets: same PID, no PUSI. Continuity is
|
||||||
// monotonically incrementing continuity counter. The CC lives in
|
// checked with the CANONICAL `cc_is_gap` (ISO/IEC 13818-1 §2.4.3.3)
|
||||||
// the low nibble of the 4th TS-header byte (offset+7 here: the
|
// shared with `process_packet`, NOT a local `cc != expected` test:
|
||||||
// BD-TS 4-byte prefix precedes the sync byte). A CC gap means a
|
// that copy rejected a legal duplicate packet and counted
|
||||||
// dropped/duplicated packet → the assembled section is corrupt, so
|
// adaptation-field-only packets (which do not increment the CC)
|
||||||
// abandon it rather than splicing in misordered payload.
|
// as gaps, so a spec-conformant PMT was misdiagnosed as desync and
|
||||||
let mut expected_cc = ((data[offset + 7] & 0x0F) + 1) & 0x0F;
|
// the title's stream list came back empty. A real CC gap means a
|
||||||
|
// dropped/reordered packet → the assembled section is corrupt, so
|
||||||
|
// abandon it rather than splicing in misordered payload. The CC
|
||||||
|
// lives in the low nibble of the 4th TS-header byte (offset+7 here:
|
||||||
|
// the BD-TS 4-byte prefix precedes the sync byte).
|
||||||
|
let mut last_cc = Some(data[offset + 7] & 0x0F);
|
||||||
let mut scan = offset + BD_SOURCE_PACKET_BYTES;
|
let mut scan = offset + BD_SOURCE_PACKET_BYTES;
|
||||||
let mut desync = false;
|
let mut desync = false;
|
||||||
while scan + BD_SOURCE_PACKET_BYTES <= data.len() && section.len() < total {
|
while scan + BD_SOURCE_PACKET_BYTES <= data.len() && section.len() < total {
|
||||||
@@ -786,17 +809,27 @@ fn collect_psi_section(data: &[u8], target_pid: u16, table_id: u8) -> Option<Vec
|
|||||||
let cpid = (((data[scan + 5] & 0x1F) as u16) << 8) | data[scan + 6] as u16;
|
let cpid = (((data[scan + 5] & 0x1F) as u16) << 8) | data[scan + 6] as u16;
|
||||||
let cpusi = data[scan + 5] & 0x40 != 0;
|
let cpusi = data[scan + 5] & 0x40 != 0;
|
||||||
if cpid == target_pid && !cpusi {
|
if cpid == target_pid && !cpusi {
|
||||||
|
// Continuation packets may also carry an adaptation field;
|
||||||
|
// compute their payload base the same way. `None` = the
|
||||||
|
// packet carries NO payload (adaptation field only, or a
|
||||||
|
// malformed AF): §2.4.3.3 does not increment the CC for
|
||||||
|
// those, so they take no part in the continuity check.
|
||||||
|
let Some(cbase) = psi_payload_base(&data[scan..scan + BD_SOURCE_PACKET_BYTES])
|
||||||
|
else {
|
||||||
|
scan += BD_SOURCE_PACKET_BYTES;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let cc = data[scan + 7] & 0x0F;
|
let cc = data[scan + 7] & 0x0F;
|
||||||
if cc != expected_cc {
|
if cc_is_gap(last_cc, cc) {
|
||||||
desync = true;
|
desync = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
expected_cc = (cc + 1) & 0x0F;
|
// A repeated CC is the spec's duplicate packet: identical
|
||||||
// Continuation packets may also carry an adaptation
|
// payload, already collected. Skip it — appending it again
|
||||||
// field; compute their payload base the same way.
|
// would corrupt the section it is meant to protect.
|
||||||
if let Some(cbase) =
|
let duplicate = last_cc == Some(cc);
|
||||||
psi_payload_base(&data[scan..scan + BD_SOURCE_PACKET_BYTES])
|
last_cc = Some(cc);
|
||||||
{
|
if !duplicate {
|
||||||
section
|
section
|
||||||
.extend_from_slice(&data[scan + cbase..scan + BD_SOURCE_PACKET_BYTES]);
|
.extend_from_slice(&data[scan + cbase..scan + BD_SOURCE_PACKET_BYTES]);
|
||||||
}
|
}
|
||||||
@@ -1591,6 +1624,138 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Raw PMT PSI section (table_id .. CRC) for `entries`.
|
||||||
|
fn pmt_section(entries: &[(u8, u16)]) -> Vec<u8> {
|
||||||
|
let section_length = 9 + entries.len() * 5 + 4;
|
||||||
|
let mut section = Vec::new();
|
||||||
|
section.push(0x02); // table_id
|
||||||
|
section.push(0xB0 | (((section_length >> 8) as u8) & 0x0F));
|
||||||
|
section.push((section_length & 0xFF) as u8);
|
||||||
|
section.extend_from_slice(&[0x00, 0x01]); // program_number
|
||||||
|
section.push(0xC1); // version/current_next
|
||||||
|
section.push(0x00); // section_number
|
||||||
|
section.push(0x00); // last_section_number
|
||||||
|
section.extend_from_slice(&[0xE0, 0x00]); // PCR PID
|
||||||
|
section.extend_from_slice(&[0xF0, 0x00]); // program_info_length = 0
|
||||||
|
for &(stype, es_pid) in entries {
|
||||||
|
section.push(stype);
|
||||||
|
section.push(0xE0 | (((es_pid >> 8) as u8) & 0x1F));
|
||||||
|
section.push((es_pid & 0xFF) as u8);
|
||||||
|
section.extend_from_slice(&[0xF0, 0x00]); // ES_info_length = 0
|
||||||
|
}
|
||||||
|
section.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]); // CRC (unchecked)
|
||||||
|
section
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a PSI section into BD-TS packets on `pid`: a PUSI packet carrying
|
||||||
|
/// the pointer_field + head, then continuation packets, each with the
|
||||||
|
/// continuity counter incremented by one (every packet here carries payload).
|
||||||
|
fn psi_packets(pid: u16, section: &[u8]) -> Vec<Vec<u8>> {
|
||||||
|
let mut pkts = Vec::new();
|
||||||
|
let head_len = (184 - 1).min(section.len());
|
||||||
|
let mut p0 = [0xFFu8; 184];
|
||||||
|
p0[0] = 0x00; // pointer_field
|
||||||
|
p0[1..1 + head_len].copy_from_slice(§ion[..head_len]);
|
||||||
|
pkts.push(bdts_packet(p0, pid, true));
|
||||||
|
let mut pos = head_len;
|
||||||
|
let mut cc = 0u8;
|
||||||
|
while pos < section.len() {
|
||||||
|
let n = 184.min(section.len() - pos);
|
||||||
|
let mut p = [0xFFu8; 184];
|
||||||
|
p[..n].copy_from_slice(§ion[pos..pos + n]);
|
||||||
|
let mut pkt = bdts_packet(p, pid, false);
|
||||||
|
cc = (cc + 1) & 0x0F;
|
||||||
|
pkt[7] = (pkt[7] & 0xF0) | cc;
|
||||||
|
pkts.push(pkt);
|
||||||
|
pos += n;
|
||||||
|
}
|
||||||
|
pkts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An adaptation-field-ONLY BD-TS packet (AFC = 0b10, no payload) on `pid`.
|
||||||
|
/// ISO/IEC 13818-1 §2.4.3.3: such a packet does NOT increment the
|
||||||
|
/// continuity_counter, so it repeats the previous packet's value.
|
||||||
|
fn af_only_packet(pid: u16, cc: u8) -> Vec<u8> {
|
||||||
|
let mut pkt = vec![0xFFu8; BD_SOURCE_PACKET_BYTES];
|
||||||
|
pkt[..4].fill(0); // TP_extra_header
|
||||||
|
pkt[4] = SYNC_BYTE;
|
||||||
|
pkt[5] = ((pid >> 8) as u8) & 0x1F; // no PUSI
|
||||||
|
pkt[6] = (pid & 0xFF) as u8;
|
||||||
|
pkt[7] = 0x20 | (cc & 0x0F); // AFC = 0b10 (adaptation field only)
|
||||||
|
pkt[8] = 183; // adaptation_field_length fills the packet
|
||||||
|
pkt[9] = 0x00; // AF flags (no discontinuity_indicator)
|
||||||
|
pkt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A spec-legal adaptation-field-only packet interleaved between PMT
|
||||||
|
/// continuations must not be mistaken for a continuity desync: per ISO/IEC
|
||||||
|
/// 13818-1 §2.4.3.3 a packet with no payload does not increment the
|
||||||
|
/// continuity_counter, so it repeats the previous value. Misdiagnosing it
|
||||||
|
/// abandoned the PMT and returned an EMPTY stream list for a perfectly
|
||||||
|
/// valid title.
|
||||||
|
#[test]
|
||||||
|
fn scan_streams_tolerates_af_only_packet_between_pmt_continuations() {
|
||||||
|
let pmt_pid = 0x0100;
|
||||||
|
let mut entries: Vec<(u8, u16)> = vec![(0x1B, 0x1011)];
|
||||||
|
for i in 0..40u16 {
|
||||||
|
entries.push((0x80, 0x1100 + i));
|
||||||
|
}
|
||||||
|
let pkts = psi_packets(pmt_pid, &pmt_section(&entries));
|
||||||
|
assert!(pkts.len() >= 2, "section must span a continuation");
|
||||||
|
|
||||||
|
let mut data = pat_packet(pmt_pid);
|
||||||
|
data.extend(pkts[0].clone());
|
||||||
|
// AF-only packet after the PUSI packet (CC = 0, unchanged).
|
||||||
|
data.extend(af_only_packet(pmt_pid, 0));
|
||||||
|
for p in &pkts[1..] {
|
||||||
|
data.extend(p.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let streams = scan_streams(&data)
|
||||||
|
.expect("an adaptation-field-only packet must not abort PMT assembly");
|
||||||
|
assert_eq!(streams.len(), entries.len(), "every PMT entry reassembled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A duplicate TS packet (same continuity_counter, identical payload) is
|
||||||
|
/// explicitly legal (ISO/IEC 13818-1 §2.4.3.3) — `process_packet` already
|
||||||
|
/// tolerates it. The PSI reassembler must too: treat it as a duplicate
|
||||||
|
/// (payload NOT appended a second time), not as a desync.
|
||||||
|
#[test]
|
||||||
|
fn scan_streams_tolerates_duplicate_pmt_continuation_packet() {
|
||||||
|
use crate::disc::{Codec, Stream};
|
||||||
|
let pmt_pid = 0x0100;
|
||||||
|
// Enough entries that the section spans three packets, so the duplicate
|
||||||
|
// lands mid-assembly rather than after the section has completed.
|
||||||
|
let mut entries: Vec<(u8, u16)> = vec![(0x1B, 0x1011)];
|
||||||
|
for i in 0..90u16 {
|
||||||
|
entries.push((0x80, 0x1100 + i));
|
||||||
|
}
|
||||||
|
let pkts = psi_packets(pmt_pid, &pmt_section(&entries));
|
||||||
|
assert!(
|
||||||
|
pkts.len() >= 3,
|
||||||
|
"section must span at least two continuations, got {}",
|
||||||
|
pkts.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut data = pat_packet(pmt_pid);
|
||||||
|
data.extend(pkts[0].clone());
|
||||||
|
data.extend(pkts[1].clone());
|
||||||
|
data.extend(pkts[1].clone()); // legal duplicate of the first continuation
|
||||||
|
for p in &pkts[2..] {
|
||||||
|
data.extend(p.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let streams =
|
||||||
|
scan_streams(&data).expect("a duplicate PSI packet must not abort PMT assembly");
|
||||||
|
assert_eq!(streams.len(), entries.len(), "every PMT entry reassembled");
|
||||||
|
assert!(
|
||||||
|
streams.iter().any(
|
||||||
|
|s| matches!(s, Stream::Audio(a) if a.pid == 0x1100 + 89 && a.codec == Codec::Lpcm)
|
||||||
|
),
|
||||||
|
"the trailing entry survives (duplicate payload was not spliced in twice)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression for the PSI continuity-counter guard: a continuation packet
|
/// Regression for the PSI continuity-counter guard: a continuation packet
|
||||||
/// whose CC does NOT increment from the PUSI packet is a desync (dropped or
|
/// whose CC does NOT increment from the PUSI packet is a desync (dropped or
|
||||||
/// reordered packet). `collect_psi_section` must abandon that assembly
|
/// reordered packet). `collect_psi_section` must abandon that assembly
|
||||||
|
|||||||
Reference in New Issue
Block a user