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:
Matthew Jackson
2026-06-24 14:34:55 -07:00
parent 315276dd13
commit 6592f2a590
18 changed files with 1938 additions and 120 deletions
+181
View File
@@ -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
View File
@@ -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)) =
+21
View File
@@ -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
View File
@@ -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)
+25 -6
View File
@@ -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;
+13
View File
@@ -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.