libfreemkv: rc.5.1 DVD correctness fixes
- CSS: unlock scrambled-sector reads on enforcing drives via bus-auth only; classify sense 6F/03 as CSS-locked; early-bail on a fully locked scan; gate the AACS handshake off DVD discs. - DVD first-play menu no longer prepended to the feature: read the title VOBS base from vtstt_vobs (0xC4), not the menu VOBS vtsm_vobs (0xC0). - Interlaced field-duration (DefaultDecodedFieldDuration) written as a direct TrackEntry child rather than inside Video, so Windows reports the correct frame rate. - Audio channel count read from the AC-3 bitstream; FieldOrder set to TFF; per-track BPS tags. - Structured disc diagnostics at --log-level 3; reduced per-operation log spam.
This commit is contained in:
@@ -261,6 +261,81 @@ fn frame_duration_ns(data: &[u8], bsid: u8) -> u64 {
|
||||
(samples * 1_000_000_000 + rate / 2) / rate
|
||||
}
|
||||
|
||||
/// Base channel count per AC-3 `acmod` (A/52 Table 5.8), BEFORE the LFE.
|
||||
/// Index is the 3-bit acmod value; add 1 when `lfeon` is set.
|
||||
///
|
||||
/// ```text
|
||||
/// 0 = 1+1 (Ch1, Ch2) -> 2 4 = 3/0 (L,C,R) -> 3
|
||||
/// 1 = 1/0 (C, mono) -> 1 5 = 2/1 (L,R,S) -> 3
|
||||
/// 2 = 2/0 (L, R) -> 2 6 = 3/1 (L,C,R,S) -> 4
|
||||
/// 3 = 3/0 (L,C,R) -> 3 7 = 3/2 (L,C,R,SL,SR) -> 5
|
||||
/// ```
|
||||
const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 3, 4, 5];
|
||||
|
||||
/// Decode the channel count of an (E-)AC-3 frame from its bitstream `acmod` and
|
||||
/// `lfeon`, starting at the 0x0B77 syncword. Returns `None` when the frame is
|
||||
/// too short to carry the BSI bits.
|
||||
///
|
||||
/// This is the AUTHORITATIVE channel count for the track header: the DVD IFO
|
||||
/// `audio_attr_t.channels` nibble is a well-known unreliable/stale field, so
|
||||
/// the muxer prefers this over the IFO-claimed count (mirrors MakeMKV /
|
||||
/// HandBrake, which never trust the IFO audio nibble). LFE adds one channel
|
||||
/// (e.g. acmod=7 + lfeon → 6 = 5.1).
|
||||
///
|
||||
/// Bit layout from the syncword (A/52 §5.3.2 BSI):
|
||||
///
|
||||
/// ```text
|
||||
/// byte 5: bsid(5) | bsmod(3)
|
||||
/// byte 6: acmod(3) | [cmixlev(2) if acmod has a centre and acmod!=1]
|
||||
/// | [surmixlev(2) if acmod has surround]
|
||||
/// | [dsurmod(2) if acmod==2] | lfeon(1) | ...
|
||||
/// ```
|
||||
///
|
||||
/// `acmod` therefore always occupies byte-6 bits 7-5; `lfeon` follows a
|
||||
/// variable number of optional 2-bit fields, so we track the bit cursor.
|
||||
pub(crate) fn acmod_channels(data: &[u8]) -> Option<u8> {
|
||||
// Need at least bytes 0..=6 to read acmod (byte 6) and its trailing
|
||||
// optional fields + lfeon (which never spills past byte 7 for any acmod).
|
||||
if data.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let bsid = get_bsid(data);
|
||||
// E-AC-3 (bsid >= 11, Annex E) uses a different BSI layout. DVD audio is
|
||||
// always legacy AC-3 (bsid <= 8); for E-AC-3 we don't decode acmod here
|
||||
// and let the caller fall back to the passed channel count.
|
||||
if bsid >= 11 {
|
||||
return None;
|
||||
}
|
||||
// Bit cursor over `data`, MSB-first, starting at byte 6 bit 7 (= bit 48).
|
||||
let mut bit = 6 * 8;
|
||||
let read = |n: usize, bit: &mut usize| -> u32 {
|
||||
let mut v = 0u32;
|
||||
for _ in 0..n {
|
||||
let byte = data[*bit / 8];
|
||||
let shift = 7 - (*bit % 8);
|
||||
v = (v << 1) | ((byte >> shift) & 1) as u32;
|
||||
*bit += 1;
|
||||
}
|
||||
v
|
||||
};
|
||||
let acmod = read(3, &mut bit) as usize;
|
||||
// cmixlev: present when acmod has a centre channel AND is not the 1/0
|
||||
// (centre-only) mode — i.e. acmod & 0x1 != 0 && acmod != 0x1.
|
||||
if (acmod & 0x1) != 0 && acmod != 0x1 {
|
||||
let _cmixlev = read(2, &mut bit);
|
||||
}
|
||||
// surmixlev: present when acmod has a surround channel (acmod & 0x4).
|
||||
if (acmod & 0x4) != 0 {
|
||||
let _surmixlev = read(2, &mut bit);
|
||||
}
|
||||
// dsurmod: present only for the 2/0 (stereo) mode.
|
||||
if acmod == 0x2 {
|
||||
let _dsurmod = read(2, &mut bit);
|
||||
}
|
||||
let lfeon = read(1, &mut bit);
|
||||
Some(ACMOD_CHANNELS[acmod] + lfeon as u8)
|
||||
}
|
||||
|
||||
/// Find AC3/E-AC-3 syncword (0x0B77) in data.
|
||||
fn find_ac3_sync(data: &[u8]) -> Option<usize> {
|
||||
(0..data.len().saturating_sub(1)).find(|&i| data[i] == 0x0B && data[i + 1] == 0x77)
|
||||
@@ -968,6 +1043,112 @@ mod tests {
|
||||
assert!(parser.flush().is_empty());
|
||||
}
|
||||
|
||||
// --- acmod_channels: channel count from the AC-3 BSI bitstream ---
|
||||
|
||||
/// Build a minimal AC-3 BSI header (8 bytes) with a given acmod + lfeon.
|
||||
/// byte5 = bsid<<3 (bsmod=0); byte6 carries acmod in bits 7-5 followed by
|
||||
/// the optional mix-level fields and lfeon. We construct byte6/7 by writing
|
||||
/// bits MSB-first in the exact order acmod_channels reads them.
|
||||
fn make_bsi(acmod: u8, lfeon: bool) -> Vec<u8> {
|
||||
// Collect the bit sequence after byte 6 bit 7: acmod(3), [cmixlev(2)],
|
||||
// [surmixlev(2)], [dsurmod(2)], lfeon(1). Mix-level/dsurmod bits are
|
||||
// arbitrary (0 here) — only their PRESENCE shifts lfeon's position.
|
||||
let mut bits: Vec<u8> = Vec::new();
|
||||
for i in (0..3).rev() {
|
||||
bits.push((acmod >> i) & 1);
|
||||
}
|
||||
if (acmod & 0x1) != 0 && acmod != 0x1 {
|
||||
bits.push(0);
|
||||
bits.push(0); // cmixlev
|
||||
}
|
||||
if (acmod & 0x4) != 0 {
|
||||
bits.push(0);
|
||||
bits.push(0); // surmixlev
|
||||
}
|
||||
if acmod == 0x2 {
|
||||
bits.push(0);
|
||||
bits.push(0); // dsurmod
|
||||
}
|
||||
bits.push(lfeon as u8); // lfeon
|
||||
// Pack bits MSB-first starting at byte 6.
|
||||
let mut frame = vec![0u8; 8];
|
||||
frame[0] = 0x0B;
|
||||
frame[1] = 0x77;
|
||||
frame[5] = 8 << 3; // bsid = 8 (legacy AC-3), bsmod = 0
|
||||
for (idx, &b) in bits.iter().enumerate() {
|
||||
let bitpos = 6 * 8 + idx;
|
||||
if b != 0 {
|
||||
frame[bitpos / 8] |= 1 << (7 - (bitpos % 8));
|
||||
}
|
||||
}
|
||||
frame
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acmod_channels_stereo_2_0_no_lfe() {
|
||||
// acmod=2 (2/0 L,R), no LFE → 2 channels. Verifies the channel count is
|
||||
// read from the AC-3 bitstream's acmod, independent of any IFO claim.
|
||||
// (A disc whose IFO lists 5.1 but where the wrong physical substream is
|
||||
// selected is a separate stream-SELECTION bug, not this label path —
|
||||
// tracked for rc.5.2.)
|
||||
assert_eq!(acmod_channels(&make_bsi(2, false)), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acmod_channels_5_1() {
|
||||
// acmod=7 (3/2 L,C,R,SL,SR) + LFE → 6 channels (5.1).
|
||||
assert_eq!(acmod_channels(&make_bsi(7, true)), Some(6));
|
||||
// 3/2 without LFE → 5 channels.
|
||||
assert_eq!(acmod_channels(&make_bsi(7, false)), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acmod_channels_mono_and_dual_mono() {
|
||||
// acmod=1 (1/0 centre/mono) → 1; with LFE → 2.
|
||||
assert_eq!(acmod_channels(&make_bsi(1, false)), Some(1));
|
||||
assert_eq!(acmod_channels(&make_bsi(1, true)), Some(2));
|
||||
// acmod=0 (1+1 dual mono) → 2 base channels.
|
||||
assert_eq!(acmod_channels(&make_bsi(0, false)), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acmod_channels_3_0_and_2_1() {
|
||||
// acmod=4 (3/0 L,C,R) → 3 (exercises cmixlev present, surmixlev absent).
|
||||
assert_eq!(acmod_channels(&make_bsi(4, false)), Some(3));
|
||||
// acmod=5 (2/1 L,R,S) → 3 (surmixlev present, no centre).
|
||||
assert_eq!(acmod_channels(&make_bsi(5, false)), Some(3));
|
||||
// acmod=6 (3/1) + LFE → 5; lfeon position shifts after both
|
||||
// cmixlev (centre) and surmixlev (surround) 2-bit fields.
|
||||
assert_eq!(acmod_channels(&make_bsi(6, true)), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acmod_channels_short_frame_is_none() {
|
||||
// Fewer than 8 bytes cannot carry the BSI bits → None (caller falls
|
||||
// back to the IFO-claimed channel count).
|
||||
assert_eq!(acmod_channels(&[0x0B, 0x77, 0, 0, 0, 8 << 3]), None);
|
||||
assert_eq!(acmod_channels(&[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acmod_channels_eac3_is_none() {
|
||||
// E-AC-3 (bsid >= 11) uses a different BSI layout; acmod_channels
|
||||
// declines so the caller keeps the passed count.
|
||||
let mut data = make_bsi(2, false);
|
||||
data[5] = 16 << 3; // bsid = 16 (E-AC-3)
|
||||
assert_eq!(acmod_channels(&data), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acmod_channels_parses_real_built_frame() {
|
||||
// A frame built by make_ac3_frame (fscod/frmsizecod set, acmod bits 0)
|
||||
// decodes acmod=0 → 2 channels (dual mono), confirming the cursor lands
|
||||
// on the right bytes for a fully-formed frame, not just a stub header.
|
||||
let frame = make_ac3_frame(0, 2);
|
||||
// make_ac3_frame leaves byte 6 = 0 → acmod=0, lfeon=0 → 2 channels.
|
||||
assert_eq!(acmod_channels(&frame), Some(2));
|
||||
}
|
||||
|
||||
// helper: PES with a generic pts for E-AC-3 tests
|
||||
fn make_eac3_pes(data: Vec<u8>) -> PesPacket {
|
||||
PesPacket {
|
||||
|
||||
+40
-12
@@ -161,6 +161,10 @@ pub struct DiscStream {
|
||||
halt: Option<Halt>,
|
||||
event_fn: Option<Box<dyn Fn(Event) + Send>>,
|
||||
eof: bool,
|
||||
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF) — these
|
||||
/// are expected on every disc; tallied and summarised once at EOF instead of
|
||||
/// a per-packet WARN.
|
||||
dropped_nav_packets: u64,
|
||||
|
||||
// Cumulative bytes successfully read from the source. Drives
|
||||
// EventKind::BytesRead emission and autorip's per-device progress.
|
||||
@@ -284,6 +288,7 @@ impl DiscStream {
|
||||
halt: None,
|
||||
event_fn: None,
|
||||
eof: false,
|
||||
dropped_nav_packets: 0,
|
||||
bytes_read_total: 0,
|
||||
bytes_total_extents,
|
||||
ts_demuxer,
|
||||
@@ -578,6 +583,13 @@ impl crate::pes::Stream for DiscStream {
|
||||
let t0 = self.profiling.then(std::time::Instant::now);
|
||||
if !self.fill_extents()? {
|
||||
self.eof = true;
|
||||
if self.dropped_nav_packets > 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"dropped {} DVD navigation packets (private_stream_2/0xBF) — expected, carry no elementary stream",
|
||||
self.dropped_nav_packets
|
||||
);
|
||||
}
|
||||
// Flush demuxer — last PES packet may still be in the assembler
|
||||
if let Some(ref mut demuxer) = self.ts_demuxer {
|
||||
for pes in &demuxer.flush() {
|
||||
@@ -603,12 +615,20 @@ impl crate::pes::Stream for DiscStream {
|
||||
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
|
||||
// heuristic mis-routed VobSub into the AC-3 parser.
|
||||
let Some(pid) = ps.dvd_pid() else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
if ps.is_nav() {
|
||||
// Expected DVD navigation packet (PCI/DSI) —
|
||||
// tally, no WARN.
|
||||
self.dropped_nav_packets += 1;
|
||||
} else {
|
||||
// Unexpected unmappable stream_id (a
|
||||
// possibly-dropped real stream). Keep the WARN.
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some((_, track)) =
|
||||
@@ -714,12 +734,20 @@ impl crate::pes::Stream for DiscStream {
|
||||
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
|
||||
// heuristic mis-routed VobSub into the AC-3 parser.
|
||||
let Some(pid) = ps.dvd_pid() else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
if ps.is_nav() {
|
||||
// Expected DVD navigation packet (PCI/DSI) — tally,
|
||||
// no WARN.
|
||||
self.dropped_nav_packets += 1;
|
||||
} else {
|
||||
// Unexpected unmappable stream_id (a possibly-dropped
|
||||
// real stream). Keep the individual WARN.
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some((_, track)) =
|
||||
|
||||
@@ -419,6 +419,11 @@ pub const CODEC_ID: u32 = 0x86;
|
||||
pub const CODEC_PRIVATE: u32 = 0x63A2;
|
||||
pub const TRACK_NAME: u32 = 0x536E;
|
||||
pub const DEFAULT_DURATION: u32 = 0x23_E383;
|
||||
/// DefaultDecodedFieldDuration — nanoseconds per FIELD (half a frame for
|
||||
/// interlaced content). Emitting it on an interlaced track tells a reader the
|
||||
/// field rate so it stops halving the frame rate (Windows shell shows 12.5 fps
|
||||
/// for a 25 fps 576i stream without it). RFC 9559 / Matroska v4.
|
||||
pub const DEFAULT_DECODED_FIELD_DURATION: u32 = 0x23_4E7A;
|
||||
|
||||
// Video
|
||||
pub const VIDEO: u32 = 0xE0;
|
||||
@@ -434,6 +439,10 @@ pub const INTERLACED_PROGRESSIVE: u64 = 2;
|
||||
// NTSC DVD (480i) and HD (1080i) are top-field-first; PAL DVD (576i) is
|
||||
// bottom-field-first. 0xFF is our sentinel for "undetermined / omit".
|
||||
pub const FIELD_ORDER_TFF: u8 = 2;
|
||||
// Bottom-field-first. Retained for completeness/round-trip tests; the muxer
|
||||
// emits TFF for all DVD/HD interlaced content (DV is the only common BFF
|
||||
// source and freemkv does not produce it).
|
||||
#[allow(dead_code)]
|
||||
pub const FIELD_ORDER_BFF: u8 = 9;
|
||||
pub const FIELD_ORDER_UNDETERMINED: u8 = 0xFF;
|
||||
pub const DISPLAY_WIDTH: u32 = 0x54B0;
|
||||
@@ -472,6 +481,18 @@ pub const CUE_TRACK_POSITIONS: u32 = 0xB7;
|
||||
pub const CUE_TRACK: u32 = 0xF7;
|
||||
pub const CUE_CLUSTER_POSITION: u32 = 0xF1;
|
||||
|
||||
// Tags — per-track statistics tags. mkvmerge convention: a `BPS` SimpleTag
|
||||
// per track carries the bits-per-second so readers (Windows Explorer's MKV
|
||||
// property handler) that read the container tag rather than computing from
|
||||
// stream size show a bitrate for every track, not just CBR audio.
|
||||
pub const TAGS: u32 = 0x1254_C367;
|
||||
pub const TAG: u32 = 0x7373;
|
||||
pub const TARGETS: u32 = 0x63C0;
|
||||
pub const TAG_TRACK_UID: u32 = 0x63C5;
|
||||
pub const SIMPLE_TAG: u32 = 0x67C8;
|
||||
pub const TAG_NAME: u32 = 0x45A3;
|
||||
pub const TAG_STRING: u32 = 0x4487;
|
||||
|
||||
// Chapters
|
||||
pub const CHAPTERS: u32 = 0x1043_A770;
|
||||
pub const EDITION_ENTRY: u32 = 0x45B9;
|
||||
|
||||
+337
-14
@@ -36,6 +36,9 @@ pub struct MkvTrack {
|
||||
// meaningful when interlaced; `FIELD_ORDER_UNDETERMINED` omits it.
|
||||
pub interlaced: bool,
|
||||
pub field_order: u8,
|
||||
/// DefaultDecodedFieldDuration (ns per field) for interlaced video — half
|
||||
/// the frame `default_duration_ns`. 0 = omit (progressive / unknown).
|
||||
pub field_duration_ns: u64,
|
||||
// Audio-specific
|
||||
pub sample_rate: f64,
|
||||
pub channels: u8,
|
||||
@@ -131,17 +134,25 @@ impl MkvTrack {
|
||||
colour_primaries: primaries,
|
||||
colour_range: range,
|
||||
interlaced: v.resolution.is_interlaced(),
|
||||
// PAL DVD (576i) is bottom-field-first; NTSC DVD (480i) is
|
||||
// top-field-first. HD interlaced (1080i) is top-field-first.
|
||||
// Progressive content leaves the field order undetermined.
|
||||
// PAL DVD (576i), NTSC DVD (480i), and HD interlaced (1080i) are
|
||||
// all top-field-first ("almost everything but DV is TFF"). MediaInfo
|
||||
// reads "Top Field First" off the MPEG-2 picture coding extension,
|
||||
// so the container element must agree — emitting BFF here for 576i
|
||||
// (the pre-rc.5.1 value) was a wrong container value that disagreed
|
||||
// with the stream. Progressive content leaves the order undetermined.
|
||||
field_order: if v.resolution.is_interlaced() {
|
||||
match v.resolution {
|
||||
Resolution::R576i => ebml::FIELD_ORDER_BFF,
|
||||
_ => ebml::FIELD_ORDER_TFF,
|
||||
}
|
||||
ebml::FIELD_ORDER_TFF
|
||||
} else {
|
||||
ebml::FIELD_ORDER_UNDETERMINED
|
||||
},
|
||||
// One field is half a frame. For 576i 25 fps (40 ms frame) this is
|
||||
// 20 ms; for 480i 29.97 fps (~33.4 ms frame) ~16.68 ms. Only set on
|
||||
// interlaced tracks with a known frame duration.
|
||||
field_duration_ns: if v.resolution.is_interlaced() && default_duration_ns > 0 {
|
||||
default_duration_ns / 2
|
||||
} else {
|
||||
0
|
||||
},
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
@@ -214,6 +225,7 @@ impl MkvTrack {
|
||||
colour_range: 0,
|
||||
interlaced: false,
|
||||
field_order: ebml::FIELD_ORDER_UNDETERMINED,
|
||||
field_duration_ns: 0,
|
||||
sample_rate: sr,
|
||||
channels: ch,
|
||||
bit_depth: 0,
|
||||
@@ -249,6 +261,7 @@ impl MkvTrack {
|
||||
colour_range: 0,
|
||||
interlaced: false,
|
||||
field_order: ebml::FIELD_ORDER_UNDETERMINED,
|
||||
field_duration_ns: 0,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
@@ -305,6 +318,33 @@ pub struct MkvMuxer<W: Write + Seek> {
|
||||
info_offset: u64,
|
||||
tracks_offset: u64,
|
||||
chapters_offset: Option<u64>,
|
||||
/// Total payload bytes muxed PER TRACK (index = track_idx). Used to emit a
|
||||
/// per-track `BPS` statistics tag (bytes*8/duration) at finalize so Windows
|
||||
/// shows a bitrate for every track, not just CBR audio.
|
||||
track_bytes: Vec<u64>,
|
||||
/// Track UIDs in track order (parallels `track_bytes`), for the BPS Targets.
|
||||
track_uids: Vec<u64>,
|
||||
/// Segment duration in seconds (from `Info`), for the BPS denominator.
|
||||
duration_secs: f64,
|
||||
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
|
||||
/// is unreliable, so the channel count written in the track header is
|
||||
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
|
||||
/// track. Each entry records the file offset of the 1-byte Channels value
|
||||
/// (to patch in place) and the IFO-claimed count (to warn on disagreement);
|
||||
/// `corrected` flips once patched so we only act on the first frame.
|
||||
ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup>,
|
||||
}
|
||||
|
||||
/// Deferred AC-3 channel-count correction: the track header's `Channels` byte
|
||||
/// is written up-front from the (unreliable) IFO count; on the first AC-3 frame
|
||||
/// for the track the value is rewritten from the bitstream `acmod`.
|
||||
struct Ac3ChannelFixup {
|
||||
/// Absolute file offset of the 1-byte Channels value in the Tracks element.
|
||||
value_offset: u64,
|
||||
/// Channel count the IFO claimed (already written at `value_offset`).
|
||||
claimed: u8,
|
||||
/// True once the first frame has been parsed and the value finalised.
|
||||
corrected: bool,
|
||||
}
|
||||
|
||||
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
|
||||
@@ -645,10 +685,15 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
let tracks_start = writer.stream_position()?;
|
||||
let tracks_offset = tracks_start - segment_start;
|
||||
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
|
||||
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
|
||||
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
||||
std::collections::HashMap::new();
|
||||
for (i, track) in tracks.iter().enumerate() {
|
||||
let track_uid = (i + 1) as u64 | 0x100_0000;
|
||||
track_uids.push(track_uid);
|
||||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x100_0000)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
||||
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
|
||||
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
|
||||
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
|
||||
@@ -682,6 +727,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
)?;
|
||||
}
|
||||
|
||||
// DefaultDecodedFieldDuration (one FIELD = half a frame) on
|
||||
// interlaced tracks. Per the Matroska schema it is a DIRECT child
|
||||
// of TrackEntry (NOT inside Video). Without it an interlace-aware
|
||||
// reader (Windows shell) assumes "block = one field" and reports
|
||||
// half the frame rate (12.5 instead of 25 for 576i). DefaultDuration
|
||||
// above stays the full-frame period (40 ms); this is 20 ms.
|
||||
if track.track_type == ebml::TRACK_TYPE_VIDEO
|
||||
&& track.interlaced
|
||||
&& track.field_duration_ns > 0
|
||||
{
|
||||
ebml::write_uint(
|
||||
&mut writer,
|
||||
ebml::DEFAULT_DECODED_FIELD_DURATION,
|
||||
track.field_duration_ns,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Video-specific
|
||||
if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 {
|
||||
let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?;
|
||||
@@ -748,7 +810,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
// Omit Channels when unknown (0) — Matroska defaults it to 1
|
||||
// rather than us fabricating a 6-channel count.
|
||||
if track.channels > 0 {
|
||||
// Record the offset of the 1-byte Channels value so an AC-3
|
||||
// track can correct it from the bitstream acmod on its first
|
||||
// frame (the IFO nibble is unreliable). write_uint emits
|
||||
// ID(0x9F, 1B) + size(0x81, 1B) + value(1B) for 1..=255, so
|
||||
// the value byte sits 2 bytes after the element start.
|
||||
let chan_elem_pos = writer.stream_position()?;
|
||||
ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?;
|
||||
if track.codec_id == ebml::CODEC_AC3 {
|
||||
ac3_channel_fixups.insert(
|
||||
i,
|
||||
Ac3ChannelFixup {
|
||||
value_offset: chan_elem_pos + 2,
|
||||
claimed: track.channels,
|
||||
corrected: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if track.bit_depth > 0 {
|
||||
ebml::write_uint(&mut writer, ebml::BIT_DEPTH, track.bit_depth as u64)?;
|
||||
@@ -803,6 +881,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
info_offset,
|
||||
tracks_offset,
|
||||
chapters_offset,
|
||||
track_bytes: vec![0u64; tracks.len()],
|
||||
track_uids,
|
||||
duration_secs,
|
||||
ac3_channel_fixups,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -969,6 +1051,42 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
}
|
||||
self.frame_count += 1;
|
||||
|
||||
// Per-track byte total for the finalize-time BPS statistics tag.
|
||||
if let Some(b) = self.track_bytes.get_mut(track_idx) {
|
||||
*b += data.len() as u64;
|
||||
}
|
||||
|
||||
// Correct the AC-3 track's Channels element from the bitstream acmod on
|
||||
// the FIRST frame of the track. The DVD IFO audio nibble is unreliable
|
||||
// (it claims 5.1 on a 2.0 stream); the bitstream acmod is authoritative.
|
||||
// Only the first frame triggers it; the byte width is unchanged so the
|
||||
// patch is a single-byte in-place rewrite (then restore position).
|
||||
if let Some(fixup) = self.ac3_channel_fixups.get_mut(&track_idx) {
|
||||
if !fixup.corrected {
|
||||
match super::codec::ac3::acmod_channels(data) {
|
||||
Some(actual) if actual > 0 => {
|
||||
if actual != fixup.claimed {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"AC-3 track {track_idx}: IFO claimed {} channels but bitstream acmod says {}; trusting the bitstream (possible wrong-stream selection)",
|
||||
fixup.claimed,
|
||||
actual,
|
||||
);
|
||||
let here = self.writer.stream_position()?;
|
||||
self.writer
|
||||
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
|
||||
self.writer.write_all(&[actual])?;
|
||||
self.writer.seek(std::io::SeekFrom::Start(here))?;
|
||||
}
|
||||
fixup.corrected = true;
|
||||
}
|
||||
// Frame too short to carry the BSI bits — keep the passed
|
||||
// (IFO) value and try again on the next frame.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1015,6 +1133,12 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
ebml::end_master(&mut self.writer, cues_pos)?;
|
||||
}
|
||||
|
||||
// Per-track BPS statistics tags (mkvmerge convention). A reader that
|
||||
// reads the container `BPS` tag (Windows Explorer's MKV property
|
||||
// handler) rather than computing bitrate from stream size shows a
|
||||
// bitrate for EVERY track this way, not just CBR audio.
|
||||
self.write_bps_tags()?;
|
||||
|
||||
// Back-patch SeekHead SeekPosition values now that all element offsets are known.
|
||||
for fixup in &self.seek_fixups {
|
||||
let offset = match fixup.target_id {
|
||||
@@ -1036,6 +1160,48 @@ impl<W: Write + Seek> MkvMuxer<W> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a `Tags` master with a per-track `BPS` SimpleTag (bytes*8 /
|
||||
/// duration_secs). Mirrors mkvmerge's per-track statistics tag so readers
|
||||
/// that surface the container tag (Windows Explorer) show a bitrate for
|
||||
/// every track. No-op when the duration is unknown (can't compute a rate)
|
||||
/// or no track carried any bytes.
|
||||
fn write_bps_tags(&mut self) -> io::Result<()> {
|
||||
if self.duration_secs <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
if self.track_bytes.iter().all(|&b| b == 0) {
|
||||
return Ok(());
|
||||
}
|
||||
let tags_pos = ebml::start_master(&mut self.writer, ebml::TAGS)?;
|
||||
// Snapshot to avoid borrowing self across the writer borrow.
|
||||
let entries: Vec<(u64, u64)> = self
|
||||
.track_uids
|
||||
.iter()
|
||||
.zip(self.track_bytes.iter())
|
||||
.map(|(&uid, &bytes)| (uid, bytes))
|
||||
.collect();
|
||||
for (uid, bytes) in entries {
|
||||
if bytes == 0 {
|
||||
continue;
|
||||
}
|
||||
// bits per second = bytes * 8 / duration_secs, rounded to nearest.
|
||||
let bps = ((bytes as f64) * 8.0 / self.duration_secs).round() as u64;
|
||||
let tag_pos = ebml::start_master(&mut self.writer, ebml::TAG)?;
|
||||
// Targets → TagTrackUID (this tag applies to one track).
|
||||
let targets_pos = ebml::start_master(&mut self.writer, ebml::TARGETS)?;
|
||||
ebml::write_uint(&mut self.writer, ebml::TAG_TRACK_UID, uid)?;
|
||||
ebml::end_master(&mut self.writer, targets_pos)?;
|
||||
// SimpleTag(TagName="BPS", TagString="<bps>").
|
||||
let st_pos = ebml::start_master(&mut self.writer, ebml::SIMPLE_TAG)?;
|
||||
ebml::write_string(&mut self.writer, ebml::TAG_NAME, "BPS")?;
|
||||
ebml::write_string(&mut self.writer, ebml::TAG_STRING, &bps.to_string())?;
|
||||
ebml::end_master(&mut self.writer, st_pos)?;
|
||||
ebml::end_master(&mut self.writer, tag_pos)?;
|
||||
}
|
||||
ebml::end_master(&mut self.writer, tags_pos)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_cluster(&mut self, ts_ticks: i64) -> io::Result<()> {
|
||||
// Close previous cluster if open
|
||||
if self.cluster_open {
|
||||
@@ -1199,6 +1365,7 @@ mod tests {
|
||||
colour_range: 0,
|
||||
interlaced: false,
|
||||
field_order: ebml::FIELD_ORDER_UNDETERMINED,
|
||||
field_duration_ns: 0,
|
||||
sample_rate: 0.0,
|
||||
channels: 0,
|
||||
bit_depth: 0,
|
||||
@@ -1226,6 +1393,7 @@ mod tests {
|
||||
colour_range: 0,
|
||||
interlaced: false,
|
||||
field_order: ebml::FIELD_ORDER_UNDETERMINED,
|
||||
field_duration_ns: 0,
|
||||
sample_rate: 48000.0,
|
||||
channels: 6,
|
||||
bit_depth: 0,
|
||||
@@ -2986,12 +3154,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn video_emits_flag_interlaced_and_field_order() {
|
||||
// An interlaced (576i PAL) track must emit FlagInterlaced=1 and
|
||||
// FieldOrder=9 (bottom-field-first). A progressive track must emit
|
||||
// FlagInterlaced=2 and NO FieldOrder.
|
||||
// An interlaced track must emit FlagInterlaced=1 and its FieldOrder
|
||||
// value. A progressive track must emit FlagInterlaced=2 and NO
|
||||
// FieldOrder.
|
||||
let mut interlaced = make_video_track();
|
||||
interlaced.interlaced = true;
|
||||
interlaced.field_order = ebml::FIELD_ORDER_BFF;
|
||||
interlaced.field_order = ebml::FIELD_ORDER_TFF;
|
||||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[interlaced], None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present");
|
||||
@@ -3004,8 +3172,8 @@ mod tests {
|
||||
let fo = find_id(&data, ebml::FIELD_ORDER).expect("FieldOrder present");
|
||||
assert_eq!(
|
||||
data[fo + 2],
|
||||
ebml::FIELD_ORDER_BFF,
|
||||
"FieldOrder must be 9 (bottom-field-first) for PAL DVD"
|
||||
ebml::FIELD_ORDER_TFF,
|
||||
"FieldOrder value must round-trip through the writer"
|
||||
);
|
||||
|
||||
// Progressive track: FlagInterlaced=2, no FieldOrder.
|
||||
@@ -3030,6 +3198,161 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_576i_defaults_to_top_field_first() {
|
||||
// PAL 576i must default to TFF (2), not BFF — the container element must
|
||||
// agree with the MPEG-2 stream (MediaInfo reads "Top Field First" off
|
||||
// the picture coding extension). The pre-rc.5.1 BFF(9) was a wrong value.
|
||||
let v = VideoStream {
|
||||
pid: 0xE0,
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R576i,
|
||||
frame_rate: crate::disc::FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt470bg,
|
||||
display_aspect: Some((16, 9)),
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
};
|
||||
let t = MkvTrack::video(&v);
|
||||
assert!(t.interlaced, "576i is interlaced");
|
||||
assert_eq!(
|
||||
t.field_order,
|
||||
ebml::FIELD_ORDER_TFF,
|
||||
"576i must default to top-field-first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interlaced_576i_emits_default_decoded_field_duration() {
|
||||
// 576i @ 25 fps: DefaultDuration = 40 ms (frame), and
|
||||
// DefaultDecodedFieldDuration = 20 ms (field = half a frame). The field
|
||||
// element stops interlace-aware readers (Windows) halving the frame rate.
|
||||
let v = VideoStream {
|
||||
pid: 0xE0,
|
||||
codec: Codec::Mpeg2,
|
||||
resolution: Resolution::R576i,
|
||||
frame_rate: crate::disc::FrameRate::F25,
|
||||
hdr: HdrFormat::Sdr,
|
||||
color_space: ColorSpace::Bt470bg,
|
||||
display_aspect: None,
|
||||
secondary: false,
|
||||
label: String::new(),
|
||||
};
|
||||
let t = MkvTrack::video(&v);
|
||||
assert_eq!(t.default_duration_ns, 40_000_000, "frame duration is 40 ms");
|
||||
assert_eq!(t.field_duration_ns, 20_000_000, "field duration is 20 ms");
|
||||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
// DefaultDuration (frame) present and = 40 ms.
|
||||
let dd = find_id(&data, ebml::DEFAULT_DURATION).expect("DefaultDuration present");
|
||||
// [id 3B][size 0x84][4-byte value] — 40_000_000 needs 4 bytes.
|
||||
let frame_ns = u32::from_be_bytes([data[dd + 4], data[dd + 5], data[dd + 6], data[dd + 7]]);
|
||||
assert_eq!(frame_ns, 40_000_000, "DefaultDuration is the full frame");
|
||||
// DefaultDecodedFieldDuration present and = 20 ms.
|
||||
let fd =
|
||||
find_id(&data, ebml::DEFAULT_DECODED_FIELD_DURATION).expect("field duration present");
|
||||
let field_ns = u32::from_be_bytes([data[fd + 4], data[fd + 5], data[fd + 6], data[fd + 7]]);
|
||||
assert_eq!(field_ns, 20_000_000, "field duration is half the frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_video_omits_field_duration() {
|
||||
// A progressive track must NOT carry DefaultDecodedFieldDuration.
|
||||
let t = make_video_track(); // progressive
|
||||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
|
||||
let data = muxer.writer.into_inner();
|
||||
assert!(
|
||||
find_id(&data, ebml::DEFAULT_DECODED_FIELD_DURATION).is_none(),
|
||||
"no field duration for progressive content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finalize_emits_per_track_bps_tags() {
|
||||
// At finalize a Tags master with a per-track BPS SimpleTag is written.
|
||||
// BPS = bytes*8/duration_secs. With a 10 s duration and a video frame of
|
||||
// 1000 bytes, video BPS = 1000*8/10 = 800.
|
||||
let tracks = [make_video_track(), make_audio_track()];
|
||||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
|
||||
// Video keyframe 1000 bytes; audio frame 500 bytes.
|
||||
muxer
|
||||
.write_frame(0, 0, true, &vec![0xABu8; 1000], None)
|
||||
.unwrap();
|
||||
muxer
|
||||
.write_frame(1, 0, false, &vec![0xCDu8; 500], None)
|
||||
.unwrap();
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
|
||||
// The Tags master must be present as a top-level Segment child.
|
||||
let children = segment_children(&data);
|
||||
assert!(
|
||||
children.iter().any(|(id, _, _)| *id == ebml::TAGS),
|
||||
"Tags element must be written at finalize"
|
||||
);
|
||||
// The BPS values must appear as TagString text. Video: 800, Audio: 400.
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
assert!(text.contains("BPS"), "BPS TagName must be present");
|
||||
assert!(
|
||||
text.contains("800"),
|
||||
"video BPS (1000*8/10) must be present"
|
||||
);
|
||||
assert!(text.contains("400"), "audio BPS (500*8/10) must be present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_bps_tags_when_duration_unknown() {
|
||||
// With duration 0 (unknown) the BPS rate can't be computed; no Tags.
|
||||
let tracks = [make_video_track()];
|
||||
let frames = vec![(0usize, 0i64, true, vec![0xABu8; 1000])];
|
||||
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||||
let children = segment_children(&data);
|
||||
assert!(
|
||||
!children.iter().any(|(id, _, _)| *id == ebml::TAGS),
|
||||
"no Tags element when duration is unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ac3_channels_corrected_from_bitstream_acmod() {
|
||||
// The audio track header claims 6 channels (IFO 5.1), but the AC-3
|
||||
// bitstream's first frame has acmod=2 (2.0 stereo). The Channels element
|
||||
// must be rewritten to 2 from the bitstream, not left at the IFO's 6.
|
||||
let mut audio = make_audio_track(); // codec A_AC3, channels = 6
|
||||
audio.channels = 6;
|
||||
let video = make_video_track();
|
||||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||||
let writer = SharedWriter(shared.clone());
|
||||
let mut muxer = MkvMuxer::new(writer, &[video, audio], None, 0.0, &[]).unwrap();
|
||||
// A minimal AC-3 BSI with acmod=2 (2/0 stereo), no LFE → 2 channels.
|
||||
// byte5 = bsid 8 (legacy AC-3). byte6: acmod(010) | dsurmod(00) |
|
||||
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
|
||||
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
|
||||
// Open a cluster with a video keyframe first (cluster invariant).
|
||||
muxer.write_frame(0, 0, true, &[0x01, 0x02], None).unwrap();
|
||||
muxer.write_frame(1, 0, false, &ac3, None).unwrap();
|
||||
muxer.finish().unwrap();
|
||||
let data = shared.lock().unwrap().clone().into_inner();
|
||||
|
||||
// Locate the Channels element (0x9F) WITHIN the Tracks body (so a stray
|
||||
// 0x9F in cluster/AC-3 payload can't be mistaken for the element) and
|
||||
// assert the value byte is 2.
|
||||
let (tracks_start, tracks_size) = segment_children(&data)
|
||||
.into_iter()
|
||||
.find_map(|(id, off, sz)| (id == ebml::TRACKS).then_some((off, sz as usize)))
|
||||
.expect("Tracks element present");
|
||||
let tracks_body = &data[tracks_start..tracks_start + tracks_size];
|
||||
let ch = find_id(tracks_body, ebml::CHANNELS).expect("Channels element present");
|
||||
assert_eq!(
|
||||
tracks_body[ch + 2],
|
||||
2,
|
||||
"Channels must be corrected to 2 (bitstream acmod), not 6 (IFO)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dolby_vision_track_emits_block_addition_mapping() {
|
||||
// A DV track (dv_config set) must emit BlockAdditionMapping (0x41E4)
|
||||
|
||||
@@ -64,6 +64,10 @@ pub struct PipelinedPesStream {
|
||||
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
|
||||
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
|
||||
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
|
||||
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
|
||||
/// are expected on every disc; instead of a per-packet WARN they're tallied
|
||||
/// and summarised once at EOF.
|
||||
dropped_nav_packets: u64,
|
||||
}
|
||||
|
||||
impl PipelinedPesStream {
|
||||
@@ -93,6 +97,7 @@ impl PipelinedPesStream {
|
||||
eof: false,
|
||||
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
|
||||
decrypt_loss: None,
|
||||
dropped_nav_packets: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,12 +179,19 @@ impl PipelinedPesStream {
|
||||
// subtitle sub-id 0x20+j with audio track j+1, feeding
|
||||
// VobSub PES into the AC-3 parser.
|
||||
let Some(pid) = ps.dvd_pid() else {
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
if ps.is_nav() {
|
||||
// Expected DVD navigation packet (PCI/DSI) — tally, no WARN.
|
||||
self.dropped_nav_packets += 1;
|
||||
} else {
|
||||
// Unexpected unmappable stream_id — a possibly-dropped real
|
||||
// stream. Keep the individual WARN: its repetition is signal.
|
||||
tracing::warn!(
|
||||
target: "mux",
|
||||
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
|
||||
ps.stream_id,
|
||||
ps.sub_stream_id,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some((_, track)) = self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
|
||||
@@ -227,6 +239,13 @@ impl Stream for PipelinedPesStream {
|
||||
}
|
||||
false => {
|
||||
self.eof = true;
|
||||
if self.dropped_nav_packets > 0 {
|
||||
tracing::debug!(
|
||||
target: "mux",
|
||||
"dropped {} DVD navigation packets (private_stream_2/0xBF) — expected, carry no elementary stream",
|
||||
self.dropped_nav_packets
|
||||
);
|
||||
}
|
||||
// Drain any access unit a parser buffered past the last
|
||||
// PES (e.g. DTS-HD's final core+extension unit).
|
||||
let pid_to_track = &self.pid_to_track;
|
||||
|
||||
@@ -24,6 +24,9 @@ const PROGRAM_END_ID: u8 = 0xB9;
|
||||
|
||||
/// Private stream 1 (AC3, DTS, LPCM, subtitles).
|
||||
const PRIVATE_STREAM_1: u8 = 0xBD;
|
||||
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
|
||||
/// elementary stream; expected to be dropped on every disc.
|
||||
const PRIVATE_STREAM_2: u8 = 0xBF;
|
||||
|
||||
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
|
||||
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
|
||||
@@ -108,6 +111,16 @@ impl PsPacket {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a DVD navigation packet (private_stream_2, 0xBF —
|
||||
/// PCI/DSI). These carry no muxable elementary stream and are EXPECTED to
|
||||
/// be dropped on every DVD, so a per-packet WARN is noise: the mux loops
|
||||
/// count them and emit one finalize summary instead. A `dvd_pid()` of
|
||||
/// `None` for any OTHER stream_id is unexpected (a possibly-dropped real
|
||||
/// stream) and stays an individual WARN.
|
||||
pub fn is_nav(&self) -> bool {
|
||||
self.stream_id == PRIVATE_STREAM_2
|
||||
}
|
||||
}
|
||||
|
||||
/// MPEG-2 Program Stream demuxer.
|
||||
|
||||
Reference in New Issue
Block a user