DVD vob_start absolute rebase + rc.5.3 audit fixes
- ifo.rs: rebase VTS title VOBS to absolute disc LBA (file_start_lba + vtstt_vobs); fixes DVD rips opening on the menu region instead of the movie (e.g. SOTL). Adds absolute-placement regression test. - aacs/boil.rs: add mk_from_pk primitive (PK -> MK via MKB walk). - dvdnav/: nav-VM command decoder + start-cell resolver seam, parked behind USE_NAV_RESOLVER (kept compiled, never executed). - mux: FVI src.byte within-sector per spec; Unknown colour -> CICP unspecified (2,2,2,1); demux clear PCS -> NORMAL; ts.rs feed() base reset + boundary provenance fix. - Assorted audit fixes (doc/comment/test accuracy) across the crate.
This commit is contained in:
+11
-4
@@ -181,8 +181,13 @@ impl PictureInfo {
|
||||
match self.detail {
|
||||
CodingDetail::Mpeg2(m) => {
|
||||
if !m.frame_picture {
|
||||
// A single field picture is inherently interlaced; the
|
||||
// top_field_first bit names which field this picture is.
|
||||
// A single field picture is inherently interlaced. Which
|
||||
// field it actually codes is given by picture_structure
|
||||
// (top/bottom), not by top_field_first — §6.3.10 constrains
|
||||
// top_field_first to 0 for field pictures, so it is not the
|
||||
// spec source here. picture_structure is not retained on
|
||||
// this carrier, so top_field_first is used only as the lone
|
||||
// field hint available (best-effort, not spec-derived).
|
||||
Some(if m.top_field_first {
|
||||
FieldOrder::Tff
|
||||
} else {
|
||||
@@ -203,8 +208,10 @@ impl PictureInfo {
|
||||
/// Number of field-display periods this picture occupies — the basis for
|
||||
/// soft-telecine (2:3 pulldown) timing. MPEG-2 (ISO/IEC 13818-2 §6.3.10,
|
||||
/// ffmpeg `nb_fields = repeat_pict + 2`): a field picture occupies 1 field,
|
||||
/// a normal frame 2, a `repeat_first_field` frame 3 (or 4/6 in a progressive
|
||||
/// sequence). Codecs without pulldown signalling report the normal 2 fields.
|
||||
/// a normal frame 2, a `repeat_first_field` progressive-frame 3 (or 4/6 in a
|
||||
/// progressive sequence); an rff bit on a non-progressive interlaced frame is
|
||||
/// spec-forbidden (§6.3.10) and is treated as 2. Codecs without pulldown
|
||||
/// signalling report the normal 2 fields.
|
||||
pub fn nb_fields(&self) -> u8 {
|
||||
match self.detail {
|
||||
CodingDetail::Mpeg2(m) => {
|
||||
|
||||
@@ -325,7 +325,8 @@ impl CodecParser for H264Parser {
|
||||
}
|
||||
|
||||
/// Parse `(chroma_format_idc, bit_depth_luma_minus8, bit_depth_chroma_minus8)` from
|
||||
/// a High-Profile SPS NAL (profile_idc ∈ {100, 110, 122, 144}).
|
||||
/// a High-Profile SPS NAL (profile_idc ∈ `HIGH_PROFILES` — the 14 chroma/bit-depth
|
||||
/// extended profiles `codec_private` invokes this for).
|
||||
///
|
||||
/// SPS RBSP layout (ITU-T H.264 §7.3.2.1.1) up to the fields we need:
|
||||
/// byte 0 NAL header (already known to be type 7)
|
||||
|
||||
@@ -56,10 +56,7 @@ fn hevc_num_extra_slice_header_bits(pps_nal: &[u8]) -> Option<u32> {
|
||||
br.read_ue()?; // pps_pic_parameter_set_id
|
||||
br.read_ue()?; // pps_seq_parameter_set_id
|
||||
br.skip_bits(2)?; // dependent_slice_segments_enabled_flag, output_flag_present_flag
|
||||
let mut n = 0u32;
|
||||
for _ in 0..3 {
|
||||
n = (n << 1) | br.read_bit()?;
|
||||
}
|
||||
let n = br.read_bits(3)?;
|
||||
Some(n)
|
||||
}
|
||||
|
||||
|
||||
@@ -273,8 +273,8 @@ const SUP_HEADER_LEN: usize = SUP_MAGIC.len() + 4 + 4;
|
||||
const SEG_PCS: u8 = 0x16;
|
||||
/// PGS segment type: END of display set.
|
||||
const SEG_END: u8 = 0x80;
|
||||
/// PCS `composition_state` value: Epoch Start (a fresh display).
|
||||
const PCS_COMPOSITION_STATE_EPOCH_START: u8 = 0x80;
|
||||
/// PCS `composition_state` value: Normal (an update to the current epoch).
|
||||
const PCS_COMPOSITION_STATE_NORMAL: u8 = 0x00;
|
||||
/// PGS segment header on the wire (inside `frame.data`): type(1) + size(2 BE).
|
||||
const PGS_SEG_HEADER_LEN: usize = 3;
|
||||
/// Byte offset of `width`/`height` within a PCS segment (after type+size).
|
||||
@@ -346,7 +346,7 @@ impl PgsSupWriter {
|
||||
PCS_FRAME_RATE,
|
||||
0x00,
|
||||
0x00, // composition_number
|
||||
PCS_COMPOSITION_STATE_EPOCH_START,
|
||||
PCS_COMPOSITION_STATE_NORMAL,
|
||||
0x00, // palette_update_flag
|
||||
0x00, // palette_id
|
||||
PCS_NO_OBJECTS,
|
||||
|
||||
+13
-3
@@ -82,8 +82,16 @@ fn write_fvi_record(w: &mut dyn Write, r: &PictureRecord) -> io::Result<()> {
|
||||
// `src` is REQUIRED by the record schema (Appendix A); when provenance is
|
||||
// absent the member is still emitted as null — a reader treats null as
|
||||
// "position unknown".
|
||||
//
|
||||
// Per `docs/FVI_FORMAT.md` §9, `src.byte` is the offset of the AU's first
|
||||
// byte WITHIN its `sector` (not the absolute source offset). `SourcePos.byte`
|
||||
// is the absolute offset, so reduce it modulo the sector size; `sector`
|
||||
// already carries the whole-sector count.
|
||||
let src = match r.source {
|
||||
Some(s) => serde_json::json!({ "sector": s.sector, "byte": s.byte }),
|
||||
Some(s) => serde_json::json!({
|
||||
"sector": s.sector,
|
||||
"byte": s.byte % u64::from(FVI_SECTOR_SIZE),
|
||||
}),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
|
||||
@@ -355,8 +363,9 @@ mod tests {
|
||||
let dir = tempdir();
|
||||
let path = dir.join("movie.fvi");
|
||||
let mut sink = FviSink::create(&path, &mpeg2_title(), "iso://m.iso".into(), 1).unwrap();
|
||||
// Video frame on track 0 → indexed.
|
||||
sink.write(&vframe(0, Some(i_pic()), Some(SourcePos::at_byte(2048))))
|
||||
// Video frame on track 0 → indexed. Offset 2148 = sector 1, byte 100
|
||||
// within that sector (exercises the within-sector `src.byte`, §9).
|
||||
sink.write(&vframe(0, Some(i_pic()), Some(SourcePos::at_byte(2148))))
|
||||
.unwrap();
|
||||
// Audio frame on a non-video track → ignored.
|
||||
sink.write(&vframe(7, None, Some(SourcePos::at_byte(9999))))
|
||||
@@ -387,6 +396,7 @@ mod tests {
|
||||
assert_eq!(rec["nb_fields"], 2);
|
||||
assert_eq!(rec["pts"], 0);
|
||||
assert_eq!(rec["src"]["sector"], 1);
|
||||
assert_eq!(rec["src"]["byte"], 100); // 2148 % 2048 → within-sector (§9)
|
||||
assert!(rec.get("dts").is_none(), "no DTS on a frame → omitted");
|
||||
assert!(
|
||||
rec.get("gop").is_none(),
|
||||
|
||||
@@ -254,7 +254,10 @@ impl<W: Write> M2tsMux<W> {
|
||||
// step — e.g. a leading audio frame ahead of the first video keyframe),
|
||||
// which still floors to 0 per the documented behavior.
|
||||
let delta = raw_90k.wrapping_sub(base) & 0x1_FFFF_FFFF;
|
||||
if delta > (1 << 32) { 0 } else { delta }
|
||||
// Signed 33-bit: the sign bit is bit 32 (value 2^32), so the entire
|
||||
// upper half [2^32, 2^33) is negative (frame before base) and floors
|
||||
// to 0. delta == 2^32 is the most-negative value (-2^32), hence `>=`.
|
||||
if delta >= (1 << 32) { 0 } else { delta }
|
||||
}
|
||||
|
||||
/// Emit one PES payload as a chain of TS packets on `pid`. If `pcr`
|
||||
|
||||
+53
-1
@@ -28,6 +28,9 @@ const CICP_PRIMARIES_BT470BG: u8 = 5;
|
||||
const CICP_PRIMARIES_BT601_525: u8 = 6;
|
||||
/// ColourPrimaries = 9 (BT.2020 / BT.2100) — ITU-T H.273 Table 2.
|
||||
const CICP_PRIMARIES_BT2020: u8 = 9;
|
||||
/// ColourPrimaries = 2 ("unspecified" — colorimetry unknown) — ITU-T H.273
|
||||
/// Table 2.
|
||||
const CICP_PRIMARIES_UNSPECIFIED: u8 = 2;
|
||||
|
||||
/// TransferCharacteristics = 1 (BT.709) — ITU-T H.273 Table 3.
|
||||
const CICP_TRANSFER_BT709: u8 = 1;
|
||||
@@ -41,6 +44,9 @@ const CICP_TRANSFER_PQ: u8 = 16;
|
||||
/// TransferCharacteristics = 18 (ARIB STD-B67 / Hybrid Log-Gamma) — ITU-T H.273
|
||||
/// Table 3.
|
||||
const CICP_TRANSFER_HLG: u8 = 18;
|
||||
/// TransferCharacteristics = 2 ("unspecified" — transfer unknown) — ITU-T H.273
|
||||
/// Table 3.
|
||||
const CICP_TRANSFER_UNSPECIFIED: u8 = 2;
|
||||
|
||||
/// MatrixCoefficients = 1 (BT.709) — ITU-T H.273 Table 4.
|
||||
const CICP_MATRIX_BT709: u8 = 1;
|
||||
@@ -50,6 +56,8 @@ const CICP_MATRIX_BT470BG: u8 = 5;
|
||||
const CICP_MATRIX_BT601_525: u8 = 6;
|
||||
/// MatrixCoefficients = 9 (BT.2020 non-constant luminance) — ITU-T H.273 Table 4.
|
||||
const CICP_MATRIX_BT2020NC: u8 = 9;
|
||||
/// MatrixCoefficients = 2 ("unspecified" — matrix unknown) — ITU-T H.273 Table 4.
|
||||
const CICP_MATRIX_UNSPECIFIED: u8 = 2;
|
||||
|
||||
/// Matroska Colour/Range = 1 (broadcast / studio-swing "limited" range). RFC
|
||||
/// 9559 Range element. (0 = unspecified, 2 = full.)
|
||||
@@ -104,7 +112,16 @@ pub(crate) fn cicp_for_video(v: &VideoStream) -> (u8, u8, u8, u8) {
|
||||
CICP_PRIMARIES_BT601_525,
|
||||
COLOUR_RANGE_LIMITED,
|
||||
),
|
||||
ColorSpace::Unknown => (0, 0, 0, 0),
|
||||
// Unknown colorimetry → CICP "unspecified" (code point 2) for matrix,
|
||||
// transfer, and primaries, with limited range (the disc norm). Both the
|
||||
// MKV sink and the FVI sidecar emit 2 so the two sinks of one title
|
||||
// agree (matches `Colour::from_color_space`'s Unknown mapping).
|
||||
ColorSpace::Unknown => (
|
||||
CICP_MATRIX_UNSPECIFIED,
|
||||
CICP_TRANSFER_UNSPECIFIED,
|
||||
CICP_PRIMARIES_UNSPECIFIED,
|
||||
COLOUR_RANGE_LIMITED,
|
||||
),
|
||||
};
|
||||
// Override the transfer for HDR signalled by the HdrFormat (the coarse enum
|
||||
// can't express PQ/HLG). Only applies on the enum fallback; a measured CICP
|
||||
@@ -1549,6 +1566,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Unknown colorimetry with no measured CICP and no HDR must emit CICP
|
||||
/// "unspecified" (code point 2) for matrix/transfer/primaries — never 0 — so
|
||||
/// the MKV sink agrees with the FVI sidecar (`Colour::from_color_space`).
|
||||
#[test]
|
||||
fn unknown_color_space_emits_unspecified_cicp() {
|
||||
let v = VideoStream {
|
||||
pid: 0xE0,
|
||||
codec: Codec::Hevc,
|
||||
resolution: Resolution::R1080p,
|
||||
frame_rate: crate::disc::FrameRate::F24,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Unknown,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
measured_cicp: None,
|
||||
};
|
||||
let t = MkvTrack::video(&v);
|
||||
assert_eq!(
|
||||
(
|
||||
t.colour_matrix,
|
||||
t.colour_transfer,
|
||||
t.colour_primaries,
|
||||
t.colour_range
|
||||
),
|
||||
(
|
||||
CICP_MATRIX_UNSPECIFIED,
|
||||
CICP_TRANSFER_UNSPECIFIED,
|
||||
CICP_PRIMARIES_UNSPECIFIED,
|
||||
COLOUR_RANGE_LIMITED
|
||||
),
|
||||
"Unknown colorimetry must emit CICP 'unspecified' (2), not 0"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: search for a 4-byte big-endian EBML ID in a byte slice.
|
||||
fn find_id(data: &[u8], id: u32) -> Option<usize> {
|
||||
let bytes = id.to_be_bytes();
|
||||
|
||||
+68
-24
@@ -101,8 +101,12 @@ enum WriteMode {
|
||||
/// Header written; muxing live. Boxed (MkvMuxer is large) to keep the enum
|
||||
/// small (clippy::large_enum_variant).
|
||||
Active(Box<MkvMuxer<Box<dyn WriteSeek + Send>>>),
|
||||
/// Transient placeholder held only across the Pending → Active swap; never
|
||||
/// observed by `read` / `write` / `finish`.
|
||||
/// Sentinel held in `self.mode` while the muxer is being built (across the
|
||||
/// Pending → Active swap). It is also the terminal state left behind after
|
||||
/// `finish()` swaps the muxer out, and the degraded state left behind if
|
||||
/// `activate()` fails partway (the first error still surfaces via `?`). In
|
||||
/// that terminal state a subsequent `write()` no-ops (`Ok(())`) and `finish()`
|
||||
/// does not re-finalize.
|
||||
Building,
|
||||
}
|
||||
|
||||
@@ -188,7 +192,11 @@ impl MkvStream {
|
||||
/// available), then write the header and replay buffered frames. A no-op if
|
||||
/// not pending. The muxer only ever muxes the track it is given — this routes
|
||||
/// the parser's measured value onto that track first.
|
||||
fn activate(&mut self, coding: Option<crate::mux::codec::PictureInfo>) -> io::Result<()> {
|
||||
fn activate(
|
||||
&mut self,
|
||||
coding: Option<crate::mux::codec::PictureInfo>,
|
||||
video_picture_seen: bool,
|
||||
) -> io::Result<()> {
|
||||
let mut pending = match std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building))
|
||||
{
|
||||
Mode::Write(WriteMode::Pending(p)) => p,
|
||||
@@ -199,7 +207,7 @@ impl MkvStream {
|
||||
}
|
||||
};
|
||||
if let Some(vt) = pending.video_track {
|
||||
apply_coding_to_track(&mut pending.tracks[vt], coding);
|
||||
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
|
||||
}
|
||||
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
|
||||
for (i, track) in pending.tracks.iter().enumerate() {
|
||||
@@ -240,12 +248,20 @@ impl MkvStream {
|
||||
/// Set a video track's `FieldOrder` from the MEASURED coding of the first coded
|
||||
/// picture — the parser's value, the first time, never a guess.
|
||||
///
|
||||
/// A progressive track has no field order (left UNDETERMINED — expected). An
|
||||
/// INTERLACED track that reaches here with no measured field order is a
|
||||
/// A progressive track — or a progressive picture on an interlaced-flagged track
|
||||
/// — has no field order (left UNDETERMINED — expected). An INTERLACED track that
|
||||
/// reaches here WITH a video picture but no measured field order is a
|
||||
/// parser/source gap (MPEG-2 carries `top_field_first` on every interlaced
|
||||
/// picture, so it should never be missing): LOG it loudly so the source can be
|
||||
/// debugged, and leave UNDETERMINED — a muxer never fabricates a source fact.
|
||||
fn apply_coding_to_track(track: &mut MkvTrack, coding: Option<crate::mux::codec::PictureInfo>) {
|
||||
/// `video_picture_seen == false` (an empty title finalized with no frames, or a
|
||||
/// cap-triggered build that never saw the video frame) is NOT a defect — the
|
||||
/// missing coding is expected there, so log it quietly.
|
||||
fn apply_coding_to_track(
|
||||
track: &mut MkvTrack,
|
||||
coding: Option<crate::mux::codec::PictureInfo>,
|
||||
video_picture_seen: bool,
|
||||
) {
|
||||
// HDR10 static metadata measured from the bitstream (HEVC SEI). Applied for
|
||||
// ANY track type that carries it (independent of interlace): the first coded
|
||||
// picture's PictureInfo holds it once both HDR10 SEI messages were seen.
|
||||
@@ -260,17 +276,32 @@ fn apply_coding_to_track(track: &mut MkvTrack, coding: Option<crate::mux::codec:
|
||||
match coding.and_then(|c| c.field_order()) {
|
||||
Some(FieldOrder::Tff) => track.field_order = ebml::FIELD_ORDER_TFF,
|
||||
Some(FieldOrder::Bff) => track.field_order = ebml::FIELD_ORDER_BFF,
|
||||
other => {
|
||||
// A progressive picture on an interlaced-flagged track carries no field
|
||||
// order. Leave UNDETERMINED (not a guess) — there is no parser gap here.
|
||||
Some(FieldOrder::Progressive) => {
|
||||
track.field_order = ebml::FIELD_ORDER_UNDETERMINED;
|
||||
}
|
||||
None if video_picture_seen => {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"interlaced video track reached the muxer with NO measured field order \
|
||||
(field_order={:?}, coding_present={}); writing FieldOrder=UNDETERMINED \
|
||||
— NOT a guess. Debug why the source/parser did not set top_field_first.",
|
||||
other,
|
||||
"interlaced video track had a video picture but NO usable field order \
|
||||
(coding_present={}); writing FieldOrder=UNDETERMINED — NOT a guess. \
|
||||
Debug why the source/parser did not set top_field_first.",
|
||||
coding.is_some(),
|
||||
);
|
||||
track.field_order = ebml::FIELD_ORDER_UNDETERMINED;
|
||||
}
|
||||
None => {
|
||||
// No video picture was ever measured (empty title finalized with no
|
||||
// frames, or a cap-triggered build before the first video frame).
|
||||
// Coding is legitimately absent, not a parser defect — log quietly.
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"interlaced video track activated with no video picture \
|
||||
(empty/buffered-only title); writing FieldOrder=UNDETERMINED.",
|
||||
);
|
||||
track.field_order = ebml::FIELD_ORDER_UNDETERMINED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +458,7 @@ impl crate::pes::Stream for MkvStream {
|
||||
// Pass the trigger frame's coding only when it IS the video frame; a
|
||||
// cap-triggered build never saw the video frame, so nothing measured
|
||||
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
|
||||
self.activate(if use_coding { frame.coding } else { None })?;
|
||||
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
|
||||
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
|
||||
return m.write_frame(
|
||||
frame.track,
|
||||
@@ -450,7 +481,10 @@ impl crate::pes::Stream for MkvStream {
|
||||
// A title that produced no frames (or only buffered ones) is still
|
||||
// finalized into a valid MKV: activate now with no measured coding.
|
||||
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
|
||||
self.activate(None)?;
|
||||
// No video picture was ever measured for this title (it produced no
|
||||
// frames, or only buffered non-video ones): coding is legitimately
|
||||
// absent, not a parser defect — `video_picture_seen=false`.
|
||||
self.activate(None, false)?;
|
||||
}
|
||||
if let Mode::Write(WriteMode::Active(m)) =
|
||||
std::mem::replace(&mut self.mode, Mode::Write(WriteMode::Building))
|
||||
@@ -923,7 +957,7 @@ mod tests {
|
||||
|
||||
// MEASURED bottom-field-first → BFF (6). The red-flag fix.
|
||||
let mut t = interlaced_track();
|
||||
apply_coding_to_track(&mut t, Some(pic(false, false)));
|
||||
apply_coding_to_track(&mut t, Some(pic(false, false)), true);
|
||||
assert_eq!(
|
||||
t.field_order,
|
||||
ebml::FIELD_ORDER_BFF,
|
||||
@@ -932,27 +966,37 @@ mod tests {
|
||||
|
||||
// MEASURED top-field-first → TFF (1).
|
||||
let mut t = interlaced_track();
|
||||
apply_coding_to_track(&mut t, Some(pic(true, false)));
|
||||
apply_coding_to_track(&mut t, Some(pic(true, false)), true);
|
||||
assert_eq!(
|
||||
t.field_order,
|
||||
ebml::FIELD_ORDER_TFF,
|
||||
"measured TFF → FieldOrder=1"
|
||||
);
|
||||
|
||||
// Interlaced track, NO measured coding → UNDETERMINED (logged loudly,
|
||||
// never faked).
|
||||
// Interlaced track, a video picture but NO usable field order →
|
||||
// UNDETERMINED (logged loudly, never faked).
|
||||
let mut t = interlaced_track();
|
||||
apply_coding_to_track(&mut t, None);
|
||||
apply_coding_to_track(&mut t, None, true);
|
||||
assert_eq!(
|
||||
t.field_order,
|
||||
ebml::FIELD_ORDER_UNDETERMINED,
|
||||
"no measured value → UNDETERMINED, never a guess"
|
||||
);
|
||||
|
||||
// Interlaced track activated with NO video picture (empty/buffered-only
|
||||
// title) → UNDETERMINED, logged quietly (not a parser defect).
|
||||
let mut t = interlaced_track();
|
||||
apply_coding_to_track(&mut t, None, false);
|
||||
assert_eq!(
|
||||
t.field_order,
|
||||
ebml::FIELD_ORDER_UNDETERMINED,
|
||||
"empty title → UNDETERMINED, never a guess"
|
||||
);
|
||||
|
||||
// Progressive picture on an interlaced-flagged track → UNDETERMINED (no
|
||||
// field order applies; not faked to TFF/BFF).
|
||||
let mut t = interlaced_track();
|
||||
apply_coding_to_track(&mut t, Some(pic(true, true)));
|
||||
apply_coding_to_track(&mut t, Some(pic(true, true)), true);
|
||||
assert_eq!(t.field_order, ebml::FIELD_ORDER_UNDETERMINED);
|
||||
|
||||
// A PROGRESSIVE track is never touched — field order stays UNDETERMINED.
|
||||
@@ -969,7 +1013,7 @@ mod tests {
|
||||
measured_cicp: None,
|
||||
});
|
||||
assert!(!prog.interlaced);
|
||||
apply_coding_to_track(&mut prog, Some(pic(false, false)));
|
||||
apply_coding_to_track(&mut prog, Some(pic(false, false)), true);
|
||||
assert_eq!(prog.field_order, ebml::FIELD_ORDER_UNDETERMINED);
|
||||
}
|
||||
|
||||
@@ -1011,18 +1055,18 @@ mod tests {
|
||||
let mut t = make();
|
||||
assert!(t.hdr10.is_none(), "fresh track has no HDR10");
|
||||
let pic = PictureInfo::coding_type_only(CodingType::I).with_hdr10(Some(h));
|
||||
apply_coding_to_track(&mut t, Some(pic));
|
||||
apply_coding_to_track(&mut t, Some(pic), true);
|
||||
assert_eq!(t.hdr10, Some(h), "measured HDR10 must reach the track");
|
||||
|
||||
// Picture without HDR10 → track stays None (never fabricated).
|
||||
let mut t = make();
|
||||
let pic = PictureInfo::coding_type_only(CodingType::I);
|
||||
apply_coding_to_track(&mut t, Some(pic));
|
||||
apply_coding_to_track(&mut t, Some(pic), true);
|
||||
assert!(t.hdr10.is_none(), "no measured HDR10 → track stays None");
|
||||
|
||||
// No coding at all → None.
|
||||
let mut t = make();
|
||||
apply_coding_to_track(&mut t, None);
|
||||
apply_coding_to_track(&mut t, None, true);
|
||||
assert!(t.hdr10.is_none());
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! | network:// | Yes (listen) | Yes (connect) | host:port (required) |
|
||||
//! | stdio:// | Yes (stdin) | Yes (stdout) | empty |
|
||||
//! | null:// | -- | Yes | empty |
|
||||
//! | demux:// | -- | Yes | directory path (required) — per-track ES demux |
|
||||
//! | fvi:// | -- | Yes | file path (required) — per-picture video index |
|
||||
//!
|
||||
//! Bare paths without a scheme are rejected.
|
||||
@@ -920,6 +921,30 @@ mod tests {
|
||||
!parse_url("dir://x").is_disc_source(),
|
||||
"dir:// is a sink, never a disc source"
|
||||
);
|
||||
// fvi:// parses to Fvi with the raw remainder as the path, and is a
|
||||
// sink (never a disc source) — parallel to the demux:// coverage above.
|
||||
match parse_url("fvi://out/movie.fvi") {
|
||||
StreamUrl::Fvi { path } => {
|
||||
assert_eq!(path, PathBuf::from("out/movie.fvi"));
|
||||
}
|
||||
other => panic!("fvi:// must parse to Fvi, got {other:?}"),
|
||||
}
|
||||
assert_eq!(parse_url("fvi://x").scheme(), "fvi");
|
||||
assert_eq!(parse_url("fvi://x/y.fvi").path_str(), "x/y.fvi");
|
||||
assert!(
|
||||
!parse_url("fvi://x").is_disc_source(),
|
||||
"fvi:// is a sink, never a disc source"
|
||||
);
|
||||
}
|
||||
|
||||
/// `fvi://` is output-only: `input()` rejects it with StreamWriteOnly
|
||||
/// (E9001 → Unsupported), mirroring `null://` / `demux://`.
|
||||
#[test]
|
||||
fn input_fvi_url_is_write_only() {
|
||||
assert_eq!(
|
||||
input_err_kind("fvi://out/movie.fvi"),
|
||||
std::io::ErrorKind::Unsupported
|
||||
);
|
||||
}
|
||||
|
||||
/// `dir://` is output-only: `input()` rejects it (StreamWriteOnly →
|
||||
|
||||
+88
-7
@@ -229,6 +229,12 @@ impl TsDemuxer {
|
||||
/// `data` in place. Zero-copy on the bulk path; one 192-byte copy
|
||||
/// on the boundary.
|
||||
pub fn feed(&mut self, data: &[u8]) -> Vec<PesPacket> {
|
||||
// A plain feed carries no provenance. Reset any base a prior
|
||||
// `feed_at` left behind so mixing the two entry points is safe:
|
||||
// after this call no `SourcePos` is stamped, and the stale running
|
||||
// base can't leak a wrong offset into the boundary packet.
|
||||
self.feed_base = 0;
|
||||
self.has_base = false;
|
||||
self.feed_inner(data)
|
||||
}
|
||||
|
||||
@@ -263,15 +269,20 @@ impl TsDemuxer {
|
||||
self.remainder.extend_from_slice(data);
|
||||
return completed;
|
||||
}
|
||||
// Capture the remainder length before clearing — it's how many of
|
||||
// the boundary packet's bytes lived in the PREVIOUS feed buffer,
|
||||
// and `feed_base` currently points at the FIRST byte of THIS buffer.
|
||||
let rem_len = self.remainder.len();
|
||||
let mut boundary = [0u8; BD_SOURCE_PACKET_BYTES];
|
||||
boundary[..self.remainder.len()].copy_from_slice(&self.remainder);
|
||||
boundary[self.remainder.len()..].copy_from_slice(&data[..need]);
|
||||
boundary[..rem_len].copy_from_slice(&self.remainder);
|
||||
boundary[rem_len..].copy_from_slice(&data[..need]);
|
||||
self.remainder.clear();
|
||||
// The boundary packet began in the PREVIOUS feed buffer; stamp it
|
||||
// with the offset just before this buffer (its first bytes' base).
|
||||
let src = self
|
||||
.has_base
|
||||
.then(|| crate::pes::SourcePos::at_byte(self.feed_base.saturating_sub(1)));
|
||||
// The boundary packet's first byte sat `rem_len` bytes before the
|
||||
// current feed_base (in the previous buffer). Stamp it there — not
|
||||
// at `feed_base - 1`, which would be wrong by `rem_len - 1` bytes.
|
||||
let src = self.has_base.then(|| {
|
||||
crate::pes::SourcePos::at_byte(self.feed_base.saturating_sub(rem_len as u64))
|
||||
});
|
||||
self.process_packet(&boundary, src, &mut completed);
|
||||
offset = need;
|
||||
}
|
||||
@@ -931,6 +942,76 @@ mod tests {
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
/// A boundary packet (one split across two feeds) must be stamped with the
|
||||
/// source offset of its FIRST byte, which sat `remainder.len()` bytes before
|
||||
/// the current feed's base — not at `feed_base - 1`. We feed two 192-byte
|
||||
/// packets via `feed_at`, splitting mid-second-packet so the second packet
|
||||
/// is reassembled at the boundary, and assert its provenance lands exactly
|
||||
/// on its first byte.
|
||||
#[test]
|
||||
fn boundary_packet_source_is_first_byte_not_base_minus_one() {
|
||||
let pid = 0x1011;
|
||||
let base: u64 = 20480; // sector-aligned (10 × 2048)
|
||||
let mut demux = TsDemuxer::new(&[pid]);
|
||||
|
||||
let pkt0 = ts_payload_packet(pid, true, 0, &pes_start(b"AAAA"));
|
||||
let pkt1 = ts_payload_packet(pid, true, 1, &pes_start(b"BBBB"));
|
||||
let mut full = pkt0;
|
||||
full.extend_from_slice(&pkt1);
|
||||
|
||||
// Split mid-pkt1 → pkt1 is reassembled from a 100-byte remainder + the
|
||||
// next feed's head. pkt1's first byte is at absolute offset base + 192.
|
||||
let split = BD_SOURCE_PACKET_BYTES + 100;
|
||||
let out1 = demux.feed_at(base, &full[..split]);
|
||||
assert!(out1.is_empty(), "pkt0's PES is still open");
|
||||
|
||||
// Second feed carries the rest; data[0] is at absolute base + split.
|
||||
let out2 = demux.feed_at(base + split as u64, &full[split..]);
|
||||
// pkt1 (PUSI) flushes pkt0's "AAAA" PES, stamped at pkt0's first byte.
|
||||
assert_eq!(out2.len(), 1, "pkt0's PES completes when pkt1 starts");
|
||||
assert_eq!(
|
||||
out2[0].source.map(|s| s.byte),
|
||||
Some(base),
|
||||
"AAAA PES provenance is pkt0's first byte"
|
||||
);
|
||||
|
||||
// Flush emits pkt1's "BBBB" PES — its source is the boundary stamp.
|
||||
let out3 = demux.flush();
|
||||
assert_eq!(out3.len(), 1, "pkt1's PES flushes out");
|
||||
assert_eq!(
|
||||
out3[0].source.map(|s| s.byte),
|
||||
Some(base + BD_SOURCE_PACKET_BYTES as u64),
|
||||
"boundary packet provenance must be its first byte (base + 192), \
|
||||
not feed_base - 1"
|
||||
);
|
||||
}
|
||||
|
||||
/// Owner decision #7: a plain `feed()` must reset/ignore any base a prior
|
||||
/// `feed_at()` left behind, so mixing the two is safe. After a `feed_at`
|
||||
/// primes a base, the next plain `feed` must stamp `None` on PES packets it
|
||||
/// begins.
|
||||
#[test]
|
||||
fn plain_feed_resets_prior_feed_at_base() {
|
||||
let pid = 0x1011;
|
||||
let mut demux = TsDemuxer::new(&[pid]);
|
||||
|
||||
// Prime a base via feed_at; pkt0's PES stays open.
|
||||
let out1 = demux.feed_at(20480, &ts_payload_packet(pid, true, 0, &pes_start(b"AAAA")));
|
||||
assert!(out1.is_empty());
|
||||
|
||||
// Plain feed must clear the base. pkt1 (PUSI) flushes "AAAA" (which was
|
||||
// stamped during feed_at) and starts "BBBB" with NO provenance.
|
||||
let out2 = demux.feed(&ts_payload_packet(pid, true, 1, &pes_start(b"BBBB")));
|
||||
assert_eq!(out2.len(), 1, "AAAA completes");
|
||||
|
||||
let out3 = demux.flush();
|
||||
assert_eq!(out3.len(), 1, "BBBB flushes");
|
||||
assert_eq!(
|
||||
out3[0].source, None,
|
||||
"PES begun by a plain feed must carry no source after a prior feed_at"
|
||||
);
|
||||
}
|
||||
|
||||
// ── scan_streams PMT parsing ──────────────────────────────────────────
|
||||
|
||||
/// Wrap a 188-byte TS packet body in a 192-byte BD-TS packet
|
||||
|
||||
@@ -551,6 +551,21 @@ mod tests {
|
||||
},
|
||||
"measured CICP must override the coarse color_space enum"
|
||||
);
|
||||
|
||||
// Unknown colorimetry, SDR, no measured CICP → all code points map to
|
||||
// "unspecified" (2), matching `from_color_space(Unknown)`. Both sinks of
|
||||
// one title must emit 2, never 0.
|
||||
let c = Colour::from_video(&mk(HdrFormat::Sdr, ColorSpace::Unknown, None));
|
||||
assert_eq!(
|
||||
c,
|
||||
Colour {
|
||||
primaries: 2,
|
||||
transfer: 2,
|
||||
matrix: 2,
|
||||
full_range: false,
|
||||
},
|
||||
"Unknown colorimetry must emit CICP 'unspecified' (2), not 0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user