Mux Blu-ray 3D (MVC) as a single MVC video track

Fold the MVC dependent (right-eye) view into the base H.264 track as a
per-frame BlockAdditional under an mvcC BlockAdditionMapping, so a 3D
title produces one MVC video track instead of two independent H.264
tracks.

- h264: MVC-passthrough parser mode keeps the dependent view's subset
  SPS/PPS in-band, so each emitted frame is a self-contained dependent
  access unit for a BlockAdditional
- resolve: route the dependent stream through the passthrough parser
- mkvstream: detect the dependent view, pair it to the base frame by
  PTS (bounded FIFO), attach it as a BlockAdditional (BlockAddID=2),
  and skip building its own track; build the mvcC
  MVCDecoderConfigurationRecord from the captured subset SPS/PPS and set
  it on the base track at activation
- mkv: emit the mvcC BlockAdditionMapping and BlockGroup/BlockAdditions,
  with a ReferenceBlock on non-keyframe base frames
- ebml: add BlockAdditions/BlockMore/BlockAdditional/BlockAddID/
  BlockAddIDValue/ReferenceBlock elements and a signed-int writer

Verified against a Blu-ray 3D ISO: ffprobe shows a single MVC track,
the mvcC mapping is present, ~144k BlockAdditionals carry the dependent
view (8.7 GB), and the base view decodes cleanly with no regression.
MVCDecoderConfigurationRecord follows ISO/IEC 14496-15 7.6.2; StereoMode
is intentionally omitted (no enum value describes MVC-in-BlockAdditional;
the mvcC mapping is the primary 3D signal per RFC 9559).
This commit is contained in:
Matthew Jackson
2026-07-13 11:00:04 -07:00
parent 573d2f46c4
commit fd6dfbe5b0
8 changed files with 834 additions and 65 deletions
+1 -1
View File
@@ -277,7 +277,7 @@ impl Disc {
streams.push(Stream::Video(VideoStream { streams.push(Stream::Video(VideoStream {
pid: dep_pid, pid: dep_pid,
secondary: true, secondary: true,
label: "MVC dependent view (3D right eye)".to_string(), label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
..base ..base
})); }));
} }
+16
View File
@@ -216,6 +216,22 @@ pub struct VideoStream {
pub measured_cicp: Option<MeasuredCicp>, pub measured_cicp: Option<MeasuredCicp>,
} }
/// Label marking a video stream as the Blu-ray 3D **MVC dependent (right-eye)
/// view** — the paired substream of the AVC base view. Set by the BD scan
/// (`bluray.rs`) and recognised by the mux path (`resolve.rs` builds its parser
/// in param-set-preserving mode; `mkvstream.rs` folds it into the base track as
/// per-frame `BlockAdditional`). Single source of truth for the contract.
pub const MVC_DEPENDENT_LABEL: &str = "MVC dependent view (3D right eye)";
impl VideoStream {
/// Whether this video stream is the MVC dependent (right-eye) view — the
/// 3D substream that the muxer merges into the base track as a per-frame
/// `BlockAdditional` rather than emitting as an independent track.
pub fn is_mvc_dependent(&self) -> bool {
self.label == MVC_DEPENDENT_LABEL
}
}
/// Measured CICP colour signalling read directly from a video elementary stream /// Measured CICP colour signalling read directly from a video elementary stream
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present. /// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+26 -3
View File
@@ -56,6 +56,13 @@ pub struct H264Parser {
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on /// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
/// the BD/UHD transport path, which carries a per-frame PTS. /// the BD/UHD transport path, which carries a per-frame PTS.
reorder: Option<super::reorder::SparsePtsReorder>, reorder: Option<super::reorder::SparsePtsReorder>,
/// MVC dependent-view (Blu-ray 3D right-eye) passthrough mode. When set, the
/// parser does NOT strip SPS/PPS (nor re-assert at keyframes): every NAL —
/// subset SPS (type 15), prefix (14), coded-slice-extension (20), PPS (8) —
/// is length-prefixed in-band, so each emitted frame is a self-contained
/// dependent access unit suitable for a Matroska `BlockAdditional`. The base
/// view's avcC/param-set stripping is unchanged (separate parser instance).
mvc_passthrough: bool,
} }
impl Default for H264Parser { impl Default for H264Parser {
@@ -73,6 +80,7 @@ impl H264Parser {
cur_sps: None, cur_sps: None,
cur_pps: None, cur_pps: None,
reorder: None, reorder: None,
mvc_passthrough: false,
} }
} }
@@ -85,6 +93,15 @@ impl H264Parser {
self self
} }
/// Enable MVC dependent-view passthrough (see the `mvc_passthrough` field):
/// keep every parameter set in-band so each frame is a self-contained
/// dependent access unit for a Matroska `BlockAdditional`. Used only for the
/// Blu-ray 3D dependent (right-eye) stream.
pub(crate) fn with_mvc_passthrough(mut self, enabled: bool) -> Self {
self.mvc_passthrough = enabled;
self
}
/// Route a finished frame through the PTS reorderer when enabled, else emit /// Route a finished frame through the PTS reorderer when enabled, else emit
/// it directly (unchanged transport-stream behaviour). /// it directly (unchanged transport-stream behaviour).
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> { fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
@@ -192,17 +209,23 @@ impl CodecParser for H264Parser {
// frame in the mux hot path (mirrors the HEVC parser). // frame in the mux hot path (mirrors the HEVC parser).
let mut frame_data = Vec::with_capacity(pes.data.len() + 64); let mut frame_data = Vec::with_capacity(pes.data.len() + 64);
// MVC dependent-view passthrough: keep ALL param sets in-band (the frame
// is a self-contained BlockAdditional access unit), never strip/re-assert.
let mvc = self.mvc_passthrough;
for nal in NalIterator::new(&pes.data) { for nal in NalIterator::new(&pes.data) {
let nal_type = nal[0] & 0x1F; let nal_type = nal[0] & 0x1F;
match nal_type { match nal_type {
// Param sets: seed avcC, strip if unchanged vs the active set, // Param sets: seed avcC, strip if unchanged vs the active set,
// emit in-band on any change (incl. reverting to the avcC copy). // emit in-band on any change (incl. reverting to the avcC copy).
NAL_SPS => { // In MVC passthrough these fall through to the default arm so the
// subset SPS / PPS stay in-band (self-contained dependent AU).
NAL_SPS if !mvc => {
emitted_sps |= emitted_sps |=
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data) handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
} }
NAL_PPS => { NAL_PPS if !mvc => {
emitted_pps |= emitted_pps |=
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data) handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
} }
@@ -250,7 +273,7 @@ impl CodecParser for H264Parser {
// ahead of the slices (even when unchanged vs codecPrivate) so a decoder // ahead of the slices (even when unchanged vs codecPrivate) so a decoder
// that dropped the set at a reset recovers, and a stale avcC re-apply // that dropped the set at a reset recovers, and a stale avcC re-apply
// can't revert it. Skipped per-type only when this AU already carried it. // can't revert it. Skipped per-type only when this AU already carried it.
if keyframe { if keyframe && !mvc {
let mut prefix = Vec::new(); let mut prefix = Vec::new();
reassert_active(&mut prefix, &self.cur_sps, emitted_sps); reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
reassert_active(&mut prefix, &self.cur_pps, emitted_pps); reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
+16
View File
@@ -207,6 +207,22 @@ pub fn parser_for_codec(
} }
} }
/// Build the codec parser for a Blu-ray 3D **MVC dependent (right-eye)** video
/// stream. Same codec space as the base view (H.264), but in param-set
/// passthrough mode so each emitted frame is a self-contained dependent access
/// unit for a Matroska `BlockAdditional`. Non-H.264 (unexpected) falls back to
/// the ordinary parser.
pub fn parser_for_mvc_dependent(codec: Codec, is_dvd_ps: bool) -> Box<dyn CodecParser> {
match codec {
Codec::H264 => Box::new(
h264::H264Parser::new()
.with_ps_reorder(is_dvd_ps)
.with_mvc_passthrough(true),
),
_ => parser_for_codec(codec, None, is_dvd_ps),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+44
View File
@@ -94,6 +94,31 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
} }
} }
/// Write a complete EBML signed-integer element (two's-complement, big-endian,
/// minimal width). Used for `ReferenceBlock` (0xFB), whose value is a signed
/// tick offset relative to the current block's timestamp.
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
write_id(w, id)?;
// Minimal two's-complement width: shrink while the top byte is pure sign
// extension of the next byte's MSB.
let be = val.to_be_bytes();
let mut start = 0usize;
while start < 7 {
let sign_ext = if be[start + 1] & 0x80 != 0 {
0xFF
} else {
0x00
};
if be[start] != sign_ext {
break;
}
start += 1;
}
let bytes = &be[start..];
write_size(w, bytes.len() as u64)?;
w.write_all(bytes)
}
/// Write a complete EBML float element (8-byte double). /// Write a complete EBML float element (8-byte double).
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> { pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
write_id(w, id)?; write_id(w, id)?;
@@ -481,6 +506,25 @@ pub const LUMINANCE_MIN: u32 = 0x55DA;
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4; pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7; pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED; pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
/// BlockAddIDValue (RFC 9559) — the value a per-frame `BlockAddID` references
/// to select this BlockAdditionMapping. Values ≥ 2 (1 is the default plain
/// BlockAdditional). Used by the MVC (`mvcC`) mapping for Blu-ray 3D.
pub const BLOCK_ADD_ID_VALUE: u32 = 0x41F0;
// Block additions carried inside a BlockGroup — per-frame side data. For
// Blu-ray 3D (MVC) the dependent (right-eye) view NAL units for an access unit
// ride here as a BlockAdditional under the track's `mvcC` mapping (RFC 9559
// §5.1.4.1.4; Matroska Codec Specifications §4.1.5).
pub const BLOCK_ADDITIONS: u32 = 0x75A1;
pub const BLOCK_MORE: u32 = 0xA6;
pub const BLOCK_ADDITIONAL: u32 = 0xA5;
pub const BLOCK_ADD_ID: u32 = 0xEE;
/// ReferenceBlock (RFC 9559 element 0xFB, child of BlockGroup) — signed
/// timestamp (in TimestampScale ticks) of a block this one references, relative
/// to this block's own timestamp. Its PRESENCE marks the Block as non-keyframe
/// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe
/// video frames that must live in a BlockGroup to carry an MVC BlockAdditional.
pub const REFERENCE_BLOCK: u32 = 0xFB;
// Audio // Audio
pub const AUDIO: u32 = 0xE1; pub const AUDIO: u32 = 0xE1;
+287 -5
View File
@@ -68,6 +68,59 @@ const COLOUR_RANGE_LIMITED: u8 = 1;
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec). /// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343; const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
/// BlockAddIDType "mvcC" — the MVCDecoderConfigurationRecord fourcc, big-endian
/// ASCII 'm''v''c''C'. Matroska BlockAdditionMapping/BlockAddIDType for a
/// Blu-ray 3D MVC configuration (RFC 9559 + Matroska Codec Specifications
/// §4.1.5; equals ISO/IEC 14496-15 MVCConfigurationBox('mvcC')). The per-frame
/// dependent (right-eye) view rides as a BlockAdditional under this mapping.
const BLOCK_ADD_ID_TYPE_MVCC: u64 = 0x6D76_6343;
/// BlockAddIDValue for the MVC mapping — the value each per-frame `BlockAddID`
/// references (RFC 9559 requires ≥ 2; 1 is the default plain BlockAdditional).
const BLOCK_ADD_ID_VALUE_MVC: u64 = 2;
/// Build an `MVCDecoderConfigurationRecord` (ISO/IEC 14496-15:2013 §7.6.2) from
/// the dependent view's subset-SPS (NAL type 15) and PPS NAL units. This is the
/// `BlockAddIDExtraData` for the `mvcC` BlockAdditionMapping (and, in the ISO
/// container, the `MVCConfigurationBox('mvcC')` payload).
///
/// Layout mirrors the AVCDecoderConfigurationRecord except byte[4] repurposes
/// the AVC record's `bit(6) reserved` as
/// `complete_representation(1) | explicit_au_track(1) | reserved '1111'(4)`.
/// `profile`/`compat`/`level` describe the WHOLE MVC stream and come from the
/// subset SPS (bytes 1..=3). `length_size_minus_one` MUST match the base avcC
/// (freemkv always emits 4-byte length prefixes → 3). SPSs precede subset SPSs
/// in the array; here the array carries the dependent view's parameter sets.
///
/// Returns `None` if either param set is absent, too short, or exceeds the
/// 16-bit length field (a param set > 65535 bytes is non-conforming and would
/// mis-frame the record).
fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
if subset_sps.len() < 4 || subset_sps.len() > 0xFFFF || pps.is_empty() || pps.len() > 0xFFFF {
return None;
}
let mut record = vec![
1, // configurationVersion
subset_sps[1], // AVCProfileIndication (whole MVC stream, from subset SPS)
subset_sps[2], // profile_compatibility
subset_sps[3], // AVCLevelIndication
// complete_representation(1)=1 | explicit_au_track(1)=0 |
// reserved '1111'(4) | lengthSizeMinusOne(2)=3(11) → 1_0_1111_11 = 0xBF.
0xBF,
// reserved '0'(1) | numOfSequenceParameterSets(7)=1 → 0_0000001 = 0x01.
// NB: distinct from the AVC record's byte[5] (reserved(3)|numSPS(5)).
0x01,
(subset_sps.len() >> 8) as u8,
subset_sps.len() as u8,
];
record.extend_from_slice(subset_sps);
record.push(1); // numOfPictureParameterSets
record.push((pps.len() >> 8) as u8);
record.push(pps.len() as u8);
record.extend_from_slice(pps);
Some(record)
}
/// Resolve a video stream's CICP colour code points — `(matrix, transfer, /// Resolve a video stream's CICP colour code points — `(matrix, transfer,
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink /// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can /// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
@@ -176,6 +229,14 @@ pub struct MkvTrack {
/// stream is parsed. When `Some`, the serializer emits MasteringMetadata + /// stream is parsed. When `Some`, the serializer emits MasteringMetadata +
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely. /// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
pub hdr10: Option<crate::mux::codec::Hdr10Metadata>, pub hdr10: Option<crate::mux::codec::Hdr10Metadata>,
/// Blu-ray 3D (MVC): the dependent (right-eye) view's `(subset_sps, pps)`
/// NAL units, from which the serializer builds the `mvcC`
/// MVCDecoderConfigurationRecord (ISO/IEC 14496-15 §7.6.2) for the track's
/// BlockAdditionMapping, and emits `StereoMode`. `None` for non-3D tracks.
/// Set at muxer activation from the dependent stream's parameter sets — the
/// same deferred path `hdr10`/FieldOrder use — never at construction. When
/// `Some`, the per-frame dependent view rides as a `BlockAdditional`.
pub mvc_params: Option<(Vec<u8>, Vec<u8>)>,
} }
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska /// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
@@ -380,6 +441,7 @@ impl MkvTrack {
// coded picture's PictureInfo before the header is written (the same // coded picture's PictureInfo before the header is written (the same
// deferred path FieldOrder uses). `None` here → omitted unless seen. // deferred path FieldOrder uses). `None` here → omitted unless seen.
hdr10: None, hdr10: None,
mvc_params: None,
} }
} }
@@ -448,6 +510,7 @@ impl MkvTrack {
bit_depth: 0, bit_depth: 0,
dv_config: None, dv_config: None,
hdr10: None, hdr10: None,
mvc_params: None,
} }
} }
@@ -485,6 +548,7 @@ impl MkvTrack {
bit_depth: 0, bit_depth: 0,
dv_config: None, dv_config: None,
hdr10: None, hdr10: None,
mvc_params: None,
} }
} }
} }
@@ -565,6 +629,11 @@ pub struct MkvMuxer<W: Write + Seek> {
/// Highest block timestamp (TimestampScale ticks) written across all tracks — /// Highest block timestamp (TimestampScale ticks) written across all tracks —
/// the muxed runtime, used to back-patch the DURATION placeholder. /// the muxed runtime, used to back-patch the DURATION placeholder.
max_block_ticks: i64, max_block_ticks: i64,
/// Timestamp (TimestampScale ticks) of the last video keyframe written on the
/// primary video track. A non-keyframe MVC base frame lives in a BlockGroup
/// (to carry its dependent-view BlockAdditional) and needs a `ReferenceBlock`
/// so players don't mistake it for a keyframe; it references this keyframe.
last_video_keyframe_ticks: Option<i64>,
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble /// 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 /// 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 /// corrected from the AC-3 bitstream `acmod` of the first frame on the
@@ -923,6 +992,33 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut writer, vid_pos)?; ebml::end_master(&mut writer, vid_pos)?;
} }
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
// mediainfo recognise the dependent (right-eye) view that rides as a
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
if let Some((subset_sps, pps)) = track.mvc_params.as_ref() {
if let Some(record) = mvc_decoder_config_record(subset_sps, pps) {
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
ebml::write_uint(
&mut writer,
ebml::BLOCK_ADD_ID_VALUE,
BLOCK_ADD_ID_VALUE_MVC,
)?;
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, &record)?;
ebml::end_master(&mut writer, map_pos)?;
} else {
tracing::warn!(
target: "mux",
"MVC track: could not build MVCDecoderConfigurationRecord from the \
dependent view's parameter sets (subset_sps={} B, pps={} B); \
emitting no mvcC mapping the 3D pairing will not be signalled.",
subset_sps.len(),
pps.len(),
);
}
}
// Dolby Vision signaling — BlockAdditionMapping is a child of the // Dolby Vision signaling — BlockAdditionMapping is a child of the
// TrackEntry (sibling of Video). Carries the dvcC so players / // TrackEntry (sibling of Video). Carries the dvcC so players /
// mediainfo recognise the track as Dolby Vision. // mediainfo recognise the track as Dolby Vision.
@@ -1027,6 +1123,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
duration_secs, duration_secs,
duration_patch_pos, duration_patch_pos,
max_block_ticks: 0, max_block_ticks: 0,
last_video_keyframe_ticks: None,
ac3_channel_fixups, ac3_channel_fixups,
opening_capture: None, opening_capture: None,
}) })
@@ -1065,6 +1162,43 @@ impl<W: Write + Seek> MkvMuxer<W> {
keyframe: bool, keyframe: bool,
data: &[u8], data: &[u8],
duration_ns: Option<u64>, duration_ns: Option<u64>,
) -> io::Result<()> {
self.write_frame_inner(track_idx, pts_ns, keyframe, data, duration_ns, None)
}
/// As [`write_frame`](Self::write_frame), but attaches `block_additional` to
/// the frame as a Matroska `BlockAdditional` (BlockAddID=2). Used for Blu-ray
/// 3D (MVC): the base view is the Block, the dependent (right-eye) access
/// unit rides as the BlockAdditional under the track's `mvcC` mapping. A frame
/// carrying an additional is always written as a `BlockGroup` (never a
/// SimpleBlock), with a `ReferenceBlock` when it is not a keyframe.
pub fn write_frame_with_additional(
&mut self,
track_idx: usize,
pts_ns: i64,
keyframe: bool,
data: &[u8],
duration_ns: Option<u64>,
block_additional: Option<&[u8]>,
) -> io::Result<()> {
self.write_frame_inner(
track_idx,
pts_ns,
keyframe,
data,
duration_ns,
block_additional,
)
}
fn write_frame_inner(
&mut self,
track_idx: usize,
pts_ns: i64,
keyframe: bool,
data: &[u8],
duration_ns: Option<u64>,
block_additional: Option<&[u8]>,
) -> io::Result<()> { ) -> io::Result<()> {
// --log-level 3: capture the first ~100 coded frames per track to the // --log-level 3: capture the first ~100 coded frames per track to the
// side file BEFORE any timeline mangling, with the codec parser's own // side file BEFORE any timeline mangling, with the codec parser's own
@@ -1226,15 +1360,45 @@ impl<W: Write + Seek> MkvMuxer<W> {
self.max_block_ticks = self.max_block_ticks.max(block_end_ticks); self.max_block_ticks = self.max_block_ticks.max(block_end_ticks);
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16; let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
match duration_ns { let duration_ticks =
Some(dur_ns) => { duration_ns.map(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64);
// BlockDuration is in TimestampScale ticks, floored at 1. match block_additional {
let duration_ticks = (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64; // MVC: base view Block + dependent-view BlockAdditional, always a
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ticks)?; // BlockGroup. Non-keyframe base frames get a ReferenceBlock to the
// last keyframe (a keyframe carries none), so a player never treats a
// P/B frame as a seek point.
Some(additional) => {
let reference = if keyframe {
None
} else {
// Offset (ticks) of the referenced keyframe relative to this
// block. `None` only if no keyframe preceded (unreachable for a
// real non-keyframe); then the ReferenceBlock is simply omitted.
self.last_video_keyframe_ticks.map(|kf| kf - pts_ticks)
};
self.write_block_group_mvc(
track_idx + 1,
relative_ts,
data,
additional,
reference,
duration_ticks,
)?;
}
None => match duration_ticks {
// BlockDuration present (PGS subtitles) → BlockGroup.
Some(dt) => {
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, dt)?;
} }
None => { None => {
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?; self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
} }
},
}
// Remember the last video keyframe's tick so a later non-keyframe MVC
// base frame can reference it (see block_additional path above).
if is_video && keyframe {
self.last_video_keyframe_ticks = Some(pts_ticks);
} }
self.frame_count += 1; self.frame_count += 1;
@@ -1519,6 +1683,52 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut self.writer, bg_pos)?; ebml::end_master(&mut self.writer, bg_pos)?;
Ok(()) Ok(())
} }
/// Write a BlockGroup carrying the base view Block plus the MVC dependent
/// (right-eye) access unit as a `BlockAdditional` (BlockAddID=2), per the
/// track's `mvcC` BlockAdditionMapping. A non-keyframe frame gets a
/// `ReferenceBlock` (`reference` = referenced keyframe offset in ticks) so it
/// is not mistaken for a seek point; `BlockDuration` is written when known.
fn write_block_group_mvc(
&mut self,
track_num: usize,
relative_ts: i16,
data: &[u8],
additional: &[u8],
reference: Option<i64>,
duration_ticks: Option<u64>,
) -> io::Result<()> {
let (tv, tv_len) = track_vint(track_num);
let track_vint = &tv[..tv_len];
// The 0x80 Keyframe flag is SimpleBlock-only; inside a BlockGroup Block
// it is reserved and MUST be 0 — keyframe-ness is signalled by the
// presence/absence of ReferenceBlock.
let flags: u8 = 0x00;
let block_size = track_vint.len() + 2 + 1 + data.len();
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
ebml::write_size(&mut self.writer, block_size as u64)?;
self.writer.write_all(track_vint)?;
self.writer.write_all(&relative_ts.to_be_bytes())?;
self.writer.write_all(&[flags])?;
self.writer.write_all(data)?;
if let Some(dt) = duration_ticks {
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, dt)?;
}
if let Some(ref_off) = reference {
ebml::write_int(&mut self.writer, ebml::REFERENCE_BLOCK, ref_off)?;
}
// BlockAdditions → BlockMore { BlockAddID=2, BlockAdditional=dependent AU }.
let adds_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_ADDITIONS)?;
let more_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_MORE)?;
ebml::write_uint(&mut self.writer, ebml::BLOCK_ADD_ID, BLOCK_ADD_ID_VALUE_MVC)?;
ebml::write_binary(&mut self.writer, ebml::BLOCK_ADDITIONAL, additional)?;
ebml::end_master(&mut self.writer, more_pos)?;
ebml::end_master(&mut self.writer, adds_pos)?;
ebml::end_master(&mut self.writer, bg_pos)?;
Ok(())
}
} }
// ============================================================ // ============================================================
@@ -1702,6 +1912,7 @@ mod tests {
bit_depth: 0, bit_depth: 0,
dv_config: None, dv_config: None,
hdr10: None, hdr10: None,
mvc_params: None,
} }
} }
@@ -1731,6 +1942,7 @@ mod tests {
bit_depth: 0, bit_depth: 0,
dv_config: None, dv_config: None,
hdr10: None, hdr10: None,
mvc_params: None,
} }
} }
@@ -4234,6 +4446,76 @@ mod tests {
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none()); assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
} }
#[test]
fn mvc_decoder_config_record_matches_iso_14496_15_layout() {
// subset SPS (NAL type 15): [nal_hdr, profile, compat, level, ...].
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
let pps = vec![0x68, 0xEE, 0x3C];
let rec = mvc_decoder_config_record(&subset_sps, &pps).expect("record builds");
// ISO/IEC 14496-15:2013 §7.6.2 byte layout, verbatim:
let expected: Vec<u8> = [
vec![
1, // configurationVersion
0x80, // AVCProfileIndication = subset_sps[1]
0x00, // profile_compatibility = subset_sps[2]
0x33, // AVCLevelIndication = subset_sps[3]
0xBF, // complete_rep(1) explicit_au(0) reserved'1111' lengthSizeMinusOne=3
0x01, // reserved'0'(1) numOfSequenceParameterSets(7)=1
0x00, 0x06, // sequenceParameterSetLength = 6
],
subset_sps.clone(),
vec![
1, // numOfPictureParameterSets
0x00, 0x03, // pictureParameterSetLength = 3
],
pps.clone(),
]
.concat();
assert_eq!(rec, expected, "MVCDecoderConfigurationRecord byte layout");
// Guards: too-short SPS and empty PPS both refuse (no corrupt record).
assert!(mvc_decoder_config_record(&[0x6F, 0x80, 0x00], &pps).is_none());
assert!(mvc_decoder_config_record(&subset_sps, &[]).is_none());
}
#[test]
fn mvc_track_emits_mvcc_block_addition_mapping() {
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
// players / mediainfo recognise the Blu-ray 3D dependent view.
let mut v = make_video_track();
v.mvc_params = Some((
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
vec![0x68, 0xEE, 0x3C],
));
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_some(),
"MVC track must emit BlockAdditionMapping"
);
assert!(
find_id(&data, ebml::BLOCK_ADD_ID_VALUE).is_some(),
"MVC mapping must carry BlockAddIDValue"
);
assert!(
data.windows(4).any(|w| w == [0x6D, 0x76, 0x63, 0x43]),
"mvcC fourcc must be present as the BlockAddIDType value"
);
// Without mvc_params, no mapping.
let muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
let data = muxer.writer.into_inner();
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
}
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) ------------- // ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
// //
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux // `MkvTrack::video` always builds with `codec_private: None`; the PES mux
+428 -50
View File
@@ -120,14 +120,196 @@ struct PendingMux {
video_track: Option<usize>, video_track: Option<usize>,
/// `--log-level 3` opening-capture side-file path (if any). /// `--log-level 3` opening-capture side-file path (if any).
opening_capture_path: Option<std::path::PathBuf>, opening_capture_path: Option<std::path::PathBuf>,
/// Frames received before activation, replayed in order once built. /// Frames received before activation, replayed in order once built. Each
buffered: Vec<crate::pes::PesFrame>, /// carries an optional MVC dependent-view `BlockAdditional` (present only
/// for a 3D base-view frame that was already paired before activation).
buffered: Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>,
} }
/// Matroska container stream. /// Matroska container stream.
pub struct MkvStream { pub struct MkvStream {
disc_title: DiscTitle, disc_title: DiscTitle,
mode: Mode, mode: Mode,
/// Blu-ray 3D (MVC) merge state — present iff the title carries an MVC
/// dependent (right-eye) view. Folds the dependent stream's frames into the
/// base video track as per-frame `BlockAdditional`, paired by PTS, so the
/// output is a single MVC track instead of two independent H.264 tracks.
mvc: Option<MvcMerge>,
}
/// Largest number of base frames held awaiting their PTS-matching dependent AU
/// before the oldest is flushed unpaired (a plain Block). The SSIF interleaves
/// base and dependent access units per unit, so a base's dependent normally
/// arrives within one or two frames; this window only bounds memory/latency for
/// a stream where the pairing drifts.
const MVC_PAIR_WINDOW: usize = 32;
/// A base-view frame (track already remapped to the muxer's base track index)
/// awaiting — or already carrying — its dependent-view `BlockAdditional`.
struct PendingBase {
frame: crate::pes::PesFrame,
additional: Option<Vec<u8>>,
}
/// State for folding the MVC dependent (right-eye) view into the base track.
struct MvcMerge {
/// `title.streams` index of the base (left-eye) video stream.
base_stream_idx: usize,
/// `title.streams` index of the dependent (right-eye) video stream.
dep_stream_idx: usize,
/// Muxer track index of the base view — where the dependent AU is attached
/// as a `BlockAdditional` and where `mvc_params` (the `mvcC` mapping) lives.
base_track_idx: usize,
/// `title.streams` index → muxer track index. The dependent maps to `None`
/// (it becomes a BlockAdditional, not a track); every other stream shifts
/// down by one if it followed the dependent in stream order.
stream_to_track: Vec<Option<usize>>,
/// Base frames (decode order) awaiting their dependent or a window flush.
pending_base: std::collections::VecDeque<PendingBase>,
/// Dependent AU data keyed by PTS, waiting for the matching base.
dep_by_pts: std::collections::HashMap<i64, Vec<u8>>,
/// `(subset_sps, pps)` from the first dependent AU — builds the `mvcC`
/// MVCDecoderConfigurationRecord for the base track's BlockAdditionMapping.
captured_params: Option<(Vec<u8>, Vec<u8>)>,
/// Count of dependent AUs dropped with no matching base (diagnostic).
orphan_deps: u64,
}
impl MvcMerge {
/// Ingest one incoming frame; returns `(frame, additional)` pairs ready to
/// hand to the muxer, in emit order. Base frames buffer briefly to pair with
/// their dependent by PTS; the dependent stream produces no frames of its own
/// (it becomes `BlockAdditional`); all other streams pass straight through
/// with their track index remapped.
fn ingest(
&mut self,
frame: &crate::pes::PesFrame,
) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
let mut out = Vec::new();
if frame.track == self.dep_stream_idx {
if self.captured_params.is_none() {
self.captured_params = extract_mvc_params(&frame.data);
}
// Attach to a waiting base of the same PTS, else stash by PTS.
if let Some(pb) = self
.pending_base
.iter_mut()
.find(|pb| pb.frame.pts == frame.pts && pb.additional.is_none())
{
pb.additional = Some(frame.data.clone());
} else {
self.dep_by_pts.insert(frame.pts, frame.data.clone());
// Bound the orphan map: if dependents pile up unpaired (pairing
// badly drifted), drop the surplus rather than grow unbounded.
if self.dep_by_pts.len() > MVC_PAIR_WINDOW * 4 {
self.orphan_deps += self.dep_by_pts.len() as u64;
self.dep_by_pts.clear();
}
}
} else if frame.track == self.base_stream_idx {
let additional = self.dep_by_pts.remove(&frame.pts);
let mut remapped = frame.clone();
remapped.track = self.base_track_idx;
self.pending_base.push_back(PendingBase {
frame: remapped,
additional,
});
} else {
// Audio / subtitle / other video: remap the track index and forward.
let mut remapped = frame.clone();
if let Some(Some(t)) = self.stream_to_track.get(frame.track) {
remapped.track = *t;
out.push((remapped, None));
}
}
self.drain_ready(&mut out);
out
}
/// Emit base frames from the FIFO front once each has its dependent attached,
/// or flush the oldest unpaired base as a plain Block when the window is full.
fn drain_ready(&mut self, out: &mut Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>) {
loop {
let front_ready = self
.pending_base
.front()
.map(|pb| pb.additional.is_some())
.unwrap_or(false);
if front_ready || self.pending_base.len() > MVC_PAIR_WINDOW {
if let Some(pb) = self.pending_base.pop_front() {
out.push((pb.frame, pb.additional));
continue;
}
}
break;
}
}
/// Flush every remaining buffered base frame (unpaired → plain Block) at EOF.
fn flush(&mut self) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
let mut out = Vec::new();
for pb in self.pending_base.drain(..) {
out.push((pb.frame, pb.additional));
}
self.orphan_deps += self.dep_by_pts.len() as u64;
self.dep_by_pts.clear();
out
}
}
/// Hand a frame to the muxer: the plain path (`write_frame`) when there is no
/// MVC dependent view to attach, the BlockAdditional path otherwise.
fn emit_to_muxer(
m: &mut MkvMuxer<Box<dyn WriteSeek + Send>>,
frame: &crate::pes::PesFrame,
additional: Option<&[u8]>,
) -> io::Result<()> {
match additional {
None => m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
),
Some(_) => m.write_frame_with_additional(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
additional,
),
}
}
/// Scan a length-prefixed (4-byte big-endian) H.264 NAL stream for the first
/// subset SPS (NAL type 15) and first PPS (NAL type 8) — the two parameter sets
/// that populate the `mvcC` MVCDecoderConfigurationRecord. Returns
/// `Some((subset_sps, pps))` only when BOTH are found; `None` otherwise (the
/// serializer then emits no mvcC mapping and logs it).
fn extract_mvc_params(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
let mut subset_sps: Option<Vec<u8>> = None;
let mut pps: Option<Vec<u8>> = None;
let mut i = 0usize;
while i + 4 <= data.len() {
let len = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
i += 4;
if len == 0 || i + len > data.len() {
break;
}
let nal = &data[i..i + len];
i += len;
match nal[0] & 0x1F {
15 if subset_sps.is_none() => subset_sps = Some(nal.to_vec()),
8 if pps.is_none() => pps = Some(nal.to_vec()),
_ => {}
}
if subset_sps.is_some() && pps.is_some() {
break;
}
}
Some((subset_sps?, pps?))
} }
impl MkvStream { impl MkvStream {
@@ -146,10 +328,30 @@ impl MkvStream {
title: &DiscTitle, title: &DiscTitle,
output_path: Option<&std::path::Path>, output_path: Option<&std::path::Path>,
) -> io::Result<Self> { ) -> io::Result<Self> {
// Blu-ray 3D (MVC): a dependent (right-eye) view stream is NOT emitted as
// its own track — it is folded into the base track as per-frame
// BlockAdditional. Detect it so we skip building a track for it and set up
// the merge. `base_stream_idx` is the first video stream.
let dep_stream_idx = title
.streams
.iter()
.position(|s| matches!(s, crate::disc::Stream::Video(v) if v.is_mvc_dependent()));
let base_stream_idx = title
.streams
.iter()
.position(|s| matches!(s, crate::disc::Stream::Video(_)));
let mut tracks = Vec::new(); let mut tracks = Vec::new();
let mut has_default_video = false; let mut has_default_video = false;
let mut has_default_audio = false; let mut has_default_audio = false;
// `title.streams` index → muxer track index (`None` = the dependent view,
// which has no track). Streams after the dependent shift down by one.
let mut stream_to_track: Vec<Option<usize>> = Vec::with_capacity(title.streams.len());
for (idx, s) in title.streams.iter().enumerate() { for (idx, s) in title.streams.iter().enumerate() {
if Some(idx) == dep_stream_idx {
stream_to_track.push(None);
continue;
}
let mut track = match s { let mut track = match s {
crate::disc::Stream::Video(v) => MkvTrack::video(v), crate::disc::Stream::Video(v) => MkvTrack::video(v),
crate::disc::Stream::Audio(a) => MkvTrack::audio(a), crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
@@ -166,9 +368,30 @@ impl MkvStream {
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) { if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
track.codec_private = Some(cp.clone()); track.codec_private = Some(cp.clone());
} }
stream_to_track.push(Some(tracks.len()));
tracks.push(track); tracks.push(track);
} }
// Assemble the MVC merge when a dependent view is present and paired with
// a base video track.
let mvc = match (dep_stream_idx, base_stream_idx) {
(Some(dep_stream_idx), Some(base_stream_idx)) => {
let base_track_idx =
stream_to_track[base_stream_idx].expect("base video stream always has a track");
Some(MvcMerge {
base_stream_idx,
dep_stream_idx,
base_track_idx,
stream_to_track,
pending_base: std::collections::VecDeque::new(),
dep_by_pts: std::collections::HashMap::new(),
captured_params: None,
orphan_deps: 0,
})
}
_ => None,
};
// Defer muxer construction (and the TrackEntry dump) until the first // Defer muxer construction (and the TrackEntry dump) until the first
// coded picture arrives, so the primary video track's FieldOrder is set // coded picture arrives, so the primary video track's FieldOrder is set
// from the parser's MEASURED value before the header is written — never // from the parser's MEASURED value before the header is written — never
@@ -177,6 +400,7 @@ impl MkvStream {
Ok(Self { Ok(Self {
disc_title: title.clone(), disc_title: title.clone(),
mvc,
mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux { mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux {
writer, writer,
tracks, tracks,
@@ -209,6 +433,24 @@ impl MkvStream {
if let Some(vt) = pending.video_track { if let Some(vt) = pending.video_track {
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen); apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
} }
// Blu-ray 3D: set the base video track's `mvc_params` from the dependent
// view's captured subset-SPS/PPS BEFORE the header is written, so the
// TrackEntry carries the `mvcC` BlockAdditionMapping. Captured from the
// first dependent AU (which arrives right after the first base AU in the
// SSIF), so it is available by the time the first base frame activates.
if let Some(mvc) = &self.mvc {
if let Some(params) = &mvc.captured_params {
if let Some(t) = pending.tracks.get_mut(mvc.base_track_idx) {
t.mvc_params = Some(params.clone());
}
} else {
tracing::warn!(
target: "mux",
"MVC: no dependent-view subset-SPS/PPS captured before activation; \
the base track will carry no mvcC mapping (3D not signalled)."
);
}
}
// --log-level 3: dump the FINAL TrackEntry metadata (field order set). // --log-level 3: dump the FINAL TrackEntry metadata (field order set).
for (i, track) in pending.tracks.iter().enumerate() { for (i, track) in pending.tracks.iter().enumerate() {
crate::diag::dump_mkv_track((i + 1) as u64, track); crate::diag::dump_mkv_track((i + 1) as u64, track);
@@ -223,18 +465,72 @@ impl MkvStream {
if let Some(path) = &pending.opening_capture_path { if let Some(path) = &pending.opening_capture_path {
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len())); muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
} }
for f in pending.buffered.drain(..) { for (f, additional) in pending.buffered.drain(..) {
muxer.write_frame(f.track, f.pts, f.keyframe, &f.data, f.duration_ns)?; muxer.write_frame_with_additional(
f.track,
f.pts,
f.keyframe,
&f.data,
f.duration_ns,
additional.as_deref(),
)?;
} }
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer))); self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
Ok(()) Ok(())
} }
/// Emit one frame (track index already muxer-relative) with an optional MVC
/// dependent-view `BlockAdditional`, honouring the deferred-activation
/// machinery: the first video frame triggers muxer construction (its coding
/// sets FieldOrder); earlier frames buffer. `additional` is `None` for every
/// non-3D frame and for the 3D base frames that had no paired dependent.
fn emit(&mut self, frame: &crate::pes::PesFrame, additional: Option<&[u8]>) -> io::Result<()> {
match &mut self.mode {
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
Mode::Write(WriteMode::Active(m)) => {
return emit_to_muxer(m, frame, additional);
}
Mode::Write(WriteMode::Building) => return Ok(()),
Mode::Write(WriteMode::Pending(_)) => {}
}
// Pending: the first video frame (or the safety cap) triggers muxer
// construction; that frame's coding sets the field order. Other frames
// buffer until then.
let (activate_now, use_coding) = match &self.mode {
Mode::Write(WriteMode::Pending(p)) => {
let is_video = match p.video_track {
Some(vt) => frame.track == vt,
// No video track: nothing to wait for — build on frame one.
None => true,
};
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
}
_ => unreachable!("guarded above"),
};
if activate_now {
// 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 }, use_coding)?;
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
return emit_to_muxer(m, frame, additional);
}
Ok(())
} else {
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
p.buffered
.push((frame.clone(), additional.map(|a| a.to_vec())));
}
Ok(())
}
}
/// Open an MKV file for reading → PES frames. /// Open an MKV file for reading → PES frames.
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> { pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?; let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?;
Ok(Self { Ok(Self {
disc_title, disc_title,
mvc: None,
mode: Mode::Read(ReadState { mode: Mode::Read(ReadState {
reader: Box::new(reader), reader: Box::new(reader),
cluster_ts_ticks: 0, cluster_ts_ticks: 0,
@@ -425,59 +721,40 @@ impl crate::pes::Stream for MkvStream {
} }
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> { fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
// Fast paths. if matches!(self.mode, Mode::Read(_)) {
match &mut self.mode { return Err(crate::error::Error::StreamReadOnly.into());
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
Mode::Write(WriteMode::Active(m)) => {
return m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
);
} }
Mode::Write(WriteMode::Building) => return Ok(()), // Non-3D fast path: emit the frame directly, no clone, no buffering.
Mode::Write(WriteMode::Pending(_)) => {} if self.mvc.is_none() {
return self.emit(frame, None);
} }
// Pending: the first video frame (or the safety cap) triggers muxer // Blu-ray 3D: run the frame through the MVC merge, which remaps track
// construction; that frame's coding sets the field order. Other frames // indices, folds the dependent view into the base as BlockAdditional
// buffer until then. // (paired by PTS), and yields 0+ frames ready to emit. `ingest` returns
let (activate_now, use_coding) = match &self.mode { // owned pairs so the `self.mvc` borrow is released before `emit`.
Mode::Write(WriteMode::Pending(p)) => { let emits = self.mvc.as_mut().unwrap().ingest(frame);
let is_video = match p.video_track { for (f, additional) in emits {
Some(vt) => frame.track == vt, self.emit(&f, additional.as_deref())?;
// No video track: nothing to wait for — build on frame one.
None => true,
};
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
}
_ => unreachable!("guarded above"),
};
if activate_now {
// 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 }, use_coding)?;
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
return m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
);
} }
Ok(()) Ok(())
} else {
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
p.buffered.push(frame.clone());
}
Ok(())
}
} }
fn finish(&mut self) -> io::Result<()> { fn finish(&mut self) -> io::Result<()> {
// Blu-ray 3D: flush any base frames still awaiting a dependent (emitted
// unpaired as plain Blocks) before finalizing.
if let Some(mvc) = self.mvc.as_mut() {
let tail = mvc.flush();
let orphans = mvc.orphan_deps;
if orphans > 0 {
tracing::debug!(
target: "mux",
"MVC: {orphans} dependent-view access units had no matching base frame (dropped)"
);
}
for (f, additional) in tail {
self.emit(&f, additional.as_deref())?;
}
}
// A title that produced no frames (or only buffered ones) is still // A title that produced no frames (or only buffered ones) is still
// finalized into a valid MKV: activate now with no measured coding. // finalized into a valid MKV: activate now with no measured coding.
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) { if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
@@ -916,6 +1193,107 @@ mod tests {
use crate::pes::Stream as _; use crate::pes::Stream as _;
use std::io::Cursor; use std::io::Cursor;
/// Length-prefix (4-byte big-endian) each NAL, as the H.264 parser emits.
fn lp(nals: &[&[u8]]) -> Vec<u8> {
let mut v = Vec::new();
for n in nals {
v.extend_from_slice(&(n.len() as u32).to_be_bytes());
v.extend_from_slice(n);
}
v
}
fn mvc_frame(track: usize, pts: i64, keyframe: bool, data: Vec<u8>) -> crate::pes::PesFrame {
crate::pes::PesFrame {
track,
pts,
keyframe,
data,
duration_ns: None,
source: None,
coding: None,
}
}
// A subset SPS (NAL type 15), a PPS (type 8), and a coded-slice-extension
// (type 20) — the shape of a dependent-view access unit.
const SUBSET_SPS: [u8; 5] = [0x6F, 0x80, 0x00, 0x33, 0xAA]; // 0x6F & 0x1F = 15
const DEP_PPS: [u8; 3] = [0x68, 0xEE, 0x3C]; // 0x68 & 0x1F = 8
const DEP_SLICE: [u8; 3] = [0x74, 0x11, 0x22]; // 0x74 & 0x1F = 20
#[test]
fn extract_mvc_params_finds_subset_sps_and_pps() {
let data = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
let (s, p) = extract_mvc_params(&data).expect("both param sets present");
assert_eq!(s, SUBSET_SPS, "subset SPS (NAL 15) captured verbatim");
assert_eq!(p, DEP_PPS, "PPS (NAL 8) captured verbatim");
// Missing PPS → None (the serializer then emits no mvcC mapping).
assert!(extract_mvc_params(&lp(&[&SUBSET_SPS, &DEP_SLICE])).is_none());
// Missing subset SPS → None.
assert!(extract_mvc_params(&lp(&[&DEP_PPS, &DEP_SLICE])).is_none());
}
fn empty_merge() -> MvcMerge {
MvcMerge {
base_stream_idx: 0,
dep_stream_idx: 2,
base_track_idx: 0,
stream_to_track: vec![Some(0), Some(1), None],
pending_base: std::collections::VecDeque::new(),
dep_by_pts: std::collections::HashMap::new(),
captured_params: None,
orphan_deps: 0,
}
}
#[test]
fn mvc_merge_pairs_base_and_dependent_by_pts() {
let mut m = empty_merge();
let dep = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
// Base arrives first (SSIF order): buffered, nothing emitted yet.
let e = m.ingest(&mvc_frame(0, 100, true, lp(&[&[0x65, 1, 2]])));
assert!(e.is_empty(), "base held until its dependent arrives");
// Dependent arrives → base is emitted, remapped to the base track, with
// the dependent AU as its BlockAdditional; params are captured.
let e = m.ingest(&mvc_frame(2, 100, false, dep.clone()));
assert_eq!(e.len(), 1, "the paired base frame is emitted");
assert_eq!(e[0].0.track, 0, "remapped to the base muxer track");
assert_eq!(
e[0].1.as_deref(),
Some(dep.as_slice()),
"dependent attached"
);
assert!(m.captured_params.is_some(), "mvcC params captured");
// Audio passes straight through (remapped, no additional).
let e = m.ingest(&mvc_frame(1, 100, true, vec![0xAA]));
assert_eq!(e.len(), 1);
assert_eq!(e[0].0.track, 1);
assert!(e[0].1.is_none());
// Dependent-before-base (reordered) also pairs.
let dep2 = lp(&[&DEP_SLICE]);
assert!(m.ingest(&mvc_frame(2, 200, false, dep2.clone())).is_empty());
let e = m.ingest(&mvc_frame(0, 200, false, lp(&[&[0x61, 3, 4]])));
assert_eq!(e.len(), 1);
assert_eq!(e[0].1.as_deref(), Some(dep2.as_slice()));
}
#[test]
fn mvc_merge_flushes_unpaired_base_at_eof() {
let mut m = empty_merge();
// Base with no dependent ever → held, then flushed unpaired at EOF.
assert!(
m.ingest(&mvc_frame(0, 10, true, vec![0, 0, 0, 1]))
.is_empty()
);
let tail = m.flush();
assert_eq!(tail.len(), 1, "unpaired base still emitted");
assert!(tail[0].1.is_none(), "no BlockAdditional when unpaired");
}
#[test] #[test]
fn apply_coding_to_track_sets_measured_field_order_never_guesses() { fn apply_coding_to_track_sets_measured_field_order_never_guesses() {
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream}; use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
+11 -1
View File
@@ -575,7 +575,17 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
pids.push(pid); pids.push(pid);
pid_to_track.push((pid, idx)); pid_to_track.push((pid, idx));
let is_dvd_ps = matches!(format, ContentFormat::MpegPs); let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps))); // The Blu-ray 3D MVC dependent (right-eye) view uses a param-set-
// passthrough H.264 parser so each frame is a self-contained dependent
// access unit for a BlockAdditional; every other stream uses the
// ordinary parser for its codec.
let parser = match s {
crate::disc::Stream::Video(v) if v.is_mvc_dependent() => {
super::codec::parser_for_mvc_dependent(codec, is_dvd_ps)
}
_ => super::codec::parser_for_codec(codec, None, is_dvd_ps),
};
parsers.push((pid, parser));
} }
let (ts, ps) = match format { let (ts, ps) = match format {
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())), ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),