Files
libfreemkv/src/mux/mkv.rs
T
Matthew Jackson 5b9dd0fbec error, mux: coded remux errors; MKV writer hardening; damage rules for key checks
E9077-E9084 for engine remux and preflight refusals; MuxOptions loses
its send deadline. The MKV writer drops unwrap/expect and overflow
paths, tags SDR BT.2020 correctly, leaves out streams with no Matroska
CodecID and derives the Dolby Vision level. On-arrival damage with no
vouching partner is blanked, and a garbled head is damage, not a stop.
2026-09-30 15:50:46 -07:00

7532 lines
315 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Matroska (MKV) muxer.
//!
//! Writes EBML header, Segment with tracks, clusters, and cues.
//! Designed for streaming writes: clusters are written as data arrives,
//! cues and seek head are finalized at the end.
use super::ebml;
// Clip-boundary timeline-continuity corrector — shared verbatim with the
// `demux://` sink.
use super::timeline::TimelineContinuity;
use crate::disc::{
AudioStream, Chapter, Codec, CodecKind, ColorSpace, HdrFormat, SubtitleStream, VideoStream,
};
// Production code reaches resolutions only through `VideoStream::resolution`;
// the fixtures below name the variants directly.
#[cfg(test)]
use crate::disc::Resolution;
use std::io::{self, Seek, Write};
// ── CICP colour codes (ITU-T H.273) ──────────────────────────────────────────
// Matroska's Colour element (RFC 9559) carries Matrix/Transfer/Primaries verbatim as
// ITU-T H.273 CICP code-points; named constants keep each value traceable to the spec table.
/// ColourPrimaries = 1 (BT.709 / sRGB) — ITU-T H.273 Table 2.
const CICP_PRIMARIES_BT709: u8 = 1;
/// ColourPrimaries = 5 (BT.470 System B/G — PAL/SECAM SD) — ITU-T H.273 Table 2.
const CICP_PRIMARIES_BT470BG: u8 = 5;
/// ColourPrimaries = 6 (BT.601-525 / SMPTE 170M — NTSC SD) — ITU-T H.273 Table 2.
const CICP_PRIMARIES_BT601_525: u8 = 6;
/// ColourPrimaries = 9 (BT.2020 / BT.2100) — ITU-T H.273 Table 2.
const CICP_PRIMARIES_BT2020: u8 = 9;
/// ColourPrimaries = 2 ("unspecified" — colorimetry unknown) — ITU-T H.273
/// Table 2.
const CICP_PRIMARIES_UNSPECIFIED: u8 = 2;
/// TransferCharacteristics = 1 (BT.709) — ITU-T H.273 Table 3.
const CICP_TRANSFER_BT709: u8 = 1;
/// TransferCharacteristics = 5 (BT.470 System B/G) — ITU-T H.273 Table 3.
const CICP_TRANSFER_BT470BG: u8 = 5;
/// TransferCharacteristics = 6 (BT.601-525 / SMPTE 170M) — ITU-T H.273 Table 3.
const CICP_TRANSFER_BT601_525: u8 = 6;
/// TransferCharacteristics = 14 (BT.2020 10-bit, SDR) — ITU-T H.273 Table 3.
const CICP_TRANSFER_BT2020_10: u8 = 14;
/// TransferCharacteristics = 16 (SMPTE ST 2084 / PQ — HDR10/HDR10+/DV) — ITU-T
/// H.273 Table 3.
const CICP_TRANSFER_PQ: u8 = 16;
/// TransferCharacteristics = 18 (ARIB STD-B67 / Hybrid Log-Gamma) — ITU-T H.273
/// Table 3.
const CICP_TRANSFER_HLG: u8 = 18;
/// TransferCharacteristics = 2 ("unspecified" — transfer unknown) — ITU-T H.273
/// Table 3.
const CICP_TRANSFER_UNSPECIFIED: u8 = 2;
/// MatrixCoefficients = 1 (BT.709) — ITU-T H.273 Table 4.
const CICP_MATRIX_BT709: u8 = 1;
/// MatrixCoefficients = 5 (BT.470 System B/G) — ITU-T H.273 Table 4.
const CICP_MATRIX_BT470BG: u8 = 5;
/// MatrixCoefficients = 6 (BT.601-525 / SMPTE 170M) — ITU-T H.273 Table 4.
const CICP_MATRIX_BT601_525: u8 = 6;
/// MatrixCoefficients = 9 (BT.2020 non-constant luminance) — ITU-T H.273 Table 4.
const CICP_MATRIX_BT2020NC: u8 = 9;
/// MatrixCoefficients = 2 ("unspecified" — matrix unknown) — ITU-T H.273 Table 4.
const CICP_MATRIX_UNSPECIFIED: u8 = 2;
/// Matroska Colour/Range = 1 (broadcast / studio-swing "limited" range). RFC
/// 9559 Range element. (0 = unspecified, 2 = full.)
const COLOUR_RANGE_LIMITED: u8 = 1;
/// BlockAddIDType "dvcC" — the DOVIDecoderConfigurationRecord fourcc, big-endian
/// ASCII 'd''v''c''C'. Matroska BlockAdditionMapping/BlockAddIDType for a Dolby
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
// BlockAddIDType "mvcC" — MVCDecoderConfigurationRecord fourcc, big-endian ASCII 'm''v''c''C'.
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 (BlockAddIDExtraData for the mvcC mapping). `None` if
// either param set is absent, too short, or too long.
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)
}
// Build the CodecPrivate for an MVC base track: avcc + mvcC extension block (Matroska Codec
// Specifications §4.3.9).
fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec<u8> {
let ext_size = (4 + record.len()) as u32; // "mvcC" (4) + record; = block size − 4
let mut out = Vec::with_capacity(avcc.len() + 8 + record.len());
out.extend_from_slice(avcc);
out.extend_from_slice(&ext_size.to_be_bytes());
out.extend_from_slice(b"mvcC");
out.extend_from_slice(record);
out
}
// Resolve a video stream's CICP colour code points (matrix, transfer, primaries, range) with a
// single precedence shared by every sink so they never drift.
pub(crate) fn cicp_for_video(v: &VideoStream) -> (u8, u8, u8, u8) {
if let Some(c) = v.measured_cicp {
return (c.matrix, c.transfer, c.primaries, c.range);
}
let (m, t, p, r) = match v.color_space {
// SDR BT.2020 (UHD MPLS dynamic_range 0); the HDR override below selects PQ/HLG.
ColorSpace::Bt2020 => (
CICP_MATRIX_BT2020NC,
CICP_TRANSFER_BT2020_10,
CICP_PRIMARIES_BT2020,
COLOUR_RANGE_LIMITED,
),
ColorSpace::Bt709 => (
CICP_MATRIX_BT709,
CICP_TRANSFER_BT709,
CICP_PRIMARIES_BT709,
COLOUR_RANGE_LIMITED,
),
// PAL SD: BT.470 System B/G matrix/transfer/primaries.
ColorSpace::Bt470bg => (
CICP_MATRIX_BT470BG,
CICP_TRANSFER_BT470BG,
CICP_PRIMARIES_BT470BG,
COLOUR_RANGE_LIMITED,
),
// NTSC SD: SMPTE 170M / BT.601-525.
ColorSpace::Smpte170m => (
CICP_MATRIX_BT601_525,
CICP_TRANSFER_BT601_525,
CICP_PRIMARIES_BT601_525,
COLOUR_RANGE_LIMITED,
),
// Unknown colorimetry → CICP "unspecified" (2) for matrix/transfer/primaries,
// limited range (disc norm); MKV and the FVI sidecar both emit 2 so the two
// sinks of one title agree (matches `Colour::from_color_space`'s Unknown mapping).
ColorSpace::Unknown => (
CICP_MATRIX_UNSPECIFIED,
CICP_TRANSFER_UNSPECIFIED,
CICP_PRIMARIES_UNSPECIFIED,
COLOUR_RANGE_LIMITED,
),
};
// Override the transfer for HDR signalled by the HdrFormat (the coarse enum
// can't express PQ/HLG). Only applies on the enum fallback; a measured CICP
// already carries the real transfer and returned above.
let t = match v.hdr {
HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::DolbyVision => CICP_TRANSFER_PQ,
HdrFormat::Hlg => CICP_TRANSFER_HLG,
_ => t,
};
(m, t, p, r)
}
/// MKV track definition (built from disc stream metadata).
pub struct MkvTrack {
pub track_type: u64, // 1=video, 2=audio, 17=subtitle
pub codec_id: &'static str,
pub language: String,
pub name: String, // Track name / label (e.g. "English (Lossless)")
pub codec_private: Option<Vec<u8>>,
pub is_default: bool,
pub is_forced: bool,
// Video-specific
pub pixel_width: u32,
pub pixel_height: u32,
pub default_duration_ns: u64, // nanoseconds per frame (0 = unknown)
pub display_width: u32, // display aspect ratio width (0 = same as pixel)
pub display_height: u32, // display aspect ratio height (0 = same as pixel)
// HDR colour metadata
pub colour_matrix: u8, // MatrixCoefficients (9=bt2020nc)
pub colour_transfer: u8, // TransferCharacteristics (16=smpte2084/PQ)
pub colour_primaries: u8, // Primaries (9=bt2020)
pub colour_range: u8, // Range (1=tv/limited)
// Scan type. `interlaced` drives FlagInterlaced (0x9A): true → 1
// (interlaced), false → 2 (progressive). `field_order` (0x9D) is only
// 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,
pub bit_depth: u8,
// Dolby Vision: the dvcC (DOVIDecoderConfigurationRecord) for the DV layer,
// emitted as a BlockAdditionMapping. `None` for non-DV tracks.
pub dv_config: Option<Vec<u8>>,
/// HDR10 static metadata measured from the bitstream (HEVC SEI), or `None`
/// when the stream carried no HDR10 SEI. Set from the first coded picture's
/// `PictureInfo` at muxer activation (the same deferred path FieldOrder
/// uses), NOT at construction — the SEI is only known once the elementary
/// stream is parsed. When `Some`, the serializer emits MasteringMetadata +
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
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. `None` for non-3D tracks. Set at muxer activation
/// from the dependent stream's parameter sets, never at construction. When
/// `Some`, the per-frame dependent view rides as a `BlockAdditional`. (No
/// `StereoMode`: RFC 9559 assigns none to MVC-in-BlockAdditional.)
pub mvc_params: Option<(Vec<u8>, Vec<u8>)>,
}
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
/// BlockAdditionMapping. For disc Profile 7 dual-layer the base, enhancement,
/// and RPU are all present (lossless FEL/MEL preserved as a second track).
pub fn dolby_vision_config(profile: u8, level: u8, bl_compat_id: u8) -> Vec<u8> {
let mut v = vec![0u8; 24];
v[0] = 1; // dv_version_major
v[1] = 0; // dv_version_minor
// profile(7) | level(6) | rpu_present(1) | el_present(1) | bl_present(1)
v[2] = ((profile & 0x7F) << 1) | ((level >> 5) & 0x01);
v[3] = ((level & 0x1F) << 3) | (1 << 2) | (1 << 1) | 1; // rpu = el = bl = 1
v[4] = (bl_compat_id & 0x0F) << 4;
// v[5..24] reserved = 0
v
}
// Dolby Vision level for a disc Profile 7 title: the base layer is always 3840x2160 (the
// DV-tagged stream is often the 1080p EL), so the level follows the frame rate alone
// (Dolby Vision Profiles and Levels: 6 = 2160p24, 7 = p30, 8 = p48, 9 = p60).
fn dv_level_2160p(num: u32, den: u32) -> u8 {
let (num, den) = (u64::from(num), u64::from(den.max(1)));
if num == 0 {
return 6;
}
[24, 30, 48]
.iter()
.position(|&max| num <= max * den)
.map_or(9, |i| 6 + i as u8)
}
/// SEI chromaticity unit (Rec. ITU-T H.265 D.3.28): `display_primaries_*` and
/// `white_point_*` are in increments of 0.00002. Matroska chromaticity elements
/// are floats in the [0, 1] range, so the conversion is `value * 0.00002`.
const HDR10_CHROMATICITY_UNIT: f64 = 0.00002;
/// SEI luminance unit (Rec. ITU-T H.265 D.3.28): `max/min_display_mastering_
/// luminance` are in increments of 0.0001 cd/m². Matroska Luminance elements are
/// floats in cd/m², so the conversion is `value * 0.0001`.
const HDR10_LUMINANCE_UNIT: f64 = 0.0001;
// Emit the HDR10 static-metadata children of Colour: MasteringMetadata + MaxCLL/MaxFALL. Called
// only when measured from bitstream SEI.
fn write_hdr10<W: Write + Seek>(w: &mut W, h: &crate::mux::codec::Hdr10Metadata) -> io::Result<()> {
let chroma = |v: u16| -> f64 { v as f64 * HDR10_CHROMATICITY_UNIT };
let lum = |v: u32| -> f64 { v as f64 * HDR10_LUMINANCE_UNIT };
let mm_pos = ebml::start_master(w, ebml::MASTERING_METADATA)?;
// SEI index 2 = Red, 0 = Green, 1 = Blue.
ebml::write_float(
w,
ebml::PRIMARY_R_CHROMATICITY_X,
chroma(h.display_primaries_x[2]),
)?;
ebml::write_float(
w,
ebml::PRIMARY_R_CHROMATICITY_Y,
chroma(h.display_primaries_y[2]),
)?;
ebml::write_float(
w,
ebml::PRIMARY_G_CHROMATICITY_X,
chroma(h.display_primaries_x[0]),
)?;
ebml::write_float(
w,
ebml::PRIMARY_G_CHROMATICITY_Y,
chroma(h.display_primaries_y[0]),
)?;
ebml::write_float(
w,
ebml::PRIMARY_B_CHROMATICITY_X,
chroma(h.display_primaries_x[1]),
)?;
ebml::write_float(
w,
ebml::PRIMARY_B_CHROMATICITY_Y,
chroma(h.display_primaries_y[1]),
)?;
ebml::write_float(w, ebml::WHITE_POINT_CHROMATICITY_X, chroma(h.white_point_x))?;
ebml::write_float(w, ebml::WHITE_POINT_CHROMATICITY_Y, chroma(h.white_point_y))?;
ebml::write_float(
w,
ebml::LUMINANCE_MAX,
lum(h.max_display_mastering_luminance),
)?;
ebml::write_float(
w,
ebml::LUMINANCE_MIN,
lum(h.min_display_mastering_luminance),
)?;
ebml::end_master(w, mm_pos)?;
ebml::write_uint(w, ebml::MAX_CLL, h.max_content_light_level as u64)?;
ebml::write_uint(w, ebml::MAX_FALL, h.max_pic_average_light_level as u64)?;
Ok(())
}
// RFC 9559 §12 Language is ISO 639-2; empty means "no language stated" → `und`. Single decision
// point so every scanner doesn't repeat the default.
fn language_or_und(lang: &str) -> String {
if lang.is_empty() {
"und".to_string()
} else {
lang.to_string()
}
}
/// Matroska CodecID for `codec` carried as a `kind` track, or `None` when Matroska has no ID
/// for it here (text subtitles, whose payload is not in Matroska block form; `Unknown`; a codec
/// of another kind). Exhaustive, so a new `Codec` variant must be mapped or refused here.
fn codec_id(codec: Codec, kind: CodecKind) -> Option<&'static str> {
// `A_DTS` is the sole registered ID for the whole DTS family; players tell core/HD-HRA/
// HD-MA apart from the bitstream. Unregistered `A_DTS/MA`/`A_DTS/HR` break strict parsers.
let id = match codec {
Codec::H264 => ebml::CODEC_H264,
Codec::Hevc => ebml::CODEC_HEVC,
Codec::Vc1 => ebml::CODEC_VC1,
Codec::Mpeg2 => ebml::CODEC_MPEG2,
Codec::Mpeg1 => ebml::CODEC_MPEG1,
Codec::Av1 => ebml::CODEC_AV1,
Codec::Ac3 => ebml::CODEC_AC3,
Codec::Ac3Plus => ebml::CODEC_EAC3,
Codec::TrueHd => ebml::CODEC_TRUEHD,
Codec::DtsHdMa | Codec::DtsHdHr | Codec::Dts => ebml::CODEC_DTS,
Codec::Lpcm => ebml::CODEC_PCM_BE,
Codec::Aac => ebml::CODEC_AAC,
Codec::Mp2 => ebml::CODEC_MP2,
Codec::Mp3 => ebml::CODEC_MP3,
Codec::Flac => ebml::CODEC_FLAC,
Codec::Opus => ebml::CODEC_OPUS,
Codec::Pgs => ebml::CODEC_PGS,
Codec::DvdSub => ebml::CODEC_VOBSUB,
Codec::Srt | Codec::Ssa | Codec::Unknown(_) => return None,
};
(codec.kind() == kind).then_some(id)
}
/// Whether `s` has a Matroska CodecID, i.e. whether `mkv://` carries it.
pub(crate) fn is_mappable(s: &crate::disc::Stream) -> bool {
match s {
crate::disc::Stream::Video(v) => codec_id(v.codec, CodecKind::Video).is_some(),
crate::disc::Stream::Audio(a) => codec_id(a.codec, CodecKind::Audio).is_some(),
crate::disc::Stream::Subtitle(t) => codec_id(t.codec, CodecKind::Subtitle).is_some(),
}
}
// Test shorthands for [`MkvTrack::from_stream`] on a stream known to be mappable.
#[cfg(test)]
impl MkvTrack {
pub fn video(v: &VideoStream) -> Self {
Self::try_video(v).expect("mappable video codec")
}
pub fn audio(a: &AudioStream) -> Self {
Self::try_audio(a).expect("mappable audio codec")
}
pub fn subtitle(s: &SubtitleStream) -> Self {
Self::try_subtitle(s).expect("mappable subtitle codec")
}
}
impl MkvTrack {
/// Build the track for a title stream, or `None` when its codec has no Matroska CodecID
/// (the stream is then left out rather than declared under another codec's ID).
pub(crate) fn from_stream(s: &crate::disc::Stream) -> Option<Self> {
match s {
crate::disc::Stream::Video(v) => Self::try_video(v),
crate::disc::Stream::Audio(a) => Self::try_audio(a),
crate::disc::Stream::Subtitle(t) => Self::try_subtitle(t),
}
}
// Video track: language `und`, colour from `cicp_for_video`, and a dvcC
// BlockAdditionMapping when `hdr == DolbyVision`.
fn try_video(v: &VideoStream) -> Option<Self> {
let codec_id = codec_id(v.codec, CodecKind::Video)?;
// Unknown resolution -> `pixels()` reports (0, 0) (no default is fabricated),
// and the writer omits the optional PixelWidth/PixelHeight
// on 0 per RFC 9559 5.1.4.1.28-29.
let (w, h) = v.resolution.pixels().unwrap_or((0, 0));
let (num, den) = v.frame_rate.as_fraction();
let default_duration_ns = if num > 0 {
(1_000_000_000u64 * den as u64) / num as u64
} else {
0
};
// CICP (matrix, transfer, primaries, range) — ITU-T H.273 code points.
// Derived by the single shared resolver so every sink (this muxer, the
// FVI sidecar in `videomap.rs`) reports identical code points.
let (matrix, transfer, primaries, range) = cicp_for_video(v);
// HD/UHD/BD is square-pixel so display == pixel. DVD (720x480/576) is
// anamorphic — coded pixels aren't square, so derive width from the DAR
// (e.g. 720x576 16:9 -> 1024x576) or players show 5:4/3:2 instead of 16:9.
let (display_width, display_height) = match v.display_aspect {
// u64: a caller-supplied ratio must not overflow; an unrepresentable width keeps (w, h).
Some((an, ad)) if an > 0 && ad > 0 && h > 0 => {
let dw = (u64::from(h) * u64::from(an) + u64::from(ad) / 2) / u64::from(ad);
u32::try_from(dw).map_or((w, h), |dw| (dw, h))
}
_ => (w, h),
};
Some(Self {
track_type: ebml::TRACK_TYPE_VIDEO,
codec_id,
language: "und".into(),
name: v.label.clone(),
codec_private: None,
is_default: !v.secondary,
is_forced: false,
pixel_width: w,
pixel_height: h,
default_duration_ns,
display_width,
display_height,
colour_matrix: matrix,
colour_transfer: transfer,
colour_primaries: primaries,
colour_range: range,
interlaced: v.resolution.is_interlaced(),
// FieldOrder is a bitstream property the IFO/MPLS scan can't know, so it
// defaults to UNDETERMINED here; `MkvStream` sets the MEASURED value from
// the first coded picture. Still UNDETERMINED at mux time = logged, never faked.
field_order: ebml::FIELD_ORDER_UNDETERMINED,
// DefaultDecodedFieldDuration DELIBERATELY NOT emitted (0 suppresses it):
// setting it made Windows Explorer report half fps / "Variable" instead of
// "Constant" (a known-good rip omits it); the ES already signals interlace.
field_duration_ns: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
// The DV layer (hdr=DolbyVision) carries the dvcC so the track is
// recognised as Dolby Vision (disc Profile 7 dual-layer).
dv_config: if matches!(v.hdr, HdrFormat::DolbyVision) {
Some(dolby_vision_config(7, dv_level_2160p(num, den), 0))
} else {
None
},
// HDR10 static metadata is measured from the HEVC SEI at mux time, not known
// at construction; the mux stream sets it before the header is written
// (same deferred path FieldOrder uses). `None` here -> omitted unless seen.
hdr10: None,
mvc_params: None,
})
}
// Audio track; every DTS family member maps to the single registered `A_DTS`.
fn try_audio(a: &AudioStream) -> Option<Self> {
let codec_id = codec_id(a.codec, CodecKind::Audio)?;
// Unknown sample rate/channels -> accessors return 0, so the serializer omits
// SamplingFrequency/Channels rather than writing a fabricated 48kHz/6ch value.
let sr = a.sample_rate.hz();
let ch = a.channels.count();
let name = a.label.clone();
Some(Self {
track_type: ebml::TRACK_TYPE_AUDIO,
codec_id,
language: language_or_und(&a.language),
name,
codec_private: None,
is_default: !a.secondary,
is_forced: false,
pixel_width: 0,
pixel_height: 0,
default_duration_ns: 0,
display_width: 0,
display_height: 0,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: sr,
channels: ch,
// Matroska requires BitDepth for `A_PCM/INT/*`; `LpcmParser` always emits 24-bit.
bit_depth: if a.codec == Codec::Lpcm {
super::codec::lpcm::OUTPUT_BIT_DEPTH
} else {
0
},
dv_config: None,
hdr10: None,
mvc_params: None,
})
}
// Subtitle track (PGS or VobSub); `codec_data` (the VobSub `.idx` palette header) becomes
// the CodecPrivate and the forced flag is propagated.
fn try_subtitle(s: &SubtitleStream) -> Option<Self> {
let codec_id = codec_id(s.codec, CodecKind::Subtitle)?;
Some(Self {
track_type: ebml::TRACK_TYPE_SUBTITLE,
codec_id,
language: language_or_und(&s.language),
name: String::new(),
codec_private: s.codec_data.clone(),
is_default: false,
is_forced: s.forced,
pixel_width: 0,
pixel_height: 0,
default_duration_ns: 0,
display_width: 0,
display_height: 0,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
})
}
}
/// Cue point for seeking.
struct CuePoint {
timestamp_ticks: i64, // TimestampScale ticks
track: usize,
cluster_pos: u64, // relative to Segment start
}
/// SeekHead entry that needs its 8-byte SeekPosition back-patched after Cues are written.
struct SeekPositionFixup {
target_id: u32,
value_offset: u64, // absolute file offset of the 8-byte SeekPosition value
}
/// MKV muxer. Call write_frame() for each frame, then finish() at the end.
pub struct MkvMuxer<W: Write + Seek> {
writer: W,
segment_start: u64,
cluster_open: bool,
cluster_pos: u64,
cluster_size_pos: u64,
cluster_ts_ticks: i64,
/// Reusable scratch buffer for assembling ONE BlockGroup before it is written with a single
/// `write_all`; building it in memory means its size is known before it reaches the file,
/// so no `start_master`/ `end_master` back-patch seek is needed. Kept on the muxer so the
/// allocation is made once, not per frame.
block_group_buf: Vec<u8>,
base_pts_ticks: Option<i64>,
/// Last block timecode (TimestampScale ticks, relative to base_pts) written
/// PER TRACK, to enforce strictly-monotonic per-track timestamps —
/// players and decoders reject non-monotonic DTS, and some audio PES PTS land on
/// the same tick (or tick back one from rounding).
last_pts_ticks: std::collections::HashMap<usize, i64>,
/// Per-track-index flag: true if the track is video. The strictly-monotonic
/// block-timestamp nudge must be skipped for EVERY video track, not just
/// track 0 — a title can carry a second video track (e.g. a Dolby Vision
/// enhancement layer at index 1) whose B-frame PTS is just as legitimately
/// non-monotonic. Keying the exemption on track type (not index) keeps that
/// EL's true PTS instead of clobbering it to prev+1ms.
track_is_video: Vec<bool>,
/// Per-track-index flag: true if the track is a subtitle track
/// (`track_type == 17`). A subtitle block must NEVER be written as a bare
/// `SimpleBlock`: subtitle tracks carry no `DefaultDuration`, so without a
/// `BlockDuration` a demuxer has nothing to bound the cue and ffmpeg reports
/// "Timestamps are unset in a packet for stream N" (issue #52). Keyed on
/// track type (mirroring `track_is_video`) so the invariant holds for every
/// subtitle track — including a sparse forced-narrative second track.
track_is_subtitle: Vec<bool>,
/// Per-track-index flag: true for an `S_VOBSUB` track, whose SPU carries its
/// own display duration.
track_is_vobsub: Vec<bool>,
/// Index of the PRIMARY video track — the first track whose type is video.
/// This (not the literal index 0) is the clip-boundary epoch driver: the
/// M2TS/PMT path orders streams by PMT declaration order and may list an
/// audio ES before the video ES, so `streams[0]` is not guaranteed to be
/// the primary video. `None` when the title has no video track (no track
/// drives epochs).
primary_video_track: Option<usize>,
/// Per-track: whether the track declared an `mvcC` BlockAdditionMapping (its
/// `mvc_params` was set at activation). A `BlockAdditional` with BlockAddID=2
/// is only conforming when the track carries the matching mapping, so
/// `write_frame`'s additional is dropped for a track without it (e.g. the
/// dependent view's parameter sets were never captured before activation).
track_has_mvc_mapping: Vec<bool>,
/// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing).
continuity: TimelineContinuity,
cues: Vec<CuePoint>,
frame_count: u64,
/// Frames handed to `write_frame` that were dropped because no cluster was open yet (opens
/// only on a keyframe from the primary video track). The ALL-dropped case is surfaced by
/// `finish()` via `frame_count == 0`, not this counter; a PARTIAL drop is normal and is
/// only logged, not an error — `finish()` is this field's only reader, so keep that log.
dropped_pre_cluster: u64,
/// Ticks the timeline origin sits before the first video keyframe.
origin_lead_ticks: i64,
/// Per VobSub track: file offset of an open-ended block's 3-byte
/// BlockDuration value, and that block's timestamp in ticks.
vobsub_open_end: std::collections::HashMap<usize, (u64, i64)>,
seek_fixups: Vec<SeekPositionFixup>,
/// Absolute file offset of the CUES SeekHead entry (a fixed 21-byte Seek
/// element). When `finish()` writes no Cues element (zero cue points), this
/// entry is overwritten with a Void so the SeekHead carries no pointer to a
/// non-existent / wrong element.
cues_seek_entry_pos: Option<u64>,
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,
/// Byte offset of the DURATION element's 8-byte payload when it was written
/// as a patch-later placeholder — the source supplied no duration (e.g.
/// HD-DVD, whose `.MAP` timemaps are not parsed). `None` when a real duration
/// was written up-front. Back-patched at `finish()` from the muxed timeline.
duration_patch_pos: Option<u64>,
/// Highest block timestamp (TimestampScale ticks) written across all tracks —
/// the muxed runtime, used to back-patch the DURATION placeholder.
max_block_ticks: i64,
/// Timestamp (TimestampScale ticks) of the last video keyframe written on the
/// video track. A non-keyframe frame written as a BlockGroup needs a
/// `ReferenceBlock` so players don't mistake it for a keyframe, and it must
/// reference a keyframe on its OWN track.
///
/// Per track, not global: the ReferenceBlock is emitted for ANY video track,
/// so a single global value made a secondary video track's non-keyframe point
/// at a keyframe on a different track (or at 0, a self-reference, when the
/// primary had not produced one yet). Indexed by `track_idx`.
last_video_keyframe_ticks: Vec<Option<i64>>,
/// 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 first frame's bitstream (AC-3 `acmod`, MPEG audio Layer II header
/// and `mc_header`). 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.
channel_fixups: std::collections::HashMap<usize, ChannelFixup>,
/// Deferred PGS forced-subtitle detection. A PGS subtitle track reserves a
/// 1-byte `FlagForced` value up-front; as its display sets are written, the
/// track is judged forced iff it displayed at least one subtitle and EVERY
/// display set carried the HDMV `forced_on_flag` (a dedicated forced/narrative
/// track). At `finish()` the reserved byte is promoted to 1 for such tracks —
/// so forced subs are flagged even on discs without vendor label metadata.
/// A vendor forced flag is cleared only under the cross-track guard in
/// `finish()` (see `super::codec::pgs::demotable`).
pgs_forced_fixups: std::collections::HashMap<usize, PgsForcedFixup>,
/// Deferred `FlagInterlaced` correction. A video track's scan type is written
/// up-front from the FIRST coded picture, but MPEG-2 `progressive_frame` is a
/// PER-picture flag (§6.3.10): the first picture (a progressive leader/logo on
/// an interlaced feature, or an interlaced leader on a progressive one) can
/// misrepresent the whole title. Each entry records the `FlagInterlaced` byte
/// offset and the `FieldOrder` element's span, then counts progressive vs
/// interlaced pictures across the WHOLE track; at `finish()` the byte is
/// rewritten to the MAJORITY scan (and a now-wrong FieldOrder Void'd).
flag_interlaced_fixups: std::collections::HashMap<usize, FlagInterlacedFixup>,
/// `--log-level 3` opening-frame capture: the first ~100 coded frames per
/// track are written (raw) to a `<output>.opening.bin` side file with a
/// per-frame summary logged, so an opening-GOP / menu / mid-GOP-open issue is
/// diagnosable from a future log without the disc. `None` on normal runs
/// (diag off) — the muxer pays nothing.
opening_capture: Option<crate::diag::OpeningCapture>,
/// Per track: file offset of a Void reserved for a CodecPrivate that was not
/// known at header time (AAC), filled by [`Self::set_codec_private`].
codec_private_reserves: std::collections::HashMap<usize, u64>,
}
// Bytes reserved for a late AAC CodecPrivate: ID (2) + size (1-2) + an ASC of
// up to 12 bytes, all inside one Void so the Tracks size never changes.
const CODEC_PRIVATE_RESERVE: usize = 16;
/// Deferred Channels correction: the track header's `Channels` byte is written up-front from
/// the (unreliable) IFO count; on the first frame the bitstream can describe, it is rewritten
/// from what the stored stream actually carries.
struct ChannelFixup {
/// 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,
/// Which bitstream describes the count.
source: ChannelSource,
}
enum ChannelSource {
/// AC-3 BSI `acmod` + `lfeon`.
Ac3,
/// MPEG audio header `mode`, plus a CRC-verified 13818-3 `mc_header` (§2.5.3.1).
// Not gated on IFO coding mode 3: mode 2 is "MPEG-1 or MPEG-2 without extension bit stream"
// (EP0867877A2), i.e. ext '0' multichannel; §2.5.3.1's mandatory CRC-check is the gate.
MpegLayerII(super::codec::mp2_channels::ChannelTracker),
}
/// Deferred PGS forced-subtitle detection state for one PGS subtitle track.
struct PgsForcedFixup {
/// Absolute file offset of the 1-byte `FlagForced` value in the Tracks element.
value_offset: u64,
/// The value written up-front — the scan/vendor-label flag. Kept so
/// `finish()` can tell a promotion from a demotion and rewrite only the byte
/// that actually changes.
initial_forced: bool,
/// Shared forced-narrative classifier fed the track's display sets. The same
/// type drives the `info`-time forced probe, so both classify identically.
tracker: super::codec::pgs::ForcedTracker,
}
// Deferred FlagInterlaced correction state for one video track: every
// picture's `progressive()` verdict is tallied and the header byte is
// corrected to the majority at `finish()`.
struct FlagInterlacedFixup {
/// Absolute file offset of the 1-byte `FlagInterlaced` value in the Tracks element.
value_offset: u64,
/// `(offset, len)` of the `FieldOrder` element, present only when one was
/// written up-front (interlaced provisional with a determined order). On a
/// demotion to progressive it is overwritten with a Void of identical length.
field_order_span: Option<(u64, u64)>,
/// The value written up-front (`true` = interlaced), so `finish()` rewrites
/// only when the majority actually disagrees.
initial_interlaced: bool,
/// Pictures measured progressive / interlaced across the whole track. Pictures
/// with no scan signal (e.g. H.264/HEVC `CodingTypeOnly`) count toward neither.
progressive_pics: u64,
interlaced_pics: u64,
}
/// One `finish()`-time FlagInterlaced correction, resolved from the whole-stream
/// scan majority.
struct InterlacedRewrite {
/// File offset of the 1-byte FlagInterlaced value to overwrite.
value_offset: u64,
/// The corrected scan (`true` = interlaced) the majority resolved to.
final_interlaced: bool,
/// `(offset, len)` of the up-front FieldOrder element, Void'd on a demotion.
field_order_span: Option<(u64, u64)>,
}
// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns) avoids
// collisions the classic 1 ms scale causes for B-frame-reordered video and TrueHD's 0.833 ms
// AUs.
const TIMESTAMP_SCALE_NS: i64 = 100_000;
// BlockDuration for a durationless PGS block (issue #52): a PGS cue ends at the
// next display set, so a long bound only caps a cue that is already replaced.
const PGS_FALLBACK_BLOCK_DURATION_NS: i64 = 30_000_000_000;
// A VobSub SPU with no stop command ends at the next SPU on its track (patched
// in place), or after this cap. 100_000 ticks keeps the field 3 bytes wide.
const VOBSUB_OPEN_END_TICKS: u64 = 100_000;
// VobSub SP_DCSQ delays count 1024 ticks of the 90 kHz clock.
const SPU_DELAY_NS: u64 = 1024 * 1_000_000_000 / 90_000;
// Display time of a VobSub SPU: the delay of its first STP_DSP (0x02) command,
// walking the SP_DCSQ chain. `None` when the SPU has no stop or is malformed.
fn vobsub_display_ns(spu: &[u8]) -> Option<u64> {
let be16 = |at: usize| -> Option<usize> {
Some(u16::from_be_bytes([*spu.get(at)?, *spu.get(at + 1)?]) as usize)
};
let mut dcsq = be16(2)?;
// Each DCSQ is at least 5 bytes, so a well-formed chain has bounded length.
for _ in 0..spu.len() / 5 + 1 {
let delay = be16(dcsq)? as u64;
let next = be16(dcsq + 2)?;
let mut at = dcsq + 4;
loop {
let args = match *spu.get(at)? {
0x02 => return Some(delay * SPU_DELAY_NS),
0x00 | 0x01 => 0,
0x03 | 0x04 => 2,
0x05 => 6,
0x06 => 4,
0x07 => be16(at + 1)?,
_ => break,
};
at += 1 + args;
}
if next == dcsq {
return None;
}
dcsq = next;
}
None
}
// Nominal new-cluster interval (2 s) in TimestampScale ticks; a keyframe only opens a new
// cluster once this much has elapsed.
const CLUSTER_DURATION_TICKS: i64 = 2_000 * 1_000_000 / TIMESTAMP_SCALE_NS;
// Maximum block-relative timestamp in the signed 16-bit SimpleBlock/Block field; a frame
// outside this forces a new cluster so `as i16` never wraps.
const MAX_BLOCK_REL: i64 = i16::MAX as i64;
/// Nanoseconds to timestamp ticks, at least 1 so a duration never truncates to 0.
fn ns_to_ticks(ns: u64) -> u64 {
(ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64
}
/// Minimum block-relative timestamp expressible in the signed 16-bit field.
const MIN_BLOCK_REL: i64 = i16::MIN as i64;
// Force a per-track block timestamp to be strictly later than `prev` (the track's last
// timestamp, `None` for the first frame); never moves it earlier.
fn monotonic_ts(prev: Option<i64>, pts_ticks: i64) -> i64 {
match prev {
Some(p) => pts_ticks.max(p.saturating_add(1)),
None => pts_ticks,
}
}
// Per-track block timestamp: monotonic nudge applies to AUDIO/SUBTITLE only; video is UNCHANGED
// (B-frame PTS is legitimately non-monotonic), keyed on `is_video` not track index.
fn block_ts(is_video: bool, prev: Option<i64>, pts_ticks: i64) -> i64 {
if is_video {
pts_ticks
} else {
monotonic_ts(prev, pts_ticks)
}
}
// Encode a Matroska track number as an EBML VINT into a stack buffer (no heap alloc; hot path).
// 1/2/3-byte widths per VINT marker bit; handled in RELEASE (not just debug_assert).
fn track_vint(track_num: usize) -> ([u8; 3], usize) {
if track_num < 0x80 {
([(track_num as u8) | 0x80, 0, 0], 1)
} else if track_num < 0x4000 {
([0x40 | ((track_num >> 8) as u8), track_num as u8, 0], 2)
} else {
debug_assert!(
track_num < 0x20_0000,
"track number {track_num} exceeds the 21-bit 3-byte EBML VINT range"
);
(
[
0x20 | ((track_num >> 16) as u8),
(track_num >> 8) as u8,
track_num as u8,
],
3,
)
}
}
impl<W: Write + Seek> MkvMuxer<W> {
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters.
#[cfg(test)]
pub fn new(
writer: W,
tracks: &[MkvTrack],
title: Option<&str>,
duration_secs: f64,
chapters: &[Chapter],
) -> io::Result<Self> {
Self::new_with_timing(writer, tracks, &[], title, duration_secs, chapters)
}
pub(crate) fn new_with_timing(
mut writer: W,
tracks: &[MkvTrack],
timings: &[crate::pes::TrackTiming],
title: Option<&str>,
duration_secs: f64,
chapters: &[Chapter],
) -> io::Result<Self> {
// EBML Header
let ebml_pos = ebml::start_master(&mut writer, ebml::EBML)?;
ebml::write_uint(&mut writer, ebml::EBML_VERSION, 1)?;
ebml::write_uint(&mut writer, ebml::EBML_READ_VERSION, 1)?;
ebml::write_uint(&mut writer, ebml::EBML_MAX_ID_LENGTH, 4)?;
ebml::write_uint(&mut writer, ebml::EBML_MAX_SIZE_LENGTH, 8)?;
ebml::write_string(&mut writer, ebml::EBML_DOC_TYPE, "matroska")?;
ebml::write_uint(&mut writer, ebml::EBML_DOC_TYPE_VERSION, 4)?;
ebml::write_uint(&mut writer, ebml::EBML_DOC_TYPE_READ_VERSION, 2)?;
ebml::end_master(&mut writer, ebml_pos)?;
// Segment (unknown size — we'll write cues at the end)
ebml::write_id(&mut writer, ebml::SEGMENT)?;
ebml::write_unknown_size(&mut writer)?;
let segment_start = writer.stream_position()?;
// SeekHead with fixed-width SeekPosition placeholders. Order: Info, Tracks, [Chapters], Cues.
let mut seek_fixups: Vec<SeekPositionFixup> = Vec::new();
let seekhead_pos = ebml::start_master(&mut writer, ebml::SEEK_HEAD)?;
let mut targets: Vec<u32> = vec![ebml::INFO, ebml::TRACKS];
if !chapters.is_empty() {
targets.push(ebml::CHAPTERS);
}
targets.push(ebml::CUES);
let seek_id_be = (ebml::SEEK as u16).to_be_bytes();
let seek_inner_id_be = (ebml::SEEK_ID as u16).to_be_bytes();
let seek_pos_id_be = (ebml::SEEK_POSITION as u16).to_be_bytes();
// Offset of the CUES Seek entry, so that if no Cues element is written (zero
// cue points) it can be overwritten with a Void at finish() instead of a
// SeekHead pointer resolving to whatever element happens to land there.
let mut cues_seek_entry_pos: Option<u64> = None;
for target_id in &targets {
let entry_pos = writer.stream_position()?;
if *target_id == ebml::CUES {
cues_seek_entry_pos = Some(entry_pos);
}
writer.write_all(&[seek_id_be[0], seek_id_be[1], 0x92])?;
writer.write_all(&[seek_inner_id_be[0], seek_inner_id_be[1], 0x84])?;
writer.write_all(&target_id.to_be_bytes())?;
writer.write_all(&[seek_pos_id_be[0], seek_pos_id_be[1], 0x88])?;
let value_offset = writer.stream_position()?;
writer.write_all(&[0u8; 8])?;
seek_fixups.push(SeekPositionFixup {
target_id: *target_id,
value_offset,
});
}
ebml::end_master(&mut writer, seekhead_pos)?;
// Info
let info_start = writer.stream_position()?;
let info_offset = info_start - segment_start;
let info_pos = ebml::start_master(&mut writer, ebml::INFO)?;
ebml::write_uint(
&mut writer,
ebml::TIMESTAMP_SCALE,
TIMESTAMP_SCALE_NS as u64,
)?;
let duration_patch_pos = if duration_secs > 0.0 {
// Duration is expressed in TimestampScale ticks (not ms).
let duration_ticks = duration_secs * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64;
ebml::write_float(&mut writer, ebml::DURATION, duration_ticks)?;
None
} else {
// No declared duration (e.g. HD-DVD `.MAP` timemaps aren't parsed): reserve
// a DURATION placeholder and back-patch it at finish() from the muxed
// timeline. Payload sits 3 bytes in (2-byte ID `0x4489` + 1-byte size).
let pos = writer.stream_position()?;
ebml::write_float(&mut writer, ebml::DURATION, 0.0)?;
Some(pos + 3)
};
// Stamp the freemkv version so any muxed file is traceable to the build
// that produced it (surfaced as a media analyzer's "Writing
// application"/"library" field).
ebml::write_string(&mut writer, ebml::MUXING_APP, crate::MUX_APP)?;
ebml::write_string(&mut writer, ebml::WRITING_APP, crate::MUX_APP)?;
if let Some(t) = title {
ebml::write_string(&mut writer, ebml::TITLE, t)?;
}
ebml::end_master(&mut writer, info_pos)?;
// Tracks
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 channel_fixups: std::collections::HashMap<usize, ChannelFixup> =
std::collections::HashMap::new();
let mut pgs_forced_fixups: std::collections::HashMap<usize, PgsForcedFixup> =
std::collections::HashMap::new();
let mut codec_private_reserves: std::collections::HashMap<usize, u64> =
std::collections::HashMap::new();
let mut flag_interlaced_fixups: std::collections::HashMap<usize, FlagInterlacedFixup> =
std::collections::HashMap::new();
// Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping.
// Filled below from the SAME built record that drives the CodecPrivate
// mvcC extension, so the three MVC signals never diverge.
let mut track_has_mvc_mapping: Vec<bool> = Vec::with_capacity(tracks.len());
for (i, track) in tracks.iter().enumerate() {
let track_uid = (i + 1) as u64 | 0x100_0000;
track_uids.push(track_uid);
// Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per track;
// `None` for non-3D or malformed params. Single source of truth for the
// CodecPrivate mvcC extension, BlockAdditionMapping, and conformance flag.
let mvc_record = track
.mvc_params
.as_ref()
.and_then(|(sps, pps)| mvc_decoder_config_record(sps, pps));
track_has_mvc_mapping.push(mvc_record.is_some());
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, track_uid)?;
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
if let Some(timing) = timings.get(i) {
if timing.codec_delay_ns > 0 {
ebml::write_uint(&mut writer, ebml::CODEC_DELAY, timing.codec_delay_ns)?;
}
if timing.seek_preroll_ns > 0 {
ebml::write_uint(&mut writer, ebml::SEEK_PRE_ROLL, timing.seek_preroll_ns)?;
}
}
if mvc_record.is_some() {
ebml::write_uint(
&mut writer,
ebml::MAX_BLOCK_ADDITION_ID,
BLOCK_ADD_ID_VALUE_MVC,
)?;
}
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
ebml::write_string(&mut writer, ebml::LANGUAGE, &track.language)?;
if !track.name.is_empty() {
ebml::write_string(&mut writer, ebml::TRACK_NAME, &track.name)?;
}
if !track.is_default {
ebml::write_uint(&mut writer, ebml::FLAG_DEFAULT, 0)?;
}
if track.track_type == ebml::TRACK_TYPE_SUBTITLE && track.codec_id == ebml::CODEC_PGS {
// Reserve a 1-byte FlagForced (initial = scan/vendor flag) and record
// its offset, so PGS content can promote it to 1 at finish() if the
// track proves forced narrative — same patchable-byte idiom as Channels.
ebml::write_id(&mut writer, ebml::FLAG_FORCED)?;
ebml::write_size(&mut writer, 1)?;
let value_offset = writer.stream_position()?;
writer.write_all(&[track.is_forced as u8])?;
pgs_forced_fixups.insert(
i,
PgsForcedFixup {
value_offset,
initial_forced: track.is_forced,
tracker: super::codec::pgs::ForcedTracker::new(),
},
);
} else if track.is_forced {
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
}
// A_PCM defines no CodecPrivate; the LPCM parser's is BD re-mux metadata.
if let Some(ref cp) = track.codec_private
&& track.codec_id != ebml::CODEC_PCM_BE
{
match mvc_record.as_ref() {
// MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC + `mvcC`
// extension, the track-level signal decoders read to recognise the
// stereoscopic track (dependent view rides in BlockAdditional below).
Some(record) => {
let cp_mvc = mvc_codec_private(cp, record);
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &cp_mvc)?;
}
// Non-MVC (2D/UHD/audio/…): write codec_private verbatim, unchanged.
None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?,
}
} else if track.codec_id == ebml::CODEC_AAC {
codec_private_reserves.insert(i, writer.stream_position()?);
writer.write_all(&ebml::void_element(CODEC_PRIVATE_RESERVE)?)?;
}
// Pre-0.13's deferred codecPrivate path was removed as dead code.
// DefaultDuration — frame duration in nanoseconds
if track.default_duration_ns > 0 {
ebml::write_uint(
&mut writer,
ebml::DEFAULT_DURATION,
track.default_duration_ns,
)?;
}
// DefaultDecodedFieldDuration: production video always passes 0 here
// (emitting it made Windows Explorer report half fps / VFR, see
// `MkvTrack::video`); the guard still emits a valid element for other callers.
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)?;
ebml::write_uint(&mut writer, ebml::PIXEL_WIDTH, track.pixel_width as u64)?;
ebml::write_uint(&mut writer, ebml::PIXEL_HEIGHT, track.pixel_height as u64)?;
// FlagInterlaced (1=interlaced, 2=progressive) and FieldOrder are
// written by hand (id+size+value) so their file offsets can be
// captured for the whole-stream majority correction at finish().
ebml::write_id(&mut writer, ebml::FLAG_INTERLACED)?;
ebml::write_size(&mut writer, 1)?;
let flag_offset = writer.stream_position()?;
writer.write_all(&[if track.interlaced {
ebml::INTERLACED_INTERLACED as u8
} else {
ebml::INTERLACED_PROGRESSIVE as u8
}])?;
let field_order_span =
if track.interlaced && track.field_order != ebml::FIELD_ORDER_UNDETERMINED {
// `track.field_order` was set correctly before construction (the mux
// stream reads the first coded picture's measured field order).
let start = writer.stream_position()?;
ebml::write_uint(&mut writer, ebml::FIELD_ORDER, track.field_order as u64)?;
Some((start, writer.stream_position()? - start))
} else {
None
};
flag_interlaced_fixups.insert(
i,
FlagInterlacedFixup {
value_offset: flag_offset,
field_order_span,
initial_interlaced: track.interlaced,
progressive_pics: 0,
interlaced_pics: 0,
},
);
if track.display_width > 0 && track.display_height > 0 {
ebml::write_uint(&mut writer, ebml::DISPLAY_WIDTH, track.display_width as u64)?;
ebml::write_uint(
&mut writer,
ebml::DISPLAY_HEIGHT,
track.display_height as u64,
)?;
}
// Colour metadata (HDR). Open the Colour master when the track
// carries CICP signalling OR measured HDR10 static metadata.
if track.colour_matrix > 0 || track.colour_transfer > 0 || track.hdr10.is_some() {
let col_pos = ebml::start_master(&mut writer, ebml::COLOUR)?;
ebml::write_uint(
&mut writer,
ebml::MATRIX_COEFFICIENTS,
track.colour_matrix as u64,
)?;
ebml::write_uint(
&mut writer,
ebml::TRANSFER_CHARACTERISTICS,
track.colour_transfer as u64,
)?;
ebml::write_uint(&mut writer, ebml::PRIMARIES, track.colour_primaries as u64)?;
ebml::write_uint(&mut writer, ebml::RANGE, track.colour_range as u64)?;
// HDR10 static metadata — emitted ONLY when measured from the
// bitstream SEI (never fabricated for SDR).
if let Some(h) = track.hdr10 {
write_hdr10(&mut writer, &h)?;
}
ebml::end_master(&mut writer, col_pos)?;
}
ebml::end_master(&mut writer, vid_pos)?;
}
// Blu-ray 3D (MVC) signaling: BlockAdditionMapping carries the mvcC record
// so players recognise the dependent (right-eye) view riding as a
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
match (mvc_record.as_ref(), track.mvc_params.as_ref()) {
(Some(record), _) => {
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)?;
}
// `mvc_params` present but the record failed to build (malformed params):
// no mapping, `track_has_mvc_mapping` is already `false`, so
// BlockAdditionals are dropped — file stays conforming, no orphan BlockAddID.
(None, Some((s, p))) => {
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.",
s.len(),
p.len(),
);
}
(None, None) => {}
}
// Dolby Vision signaling — BlockAdditionMapping is a child of the
// TrackEntry (sibling of Video). Carries the dvcC so players /
// analyzers recognise the track as Dolby Vision.
if let Some(ref dvcc) = track.dv_config {
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
// BlockAddIDType = "dvcC" fourcc (DOVIDecoderConfigurationRecord).
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_DVCC)?;
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, dvcc)?;
ebml::end_master(&mut writer, map_pos)?;
}
// Audio-specific
if track.track_type == ebml::TRACK_TYPE_AUDIO && track.sample_rate > 0.0 {
let aud_pos = ebml::start_master(&mut writer, ebml::AUDIO)?;
ebml::write_float(&mut writer, ebml::SAMPLING_FREQUENCY, track.sample_rate)?;
// Omit Channels when unknown (0) — Matroska defaults it to 1
// rather than us fabricating a 6-channel count.
if track.channels > 0 {
// Capture the offset of the 1-byte Channels value so an AC-3 or MP2 track
// can correct it from its bitstream (the IFO count is unreliable); written
// explicitly so the in-place single-byte rewrite stays valid.
ebml::write_id(&mut writer, ebml::CHANNELS)?;
ebml::write_size(&mut writer, 1)?;
let value_offset = writer.stream_position()?;
writer.write_all(&[track.channels])?;
let source = match track.codec_id {
ebml::CODEC_AC3 => Some(ChannelSource::Ac3),
ebml::CODEC_MP2 => Some(ChannelSource::MpegLayerII(Default::default())),
_ => None,
};
if let Some(source) = source {
channel_fixups.insert(
i,
ChannelFixup {
value_offset,
claimed: track.channels,
corrected: false,
source,
},
);
}
}
if track.bit_depth > 0 {
ebml::write_uint(&mut writer, ebml::BIT_DEPTH, track.bit_depth as u64)?;
}
ebml::end_master(&mut writer, aud_pos)?;
}
ebml::end_master(&mut writer, entry_pos)?;
}
ebml::end_master(&mut writer, tracks_pos)?;
// Chapters
let mut chapters_offset: Option<u64> = None;
if !chapters.is_empty() {
let chapters_start = writer.stream_position()?;
chapters_offset = Some(chapters_start - segment_start);
let chapters_pos = ebml::start_master(&mut writer, ebml::CHAPTERS)?;
let edition_pos = ebml::start_master(&mut writer, ebml::EDITION_ENTRY)?;
for (i, ch) in chapters.iter().enumerate() {
let atom_pos = ebml::start_master(&mut writer, ebml::CHAPTER_ATOM)?;
ebml::write_uint(&mut writer, ebml::CHAPTER_UID, (i + 1) as u64)?;
let time_ns = (ch.time_secs * 1_000_000_000.0) as u64;
ebml::write_uint(&mut writer, ebml::CHAPTER_TIME_START, time_ns)?;
let display_pos = ebml::start_master(&mut writer, ebml::CHAPTER_DISPLAY)?;
ebml::write_string(&mut writer, ebml::CHAP_STRING, &ch.name)?;
ebml::write_string(&mut writer, ebml::CHAP_LANGUAGE, "und")?;
ebml::end_master(&mut writer, display_pos)?;
ebml::end_master(&mut writer, atom_pos)?;
}
ebml::end_master(&mut writer, edition_pos)?;
ebml::end_master(&mut writer, chapters_pos)?;
}
Ok(Self {
writer,
segment_start,
cluster_open: false,
cluster_pos: 0,
cluster_size_pos: 0,
block_group_buf: Vec::new(),
cluster_ts_ticks: 0,
base_pts_ticks: None,
last_pts_ticks: std::collections::HashMap::new(),
track_is_video: tracks
.iter()
.map(|t| t.track_type == ebml::TRACK_TYPE_VIDEO)
.collect(),
track_is_subtitle: tracks
.iter()
.map(|t| t.track_type == ebml::TRACK_TYPE_SUBTITLE)
.collect(),
track_is_vobsub: tracks
.iter()
.map(|t| t.codec_id == ebml::CODEC_VOBSUB)
.collect(),
primary_video_track: tracks
.iter()
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
track_has_mvc_mapping,
continuity: TimelineContinuity::new(),
cues: Vec::new(),
frame_count: 0,
dropped_pre_cluster: 0,
origin_lead_ticks: 0,
vobsub_open_end: std::collections::HashMap::new(),
seek_fixups,
cues_seek_entry_pos,
info_offset,
tracks_offset,
chapters_offset,
track_bytes: vec![0u64; tracks.len()],
track_uids,
duration_secs,
duration_patch_pos,
max_block_ticks: 0,
last_video_keyframe_ticks: vec![None; tracks.len()],
channel_fixups,
pgs_forced_fixups,
flag_interlaced_fixups,
opening_capture: None,
codec_private_reserves,
})
}
/// Fill a CodecPrivate reserved at header time. `Ok(false)` when the track
/// had no reservation (already set, or not AAC) or `cp` does not fit.
pub(crate) fn set_codec_private(&mut self, track: usize, cp: &[u8]) -> io::Result<bool> {
let Some(&pos) = self.codec_private_reserves.get(&track) else {
return Ok(false);
};
let mut rest = match CODEC_PRIVATE_RESERVE.checked_sub(3 + cp.len()) {
Some(r) => r,
None => return Ok(false),
};
let mut el = Vec::with_capacity(CODEC_PRIVATE_RESERVE);
ebml::write_id(&mut el, ebml::CODEC_PRIVATE)?;
// A 1-byte remainder cannot hold a Void, so widen the size VINT instead.
if rest == 1 {
el.extend_from_slice(&[0x40, cp.len() as u8]);
rest = 0;
} else {
ebml::write_size(&mut el, cp.len() as u64)?;
}
el.extend_from_slice(cp);
if rest >= 2 {
el.extend_from_slice(&ebml::void_element(rest)?);
}
let here = self.writer.stream_position()?;
self.writer.seek(std::io::SeekFrom::Start(pos))?;
self.writer.write_all(&el)?;
self.writer.seek(std::io::SeekFrom::Start(here))?;
self.codec_private_reserves.remove(&track);
Ok(true)
}
// Overwrite a 3-byte big-endian value at `pos`, then return to the end.
fn patch_u24(&mut self, pos: u64, val: u64) -> io::Result<()> {
let end = self.writer.stream_position()?;
self.writer.seek(io::SeekFrom::Start(pos))?;
self.writer.write_all(&val.to_be_bytes()[5..])?;
self.writer.seek(io::SeekFrom::Start(end))?;
Ok(())
}
/// Put the timeline origin `lead_ns` before the first video keyframe (the
/// earliest audio/video frame), and count `dropped` frames that lay before it.
pub(crate) fn set_origin_lead_ns(&mut self, lead_ns: i64, dropped: u64) {
self.origin_lead_ticks = lead_ns.max(0) / TIMESTAMP_SCALE_NS;
self.dropped_pre_cluster += dropped;
}
/// Attach an opening-frame capture (`--log-level 3`). The capture writes the
/// first ~100 coded frames per track to `<output>.opening.bin` and logs a
/// per-frame summary, so opening-GOP / menu issues are diagnosable from a
/// log + side file without the disc. `None` is a no-op (normal runs).
pub(crate) fn set_opening_capture(&mut self, capture: Option<crate::diag::OpeningCapture>) {
self.opening_capture = capture;
}
/// Drive seam correction from the title's PlayItem marks instead of inferring it from PTS
/// jumps. Each clip is placed at the sum of the earlier clips' durations, so output runs
/// exactly as long as the playlist says. No-op for fewer than two clips or without usable
/// marks — DVD, HD-DVD and file sources keep the inference path.
pub(crate) fn set_clips(
&mut self,
clips: &[crate::disc::Clip],
content_format: crate::disc::ContentFormat,
) {
self.continuity = TimelineContinuity::with_clips(clips, content_format);
}
/// Write a single frame. `duration_ns = Some` emits a `BlockGroup` with
/// `BlockDuration` (e.g. PGS subtitles, so the on-screen bitmap is
/// removed at the right time); otherwise a plain `SimpleBlock`.
///
/// TEST-ONLY wrapper over [`MkvMuxer::write_frame_at`] — production
/// calls `write_frame_at_with_padding` to preserve container trimming.
#[cfg(test)]
pub fn write_frame(
&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_at(
track_idx,
pts_ns,
keyframe,
data,
duration_ns,
block_additional,
None,
None,
)
}
/// `write_frame` with the frame's SOURCE BYTE OFFSET, used to identify
/// which clip it came from under a seam plan (ambiguous by timestamp
/// alone inside an overlap). Pass `frame.source.map(|s| s.byte)`.
///
/// `block_additional`, when `Some`, is a Matroska `BlockAdditional` (BlockAddID=2) for
/// Blu-ray 3D MVC dependent-view data; such a frame is always a `BlockGroup` with a
/// `ReferenceBlock` when not a keyframe. `None` for non-3D.
#[allow(clippy::too_many_arguments)]
#[cfg(test)]
pub fn write_frame_at(
&mut self,
track_idx: usize,
pts_ns: i64,
keyframe: bool,
data: &[u8],
duration_ns: Option<u64>,
block_additional: Option<&[u8]>,
src_byte: Option<u64>,
scan_progressive: Option<bool>,
) -> io::Result<()> {
self.write_frame_at_with_padding(
track_idx,
pts_ns,
keyframe,
data,
duration_ns,
block_additional,
src_byte,
scan_progressive,
0,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn write_frame_at_with_padding(
&mut self,
track_idx: usize,
pts_ns: i64,
keyframe: bool,
data: &[u8],
duration_ns: Option<u64>,
block_additional: Option<&[u8]>,
src_byte: Option<u64>,
scan_progressive: Option<bool>,
discard_padding_ns: i64,
) -> io::Result<()> {
// --log-level 3: capture the first ~100 coded frames per track to the side file
// BEFORE any timeline mangling, with the parser's own PTS, so an opening-GOP
// issue is reconstructable offline. No-op/no alloc normally (capture = `None`).
if let Some(cap) = self.opening_capture.as_mut() {
cap.record(track_idx, pts_ns, keyframe, data);
}
// A block for an undeclared TrackNumber would make the file inconsistent.
if track_idx >= self.track_uids.len() {
return Err(crate::error::Error::MuxTrackRange {
track: track_idx,
tracks: self.track_uids.len(),
}
.into());
}
// Is this a video track? Used for the monotonic block-timestamp nudge
// below, which must exempt EVERY video track (incl. a Dolby Vision EL).
let is_video = self.track_is_video.get(track_idx).copied().unwrap_or(false);
// Clip-boundary epochs are driven by the PRIMARY video track only (not literal
// index 0 — M2TS/PMT can list audio first): a Dolby Vision EL's overlapping PTS
// would false-trigger a reset every GOP, inflating a 1-clip title to ~7h.
let drives_epoch = Some(track_idx) == self.primary_video_track;
// Map the raw PES PTS onto the continuous timeline FIRST: source PTS jumps
// backward at a clip boundary, so rebasing avoids non-monotonic timestamps.
// `None` = outside clip IN/OUT marks; dropping avoids duplicate content at a join.
let Some(pts_ns) = self.continuity.map(
pts_ns,
drives_epoch,
track_idx,
self.track_is_video.get(track_idx).copied().unwrap_or(false),
src_byte,
) else {
return Ok(());
};
// Tally this KEPT picture's measured scan type for the whole-stream
// FlagInterlaced majority (dropped-by-mark frames must not count). A
// picture with no scan signal (`None`) counts toward neither.
if let Some(prog) = scan_progressive
&& let Some(fixup) = self.flag_interlaced_fixups.get_mut(&track_idx)
{
if prog {
fixup.progressive_pics += 1;
} else {
fixup.interlaced_pics += 1;
}
}
let raw_ticks = pts_ns / TIMESTAMP_SCALE_NS;
// Clusters normally open on a video keyframe so Cues resolve to a seekable
// IDR. For an audio/subtitle-only title (no video track) fall back to track
// 0's keyframes, or no cluster would ever open and every frame would drop.
let cluster_driver = self.primary_video_track.unwrap_or(0);
let is_video_key = keyframe && track_idx == cluster_driver;
// Base is the first *kept* keyframe, NOT the first frame seen: with B-frame
// reordering the first frame seen can have a higher PTS, which would make
// later timestamps negative and wrap to ~u64::MAX on the `as u64` cast.
let base = match self.base_pts_ticks {
Some(b) => b,
None => {
if !is_video_key {
// No cluster can open yet (clusters start on a track-0
// keyframe). Drop this frame as before, but count it so an
// all-dropped run surfaces as an error at finish().
self.dropped_pre_cluster += 1;
return Ok(());
}
let base = raw_ticks - self.origin_lead_ticks;
self.base_pts_ticks = Some(base);
base
}
};
// Floor at 0: a frame earlier than base (pre-keyframe audio, or a stream
// discontinuity back-jump) would compute negative and wrap to ~u64::MAX on
// the `as u64` write; clamp to t=0 instead of corrupting the timeline.
let pts_ticks = (raw_ticks - base).max(0);
// Strictly-monotonic timestamps — AUDIO/SUBTITLE ONLY: nudge a truncated/
// backward PES tick to prev+1 (inaudible). VIDEO is EXEMPT: B-frame PTS is
// legitimately non-monotonic and forcing it makes decoders reject the DTS.
let pts_ticks = block_ts(
is_video,
self.last_pts_ticks.get(&track_idx).copied(),
pts_ticks,
);
let needs_new_cluster = !self.cluster_open
|| (is_video_key && (pts_ticks - self.cluster_ts_ticks) >= CLUSTER_DURATION_TICKS);
if needs_new_cluster {
if !is_video_key {
// A cluster is open but this non-keyframe wants a fresh one only
// because !cluster_open is false here — so this branch is the
// "no cluster open and not a keyframe" case. Drop and count.
if !self.cluster_open {
self.dropped_pre_cluster += 1;
}
return Ok(());
}
self.start_cluster(pts_ticks)?;
self.cues.push(CuePoint {
timestamp_ticks: pts_ticks,
track: track_idx + 1,
cluster_pos: self.cluster_pos - self.segment_start,
});
} else {
let rel = pts_ticks - self.cluster_ts_ticks;
if !(MIN_BLOCK_REL..=MAX_BLOCK_REL).contains(&rel) {
// Block-relative timestamp is signed 16-bit (~±3.27s at 0.1ms scale); a
// long GOP/audio stretch or PTS back-jump can wrap it, so force a fresh
// cluster, with a Cue entry if this is a keyframe.
self.start_cluster(pts_ticks)?;
if keyframe {
self.cues.push(CuePoint {
timestamp_ticks: pts_ticks,
track: track_idx + 1,
cluster_pos: self.cluster_pos - self.segment_start,
});
}
}
}
// Committed to writing this frame — record its (monotonic) timestamp so
// the next block on this track is forced strictly later.
self.last_pts_ticks.insert(track_idx, pts_ticks);
// Track the highest block END (start + duration when known), not just start,
// so a back-patched Segment Duration (for a missing source duration) covers
// the final frame's full presentation instead of understating the runtime.
let block_end_ticks = pts_ticks + duration_ns.map_or(0, |d| ns_to_ticks(d) as i64);
self.max_block_ticks = self.max_block_ticks.max(block_end_ticks);
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
let duration_ticks = duration_ns.map(ns_to_ticks);
// Defense in depth (issue #52): a SUBTITLE block must never be a bare
// SimpleBlock (no DefaultDuration => unbounded cue, ffmpeg "Timestamps are
// unset"). Substitute a minimum fallback so it takes the BlockGroup arm.
let is_subtitle = self
.track_is_subtitle
.get(track_idx)
.copied()
.unwrap_or(false);
let is_vobsub = self
.track_is_vobsub
.get(track_idx)
.copied()
.unwrap_or(false);
if is_vobsub && let Some((pos, start)) = self.vobsub_open_end.remove(&track_idx) {
let ticks = ((pts_ticks - start).max(1) as u64).min(VOBSUB_OPEN_END_TICKS);
self.patch_u24(pos, ticks)?;
}
let mut vobsub_open = false;
let duration_ticks = match duration_ticks {
Some(dt) => Some(dt),
None if is_vobsub => match vobsub_display_ns(data).filter(|&ns| ns > 0) {
Some(ns) => Some(ns_to_ticks(ns)),
None => {
vobsub_open = true;
Some(VOBSUB_OPEN_END_TICKS)
}
},
None if is_subtitle => {
Some((PGS_FALLBACK_BLOCK_DURATION_NS / TIMESTAMP_SCALE_NS) as u64)
}
None => None,
};
// A BlockAdditional with BlockAddID=2 conforms only when the track declared
// the matching mvcC mapping; if not (dependent-view params never captured
// before the header was written), drop it and emit a plain block instead.
let block_additional = match block_additional {
Some(a)
if self
.track_has_mvc_mapping
.get(track_idx)
.copied()
.unwrap_or(false) =>
{
Some(a)
}
Some(_) => None,
None => None,
};
// Offset of the referenced keyframe for a non-keyframe BlockGroup: inside a
// BlockGroup the SimpleBlock keyframe bit is reserved/0, so keyframe-ness is
// carried only by presence of a ReferenceBlock (gated to video; falls back to 0).
let reference = if keyframe || !is_video {
None
} else {
Some(
self.last_video_keyframe_ticks
.get(track_idx)
.copied()
.flatten()
.map(|kf| kf - pts_ticks)
.unwrap_or(0),
)
};
if discard_padding_ns != 0 {
let mut buf = std::mem::take(&mut self.block_group_buf);
buf.clear();
let result = Self::build_block_group(
&mut buf,
track_idx + 1,
relative_ts,
data,
reference,
duration_ticks,
block_additional,
discard_padding_ns,
)
.and_then(|()| self.writer.write_all(&buf));
self.block_group_buf = buf;
result?;
} else {
match block_additional {
// MVC: base view Block + dependent-view BlockAdditional, always a BlockGroup;
// non-keyframe base frames get a ReferenceBlock so a player never treats
// a P/B frame as a seek point.
Some(additional) => {
self.write_block_group_mvc(
track_idx + 1,
relative_ts,
data,
additional,
reference,
duration_ticks,
)?;
}
None => match duration_ticks {
// BlockDuration present (PGS subtitles, AC-3 audio, and EVERY
// MPEG-2 video frame) → BlockGroup.
Some(dt) => {
self.write_block_group(track_idx + 1, relative_ts, data, reference, dt)?;
}
None => {
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
}
},
}
}
// BlockDuration is the group's last element: its 3 value bytes end the write.
if vobsub_open {
let end = self.writer.stream_position()?;
self.vobsub_open_end.insert(track_idx, (end - 3, pts_ticks));
}
// Recorded per track (not a single global slot) so a later non-keyframe
// references a keyframe on its OWN track — a shared slot produced cross-track
// references on a multi-video-track title (MVC base+EL, or a two-angle disc).
if keyframe
&& is_video
&& let Some(slot) = self.last_video_keyframe_ticks.get_mut(track_idx)
{
*slot = Some(pts_ticks);
}
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 Channels from the first frame the bitstream describes: the DVD IFO count is
// unreliable (5.1 on a 2.0 AC-3 stream; an MP2 program count on a stereo base layer).
// Byte width is unchanged, so the patch is a single-byte in-place rewrite.
if let Some(fixup) = self.channel_fixups.get_mut(&track_idx)
&& !fixup.corrected
{
let actual = match &mut fixup.source {
ChannelSource::Ac3 => super::codec::ac3::acmod_channels(data).filter(|&c| c > 0),
// A frame that proves nothing leaves the IFO value in place; the tracker settles
// on the first verified frame or after its bounded look (never on a failure).
ChannelSource::MpegLayerII(t) => t.observe(data),
};
// None: frame too short or no valid header; keep the IFO value, retry next frame.
if let Some(actual) = actual {
fixup.corrected = true;
let (offset, claimed) = (fixup.value_offset, fixup.claimed);
self.patch_channels(track_idx, offset, claimed, actual)?;
}
}
// Accumulate PGS forced-subtitle state; `finish()` promotes FlagForced only
// for a track that displayed subtitles and had every one forced.
if let Some(fixup) = self.pgs_forced_fixups.get_mut(&track_idx) {
fixup.tracker.observe(data);
}
Ok(())
}
// Apply deferred in-place byte patches in `finish()`; offsets are disjoint
// fixed-size regions reserved up-front, so nothing shifts. Shared by the
// PGS-forced and FlagInterlaced fixups.
fn patch_bytes(&mut self, patches: &[(u64, Vec<u8>)]) -> io::Result<()> {
if patches.is_empty() {
return Ok(());
}
let here = self.writer.stream_position()?;
for (off, bytes) in patches {
self.writer.seek(std::io::SeekFrom::Start(*off))?;
self.writer.write_all(bytes)?;
}
self.writer.seek(std::io::SeekFrom::Start(here))?;
Ok(())
}
/// Finish the MKV file: write Cues element.
///
/// A cluster only opens on a keyframe from the PRIMARY VIDEO TRACK, not necessarily index 0
/// (see `cluster_driver`). The caller must deliver a keyframe on that track
/// before/alongside other-track data, or every `write_frame` is silently dropped; `finish`
/// then returns `Error::MkvInvalid` rather than emit a structurally valid but empty MKV.
// Rewrites a track's 1-byte Channels value in place when the bitstream disagrees.
fn patch_channels(
&mut self,
track: usize,
offset: u64,
claimed: u8,
actual: u8,
) -> io::Result<()> {
if let Some(ChannelFixup {
source: ChannelSource::MpegLayerII(t),
..
}) = self.channel_fixups.get(&track)
{
let (frames, last) = t.evidence();
let ext = t.extension_signalled();
tracing::debug!(
target: "freemkv::diag",
"tag=mp2.channels track={track} declared={claimed} stored={actual} frames={frames} extension_signalled={ext} last_fallback={last:?}",
);
}
if actual == claimed {
return Ok(());
}
tracing::warn!(
target: "mux",
"audio track {track}: IFO claimed {claimed} channels but the stored bitstream carries {actual}; trusting the bitstream",
);
let here = self.writer.stream_position()?;
self.writer.seek(std::io::SeekFrom::Start(offset))?;
self.writer.write_all(&[actual])?;
self.writer.seek(std::io::SeekFrom::Start(here))?;
Ok(())
}
pub(crate) fn finish(mut self) -> io::Result<()> {
// An MP2 track shorter than the tracker's look settles on its base count now.
let pending: Vec<(usize, u64, u8, u8)> = self
.channel_fixups
.iter_mut()
.filter(|(_, f)| !f.corrected)
.filter_map(|(&i, f)| match &mut f.source {
ChannelSource::MpegLayerII(t) => {
t.finish().map(|n| (i, f.value_offset, f.claimed, n))
}
ChannelSource::Ac3 => None,
})
.collect();
for (track, offset, claimed, actual) in pending {
self.patch_channels(track, offset, claimed, actual)?;
}
// Order matters: a title the seam plan dropped ENTIRELY also has zero frames,
// and `MkvInvalid` is classified as a skippable empty nav/menu stub — so that
// case must be decided FIRST, or a real feature silently drops at exit 0.
if self.frame_count == 0 && self.continuity.dropped_total() > 0 {
return Err(crate::error::Error::SinkWroteNothing.into());
}
if self.frame_count == 0 {
return Err(crate::error::Error::MkvInvalid.into());
}
// Partial pre-cluster drops don't fail the mux (leading audio ahead of the
// first video IDR is normal), but must leave a record — this counter was
// write-only, so frames silently vanished while the run reported success.
if self.dropped_pre_cluster > 0 {
tracing::warn!(
target: "mux",
dropped = self.dropped_pre_cluster,
frames_written = self.frame_count,
driver_track = self.primary_video_track.unwrap_or(0),
"frames were discarded before the first cluster opened (no keyframe on the cluster-driving track had arrived yet); they are absent from the output"
);
}
// Frames excluded by the playlist's clip marks are dropped on purpose (a join
// legitimately discards material a disc stores twice), but the count must not
// be write-only — same reasoning as the pre-cluster counter above.
let seam_dropped = self.continuity.dropped_total();
// Counting isn't bounding: if marks don't line up with the PES clock, the plan
// can discard most of a title, and the only other gate (zero-frame check) is a
// skippable stub — a title emitting seconds of a feature must not exit 0.
if seam_dropped > self.frame_count {
return Err(crate::error::Error::SeamPlanDroppedMost {
dropped: seam_dropped,
written: self.frame_count,
}
.into());
}
if seam_dropped > 0 {
tracing::info!(
target: "mux",
dropped = seam_dropped,
frames_written = self.frame_count,
"frames outside the playlist's clip marks were dropped at clip joins"
);
}
// The source declared no duration up-front (DURATION was reserved as a
// placeholder). Derive the real runtime from the muxed timeline so the
// Segment declares it — and so the BPS tags below can be computed.
if self.duration_patch_pos.is_some() && self.max_block_ticks > 0 {
self.duration_secs =
self.max_block_ticks as f64 * TIMESTAMP_SCALE_NS as f64 / 1_000_000_000.0;
}
// Close final cluster
self.end_cluster()?;
// Correct FlagForced for PGS tracks from what the mux saw (in-place rewrite).
// PROMOTE (0->1) a track whose displays all carry `forced_on_flag`. DEMOTE
// (1->0) a vendor-labelled track that contradicts it, per [`super::codec::pgs::demotable`].
let disc_uses_forced_flag = self
.pgs_forced_fixups
.values()
.any(|f| f.tracker.facts().forced_displays > 0);
let busiest = self
.pgs_forced_fixups
.values()
.map(|f| f.tracker.facts().displays)
.max()
.unwrap_or(0);
let rewrites: Vec<(u64, u8)> = self
.pgs_forced_fixups
.values()
.filter_map(|f| {
if f.tracker.is_forced() && !f.initial_forced {
return Some((f.value_offset, 1u8));
}
if f.initial_forced
&& !f.tracker.is_forced()
&& super::codec::pgs::demotable(
f.tracker.facts(),
disc_uses_forced_flag,
busiest,
)
{
tracing::info!(
target: "mux",
displays = f.tracker.facts().displays,
forced_displays = f.tracker.facts().forced_displays,
busiest,
"PGS track labelled forced showed no forced display sets on a disc that uses the flag; clearing FlagForced"
);
return Some((f.value_offset, 0u8));
}
None
})
.collect();
self.patch_bytes(
&rewrites
.into_iter()
.map(|(off, value)| (off, vec![value]))
.collect::<Vec<_>>(),
)?;
// Correct FlagInterlaced from the WHOLE-stream scan majority: the header
// value came from only the FIRST coded picture. Rewrite the byte to the
// dominant scan; on a demotion to progressive, Void the stale FieldOrder.
let interlaced_rewrites: Vec<InterlacedRewrite> = self
.flag_interlaced_fixups
.values()
.filter_map(|f| {
// No scan signal at all (e.g. H.264/HEVC): keep the provisional value.
if f.progressive_pics == 0 && f.interlaced_pics == 0 {
return None;
}
// Interlaced only on a strict majority — a tie stays progressive so
// a mostly-progressive title is not needlessly deinterlaced.
let final_interlaced = f.interlaced_pics > f.progressive_pics;
if final_interlaced == f.initial_interlaced {
return None; // the first picture already matched the majority
}
Some(InterlacedRewrite {
value_offset: f.value_offset,
final_interlaced,
field_order_span: f.field_order_span,
})
})
.collect();
let mut interlaced_patches: Vec<(u64, Vec<u8>)> = Vec::new();
for rw in &interlaced_rewrites {
let value = if rw.final_interlaced {
ebml::INTERLACED_INTERLACED
} else {
ebml::INTERLACED_PROGRESSIVE
} as u8;
interlaced_patches.push((rw.value_offset, vec![value]));
// Demotion to progressive: the up-front FieldOrder element is now
// contradictory — overwrite it with a same-length Void so no later
// element shifts.
if !rw.final_interlaced
&& let Some((fo_off, fo_len)) = rw.field_order_span
{
interlaced_patches.push((fo_off, ebml::void_element(fo_len as usize)?));
}
tracing::warn!(
target: "mux",
interlaced = rw.final_interlaced,
"FlagInterlaced corrected from the whole-stream scan majority; the first coded picture did not represent the title"
);
}
self.patch_bytes(&interlaced_patches)?;
// Write Cues
let cues_start = self.writer.stream_position()?;
let cues_offset = cues_start - self.segment_start;
let have_cues = !self.cues.is_empty();
if !self.cues.is_empty() {
let cues_pos = ebml::start_master(&mut self.writer, ebml::CUES)?;
for cue in &self.cues {
let cp_pos = ebml::start_master(&mut self.writer, ebml::CUE_POINT)?;
ebml::write_uint(&mut self.writer, ebml::CUE_TIME, cue.timestamp_ticks as u64)?;
let ctp_pos = ebml::start_master(&mut self.writer, ebml::CUE_TRACK_POSITIONS)?;
ebml::write_uint(&mut self.writer, ebml::CUE_TRACK, cue.track as u64)?;
ebml::write_uint(
&mut self.writer,
ebml::CUE_CLUSTER_POSITION,
cue.cluster_pos,
)?;
ebml::end_master(&mut self.writer, ctp_pos)?;
ebml::end_master(&mut self.writer, cp_pos)?;
}
ebml::end_master(&mut self.writer, cues_pos)?;
}
// Per-track BPS statistics tags (mkvmerge convention): a reader that shows
// container `BPS` (Windows Explorer) rather than computing it from stream
// size gets a bitrate for EVERY track this way, not just CBR audio.
self.write_bps_tags()?;
// Back-patch SeekHead SeekPosition values now offsets are known. If no Cues
// were written, skip the CUES fixup (else it'd point at whatever now sits at
// `cues_offset` — Tags/EOF) and Void the whole entry below instead.
for fixup in &self.seek_fixups {
if fixup.target_id == ebml::CUES && !have_cues {
continue;
}
let offset = match fixup.target_id {
ebml::INFO => self.info_offset,
ebml::TRACKS => self.tracks_offset,
// The CHAPTERS entry is reserved only with chapters, which set the offset.
ebml::CHAPTERS => match self.chapters_offset {
Some(off) => off,
None => return Err(crate::error::Error::MkvUnencodable.into()),
},
ebml::CUES => cues_offset,
_ => 0,
};
self.writer
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
self.writer.write_all(&offset.to_be_bytes())?;
}
// Back-patch the DURATION placeholder (source supplied no duration) with
// the real runtime from the muxed timeline. The CUES-void and seek-to-end
// below re-seek absolutely, so no position restore is needed here.
if let Some(pos) = self.duration_patch_pos {
if self.max_block_ticks > 0 {
self.writer.seek(std::io::SeekFrom::Start(pos))?;
self.writer
.write_all(&(self.max_block_ticks as f64).to_be_bytes())?;
} else {
// Timeline never advanced past tick 0 (degenerate single-frame recovery):
// can't derive a runtime, so DON'T leave DURATION=0.0 (reads as corrupt).
// Void the whole 11-byte element instead; `pos` is payload start (+3), back up 3.
self.writer.seek(std::io::SeekFrom::Start(pos - 3))?;
ebml::write_id(&mut self.writer, ebml::VOID)?;
ebml::write_size(&mut self.writer, 9)?; // 11 - 1 (Void id) - 1 (size)
self.writer.write_all(&[0u8; 9])?;
}
}
// Neutralise the unused CUES Seek entry: it's a fixed 21-byte Seek master, and
// a 1-byte Void ID + 1-byte size VINT covering the remaining 19 bytes occupies
// exactly 21 bytes too, overwriting it in place without shifting later elements.
if !have_cues && let Some(entry_pos) = self.cues_seek_entry_pos {
self.writer.seek(std::io::SeekFrom::Start(entry_pos))?;
ebml::write_id(&mut self.writer, ebml::VOID)?;
// 19 = 21-byte entry minus the Void ID (1) and size (1) bytes.
ebml::write_size(&mut self.writer, 19)?;
self.writer.write_all(&[0u8; 19])?;
}
self.writer.seek(std::io::SeekFrom::End(0))?;
self.writer.flush()?;
Ok(())
}
// Write a Tags master with a per-track BPS SimpleTag (bytes*8/duration),
// mirroring mkvmerge so readers like Windows Explorer show a bitrate.
// No-op when duration is unknown 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 {
self.end_cluster()?;
}
self.cluster_pos = self.writer.stream_position()?;
self.cluster_size_pos = ebml::start_master(&mut self.writer, ebml::CLUSTER)?;
ebml::write_uint(&mut self.writer, ebml::CLUSTER_TIMESTAMP, ts_ticks as u64)?;
self.cluster_ts_ticks = ts_ticks;
self.cluster_open = true;
Ok(())
}
fn end_cluster(&mut self) -> io::Result<()> {
if self.cluster_open {
ebml::end_master(&mut self.writer, self.cluster_size_pos)?;
self.cluster_open = false;
}
Ok(())
}
fn write_simple_block(
&mut self,
track_num: usize,
relative_ts: i16,
keyframe: bool,
data: &[u8],
) -> io::Result<()> {
// SimpleBlock: [track_number VINT] [relative_ts i16] [flags u8] [data]
let (tv, tv_len) = track_vint(track_num);
let track_vint = &tv[..tv_len];
let flags: u8 = if keyframe { 0x80 } else { 0x00 };
let block_size = track_vint.len() + 2 + 1 + data.len(); // vint + ts(2) + flags(1) + data
ebml::write_id(&mut self.writer, ebml::SIMPLE_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)?;
Ok(())
}
// Write a BlockGroup (Block + BlockDuration, plus ReferenceBlock when not a keyframe). Not
// subtitle-only: every MPEG-2 I/P/B frame arrives here.
fn write_block_group(
&mut self,
track_num: usize,
relative_ts: i16,
data: &[u8],
reference: Option<i64>,
duration_ticks: u64,
) -> io::Result<()> {
let mut buf = std::mem::take(&mut self.block_group_buf);
buf.clear();
let res = Self::build_block_group(
&mut buf,
track_num,
relative_ts,
data,
reference,
Some(duration_ticks),
None,
0,
)
.and_then(|()| self.writer.write_all(&buf));
// Hand the (now grown) scratch buffer back so the next frame reuses the
// allocation, on the error path too.
self.block_group_buf = buf;
res
}
// Assemble a complete BlockGroup into `buf` (one write_all at the call
// site, no seek). `additional` = Some(dependent AU) for MVC, appending
// BlockAdditions > BlockMore { BlockAddID=2, BlockAdditional }.
#[allow(clippy::too_many_arguments)]
fn build_block_group(
buf: &mut Vec<u8>,
track_num: usize,
relative_ts: i16,
data: &[u8],
reference: Option<i64>,
duration_ticks: Option<u64>,
additional: Option<&[u8]>,
discard_padding_ns: i64,
) -> 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_buf(buf, ebml::BLOCK_GROUP)?;
ebml::write_id(buf, ebml::BLOCK)?;
ebml::write_size(buf, block_size as u64)?;
buf.extend_from_slice(track_vint);
buf.extend_from_slice(&relative_ts.to_be_bytes());
buf.push(flags);
buf.extend_from_slice(data);
if discard_padding_ns != 0 {
ebml::write_int(buf, ebml::DISCARD_PADDING, discard_padding_ns)?;
}
if let Some(dt) = duration_ticks {
ebml::write_uint(buf, ebml::BLOCK_DURATION, dt)?;
}
if let Some(ref_off) = reference {
ebml::write_int(buf, ebml::REFERENCE_BLOCK, ref_off)?;
}
if let Some(additional) = additional {
let adds_pos = ebml::start_master_buf(buf, ebml::BLOCK_ADDITIONS)?;
let more_pos = ebml::start_master_buf(buf, ebml::BLOCK_MORE)?;
ebml::write_uint(buf, ebml::BLOCK_ADD_ID, BLOCK_ADD_ID_VALUE_MVC)?;
ebml::write_binary(buf, ebml::BLOCK_ADDITIONAL, additional)?;
ebml::end_master_buf(buf, more_pos)?;
ebml::end_master_buf(buf, adds_pos)?;
}
ebml::end_master_buf(buf, bg_pos)?;
Ok(())
}
// Write a BlockGroup: base view Block + MVC dependent AU as a BlockAdditional
// (BlockAddID=2) per the track's mvcC mapping.
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 mut buf = std::mem::take(&mut self.block_group_buf);
buf.clear();
let res = Self::build_block_group(
&mut buf,
track_num,
relative_ts,
data,
reference,
duration_ticks,
Some(additional),
0,
)
.and_then(|()| self.writer.write_all(&buf));
self.block_group_buf = buf;
res
}
}
// ============================================================ Helpers.
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
// Keyframe flags must survive a write→read round-trip for frames that carry a per-frame
// DURATION (the BlockGroup path). Regression test.
#[test]
fn keyframe_survives_roundtrip_for_duration_bearing_frames() {
let v = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
frame_rate: crate::disc::FrameRate::F29_97,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt470bg,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
// ── BlockGroup path: every frame carries a duration (the MPEG-2 shape).
let t = MkvTrack::video(&v);
// Write through SharedWriter and call finish() so cluster sizes are back-patched
// (the previous form skipped finish(), leaving sizes unwritten, so this test could
// only byte-scan for a ReferenceBlock instead of parsing the real structure).
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let mut muxer = MkvMuxer::new(SharedWriter(shared.clone()), &[t], None, 0.0, &[]).unwrap();
let dur = Some(33_366_667u64);
// I, then P, then B — decode order, all duration-bearing.
muxer
.write_frame(0, 0, true, &vec![0xAA; 188_459], dur, None)
.unwrap();
muxer
.write_frame(0, 33_366_667, false, &vec![0xBB; 27_053], dur, None)
.unwrap();
muxer
.write_frame(0, 66_733_334, false, &vec![0xCC; 4_096], dur, None)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// Assert PER BLOCK: keyframe-ness in a BlockGroup is signalled by ABSENCE of
// ReferenceBlock, so the I-frame must carry none and each non-keyframe exactly
// one. A blanket presence check would pass a mutant that emits one on every block.
let groups = all_block_groups(&data);
assert_eq!(
groups.len(),
3,
"three duration-bearing frames → three BlockGroups"
);
assert_eq!(
groups[0].reference, None,
"the I-frame BlockGroup must carry NO ReferenceBlock — that absence IS the keyframe signal"
);
// Offsets are relative to the referenced keyframe, in ticks. The keyframe
// sits at tick 0 here, so each non-keyframe's offset is -(its own timestamp).
for (i, g) in groups.iter().enumerate().skip(1) {
let off = g.reference.unwrap_or_else(|| {
panic!("non-keyframe BlockGroup {i} must carry a ReferenceBlock")
});
assert_eq!(
off,
-(g.rel_ts as i64),
"BlockGroup {i}: ReferenceBlock must point back to the keyframe at tick 0"
);
}
// Inside a BlockGroup the 0x80 keyframe flag is reserved and MUST be 0 for
// every block, keyframe or not — the signal lives in ReferenceBlock alone.
for (i, g) in groups.iter().enumerate() {
assert_eq!(
g.flags & 0x80,
0,
"BlockGroup {i}: the SimpleBlock-only keyframe flag must be clear"
);
}
let mut s = crate::mux::mkvstream::MkvStream::open(Cursor::new(data)).unwrap();
let f0 = crate::pes::Stream::read(&mut s).unwrap().unwrap();
assert!(
f0.keyframe,
"BlockGroup I-frame must read back keyframe=true"
);
let f1 = crate::pes::Stream::read(&mut s).unwrap().unwrap();
assert!(
!f1.keyframe,
"BlockGroup P-frame must read back keyframe=false"
);
let f2 = crate::pes::Stream::read(&mut s).unwrap().unwrap();
assert!(
!f2.keyframe,
"BlockGroup B-frame must read back keyframe=false"
);
}
/// SimpleBlock path (no per-frame duration) — the keyframe bit is
/// authoritative there and must keep round-tripping.
#[test]
fn keyframe_survives_roundtrip_for_simple_blocks() {
let v = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
frame_rate: crate::disc::FrameRate::F29_97,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt470bg,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
let t = MkvTrack::video(&v);
let mut muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &vec![0xAA; 188_459], None, None)
.unwrap();
muxer
.write_frame(0, 33_366_667, false, &vec![0xBB; 27_053], None, None)
.unwrap();
let data = muxer.writer.into_inner();
let mut s = crate::mux::mkvstream::MkvStream::open(Cursor::new(data)).unwrap();
let f0 = crate::pes::Stream::read(&mut s).unwrap().unwrap();
assert!(
f0.keyframe,
"SimpleBlock keyframe must read back keyframe=true"
);
let f1 = crate::pes::Stream::read(&mut s).unwrap().unwrap();
assert!(
!f1.keyframe,
"SimpleBlock non-keyframe must read back keyframe=false"
);
}
// End-to-end through the REAL MkvStream create/write/finish path: audio frames precede the
// first video frame, as the DVD pipeline produces.
#[test]
fn mkvstream_preserves_video_keyframe_after_audio_preroll() {
let v = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
frame_rate: crate::disc::FrameRate::F29_97,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt470bg,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
let a = crate::disc::AudioStream {
pid: 0xBD,
codec: Codec::Ac3,
channels: crate::disc::AudioChannels::Stereo,
language: String::new(),
sample_rate: crate::disc::SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
};
let mut title = crate::disc::DiscTitle::empty();
title.streams.push(crate::disc::Stream::Video(v));
title.streams.push(crate::disc::Stream::Audio(a));
let dir = std::env::temp_dir()
.join("fmkv-test-preroll")
.join(format!("{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("preroll.mkv");
let writer: Box<dyn crate::mux::WriteSeek + Send> =
Box::new(std::fs::File::create(&path).unwrap());
let mut s = crate::mux::mkvstream::MkvStream::create(writer, &title, None).unwrap();
// 5 audio frames arrive first (realistic pre-video buffering).
for i in 0..5u32 {
let f = crate::pes::PesFrame {
discard_padding_ns: 0,
coding: None,
source: None,
track: 1,
pts: i as i64 * 32_000_000,
keyframe: false,
data: vec![0xCC; 768],
duration_ns: None,
};
crate::pes::Stream::write(&mut s, &f).unwrap();
}
// Then the true video keyframe — duration-bearing, as MPEG-2 always is,
// so it is written as a BlockGroup (where the keyframe bit is reserved).
let vf = crate::pes::PesFrame {
discard_padding_ns: 0,
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: true,
data: vec![0xAA; 188_459],
duration_ns: Some(33_366_667),
};
crate::pes::Stream::write(&mut s, &vf).unwrap();
crate::pes::Stream::finish(&mut s).unwrap();
drop(s);
let f = std::fs::File::open(&path).unwrap();
let mut r = crate::mux::mkvstream::MkvStream::open(f).unwrap();
let f0 = crate::pes::Stream::read(&mut r).unwrap().unwrap();
assert_eq!(f0.track, 0, "first readback frame must be track 0 (video)");
assert!(
f0.keyframe,
"video keyframe written with 5 audio frames ahead of it must read back keyframe=true"
);
for i in 0..5 {
let audio = crate::pes::Stream::read(&mut r)
.unwrap()
.expect("buffered audio must survive");
assert_eq!(audio.track, 1);
assert_eq!(audio.pts, i * 32_000_000);
assert_eq!(audio.data, vec![0xCC; 768]);
}
assert!(
crate::pes::Stream::read(&mut r).unwrap().is_none(),
"trigger frame must not be duplicated"
);
std::fs::remove_dir_all(&dir).ok();
}
// Anamorphic DVD: 720x576 PAL flagged 16:9 must write DisplayWidth/Height
// carrying the 16:9 DAR (1024x576), not square-pixel 720x576. Square-pixel
// (`display_aspect == None`) keeps display == pixel.
#[test]
fn video_track_anamorphic_display_aspect() {
let base = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R576i,
frame_rate: crate::disc::FrameRate::F25,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: Some((16, 9)),
secondary: false,
label: String::new(),
measured_cicp: None,
};
let t = MkvTrack::video(&base);
assert_eq!((t.pixel_width, t.pixel_height), (720, 576));
assert_eq!(
(t.display_width, t.display_height),
(1024, 576),
"16:9 anamorphic must emit a 16:9 DAR, not square-pixel 720x576"
);
let square = VideoStream {
display_aspect: None,
..base
};
let t2 = MkvTrack::video(&square);
assert_eq!(
(t2.display_width, t2.display_height),
(720, 576),
"square pixels: display == pixel"
);
}
/// Measured CICP from the bitstream must take precedence over the coarse
/// `color_space` enum. A BT.2020/PQ enum that would otherwise produce
/// (9,16,9) is overridden by a measured BT.709 triplet when present.
#[test]
fn measured_cicp_overrides_color_space_enum() {
let base = VideoStream {
pid: 0xE0,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: crate::disc::FrameRate::F24,
hdr: HdrFormat::Hdr10, // enum/HDR path would force PQ transfer
color_space: ColorSpace::Bt2020,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
// Without a measured triplet: enum + HDR → BT.2020nc / PQ / BT.2020.
let t_enum = MkvTrack::video(&base);
assert_eq!(
(
t_enum.colour_matrix,
t_enum.colour_transfer,
t_enum.colour_primaries
),
(
CICP_MATRIX_BT2020NC,
CICP_TRANSFER_PQ,
CICP_PRIMARIES_BT2020
),
"enum fallback derives BT.2020/PQ"
);
// With a measured BT.709 triplet: the bitstream's value wins outright,
// INCLUDING the transfer (the HDR override does not apply to measured).
let measured = VideoStream {
measured_cicp: Some(crate::disc::MeasuredCicp {
matrix: CICP_MATRIX_BT709,
transfer: CICP_TRANSFER_BT709,
primaries: CICP_PRIMARIES_BT709,
range: COLOUR_RANGE_LIMITED,
}),
..base
};
let t = MkvTrack::video(&measured);
assert_eq!(
(t.colour_matrix, t.colour_transfer, t.colour_primaries),
(CICP_MATRIX_BT709, CICP_TRANSFER_BT709, CICP_PRIMARIES_BT709),
"measured CICP must override the enum, transfer included"
);
}
/// Unknown colorimetry with no measured CICP and no HDR must emit CICP
/// "unspecified" (code point 2) for matrix/transfer/primaries — never 0 — so
/// the MKV sink agrees with the FVI sidecar (`Colour::from_color_space`).
#[test]
fn unknown_color_space_emits_unspecified_cicp() {
let v = VideoStream {
pid: 0xE0,
codec: Codec::Hevc,
resolution: Resolution::R1080p,
frame_rate: crate::disc::FrameRate::F24,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Unknown,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
let t = MkvTrack::video(&v);
assert_eq!(
(
t.colour_matrix,
t.colour_transfer,
t.colour_primaries,
t.colour_range
),
(
CICP_MATRIX_UNSPECIFIED,
CICP_TRANSFER_UNSPECIFIED,
CICP_PRIMARIES_UNSPECIFIED,
COLOUR_RANGE_LIMITED
),
"Unknown colorimetry must emit CICP 'unspecified' (2), not 0"
);
}
/// Helper: search for a 4-byte big-endian EBML ID in a byte slice.
fn find_id(data: &[u8], id: u32) -> Option<usize> {
let bytes = id.to_be_bytes();
// Determine how many leading zero bytes to skip
let start = if bytes[0] != 0 {
0
} else if bytes[1] != 0 {
1
} else if bytes[2] != 0 {
2
} else {
3
};
let needle = &bytes[start..];
data.windows(needle.len()).position(|w| w == needle)
}
fn make_video_track() -> MkvTrack {
MkvTrack {
track_type: ebml::TRACK_TYPE_VIDEO,
codec_id: ebml::CODEC_H264,
language: "und".into(),
name: String::new(),
codec_private: Some(vec![0x00, 0x01, 0x02, 0x03]),
is_default: true,
is_forced: false,
pixel_width: 1920,
pixel_height: 1080,
default_duration_ns: 41708333,
display_width: 1920,
display_height: 1080,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
fn make_audio_track() -> MkvTrack {
MkvTrack {
track_type: ebml::TRACK_TYPE_AUDIO,
codec_id: ebml::CODEC_AC3,
language: "eng".into(),
name: "English".into(),
codec_private: None,
is_default: true,
is_forced: false,
pixel_width: 0,
pixel_height: 0,
default_duration_ns: 0,
display_width: 0,
display_height: 0,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 48000.0,
channels: 6,
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
fn audio_stream(codec: Codec) -> AudioStream {
use crate::disc::{AudioChannels, LabelPurpose, SampleRate};
AudioStream {
pid: 0x1100,
codec,
channels: AudioChannels::Surround51,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
}
}
#[test]
fn lpcm_track_writes_bitdepth_24() {
// `LpcmParser` always emits 24-bit BE PCM; without BitDepth players assume
// 16-bit and decode noise. Check the element is actually written.
let track = MkvTrack::audio(&audio_stream(Codec::Lpcm));
assert_eq!(track.bit_depth, 24);
let mut buf = Cursor::new(Vec::new());
MkvMuxer::new(&mut buf, &[track], None, 0.0, &[]).unwrap();
let bytes = buf.into_inner();
// BitDepth (0x6264), size 1, value 24.
assert!(
bytes.windows(4).any(|w| w == [0x62, 0x64, 0x81, 24]),
"BitDepth=24 element present"
);
}
#[test]
fn lpcm_track_omits_codec_private() {
// The LPCM parser's codec_private is BD re-mux metadata (channel_assignment);
// A_PCM defines no CodecPrivate, so it must not reach the track header.
let mut track = MkvTrack::audio(&audio_stream(Codec::Lpcm));
track.codec_private = Some(vec![0x91]);
let mut buf = Cursor::new(Vec::new());
MkvMuxer::new(&mut buf, &[track], None, 0.0, &[]).unwrap();
let bytes = buf.into_inner();
assert!(
!bytes.windows(2).any(|w| w == [0x63, 0xA2]),
"no CodecPrivate"
);
}
#[test]
fn dts_variants_map_to_registered_a_dts_codec_id() {
// `A_DTS` is the sole registered ID for the whole DTS family; the `/MA` and
// `/HR` suffixes aren't registered and break strict parsers, so every DTS
// variant must emit plain `A_DTS`.
for codec in [Codec::Dts, Codec::DtsHdMa, Codec::DtsHdHr] {
let track = MkvTrack::audio(&audio_stream(codec));
assert_eq!(
track.codec_id, "A_DTS",
"{codec:?} must map to registered codec ID A_DTS, got {}",
track.codec_id
);
}
// Sanity: the non-DTS variants keep their distinct IDs.
assert_eq!(MkvTrack::audio(&audio_stream(Codec::Ac3)).codec_id, "A_AC3");
assert_eq!(
MkvTrack::audio(&audio_stream(Codec::TrueHd)).codec_id,
"A_TRUEHD"
);
}
#[test]
fn every_audio_codec_gets_its_own_registered_codec_id() {
// A CodecID names the payload: falling through to A_AC3 for an unmatched codec
// writes a track declaring AC-3 but carrying something else, which players refuse
// or decode as noise. Every producible codec must map to its own registered ID.
for (codec, want) in [
(Codec::Ac3, "A_AC3"),
(Codec::Ac3Plus, "A_EAC3"),
(Codec::TrueHd, "A_TRUEHD"),
(Codec::Dts, "A_DTS"),
(Codec::DtsHdHr, "A_DTS"),
(Codec::DtsHdMa, "A_DTS"),
(Codec::Lpcm, "A_PCM/INT/BIG"),
(Codec::Aac, "A_AAC"),
(Codec::Mp2, "A_MPEG/L2"),
(Codec::Mp3, "A_MPEG/L3"),
(Codec::Flac, "A_FLAC"),
(Codec::Opus, "A_OPUS"),
] {
let got = MkvTrack::audio(&audio_stream(codec)).codec_id;
assert_eq!(
got, want,
"{codec:?} must declare {want}, got {got} — a wrong CodecID \
mislabels the payload"
);
}
// Every Codec of audio kind must be covered above: a new audio codec
// added to the enum must not silently inherit some other codec's ID.
let covered = [
Codec::Ac3,
Codec::Ac3Plus,
Codec::TrueHd,
Codec::Dts,
Codec::DtsHdHr,
Codec::DtsHdMa,
Codec::Lpcm,
Codec::Aac,
Codec::Mp2,
Codec::Mp3,
Codec::Flac,
Codec::Opus,
];
for codec in covered {
assert_eq!(
codec.kind(),
crate::disc::CodecKind::Audio,
"{codec:?} is listed as audio here but kind() disagrees"
);
}
}
#[test]
fn every_video_codec_gets_its_own_registered_codec_id() {
// Same defect class as the audio counterpart: the catch-all declared
// V_MPEG2 for Codec::Mpeg1 and Codec::Av1, so an AV1 track was announced
// to the decoder as MPEG-2 video.
for (codec, want) in [
(Codec::H264, "V_MPEG4/ISO/AVC"),
(Codec::Hevc, "V_MPEGH/ISO/HEVC"),
(Codec::Vc1, "V_MS/VFW/FOURCC"),
(Codec::Mpeg2, "V_MPEG2"),
(Codec::Mpeg1, "V_MPEG1"),
(Codec::Av1, "V_AV1"),
] {
let v = VideoStream {
pid: 0xE0,
codec,
resolution: Resolution::R1080p,
frame_rate: crate::disc::FrameRate::F24,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
let got = MkvTrack::video(&v).codec_id;
assert_eq!(got, want, "{codec:?} must declare {want}, got {got}");
assert_eq!(
codec.kind(),
crate::disc::CodecKind::Video,
"{codec:?} is listed as video here but kind() disagrees"
);
}
}
#[test]
fn dolby_vision_config_profile7() {
// dvcC for disc Profile 7 dual-layer: version 1.0, profile 7, all of
// bl/el/rpu present. 24 bytes.
let c = dolby_vision_config(7, 6, 0);
assert_eq!(c.len(), 24);
assert_eq!(c[0], 1); // dv_version_major
assert_eq!(c[1], 0); // dv_version_minor
// profile in the top 7 bits of byte 2
assert_eq!(c[2] >> 1, 7, "dv_profile must be 7");
// rpu/el/bl present flags in byte 3 (low 3 bits after level)
assert_eq!(c[3] & 0b0000_0111, 0b0000_0111, "rpu+el+bl all present");
}
#[test]
fn mkv_writes_ebml_header() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, Some("Test"), 120.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// EBML header element ID: 0x1A45DFA3
assert!(data.len() >= 4);
assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]);
}
#[test]
fn mkv_writes_segment() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// Segment element ID: 0x18538067
assert!(
find_id(&data, ebml::SEGMENT).is_some(),
"Segment element not found in output"
);
}
/// FlagInterlaced is written up-front from the FIRST picture, then corrected
/// at finish() to the WHOLE-STREAM scan majority. A progressive leader on an
/// interlaced feature must not misrepresent the title: the header byte is
/// rewritten from progressive (2) to interlaced (1).
#[test]
fn flag_interlaced_is_patched_to_the_whole_stream_scan_majority() {
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
// make_video_track() writes the header PROGRESSIVE (interlaced=false).
let mut muxer = MkvMuxer::new(
SharedWriter(shared.clone()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
// One progressive leader picture, then three interlaced → majority
// interlaced (2:1 the other way would stay progressive on a tie).
muxer
.write_frame_at(
0,
0,
true,
&[0x01, 0x02, 0x03],
None,
None,
None,
Some(true),
)
.unwrap();
for (i, pts) in [40_000_000i64, 80_000_000, 120_000_000].iter().enumerate() {
muxer
.write_frame_at(
0,
*pts,
false,
&[0x04, 0x05, i as u8],
None,
None,
None,
Some(false),
)
.unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// FlagInterlaced is written by hand as id(0x9A) size(0x81) value.
let fi = data
.windows(2)
.position(|w| w == [ebml::FLAG_INTERLACED as u8, 0x81])
.expect("FlagInterlaced element present");
assert_eq!(
data[fi + 2],
ebml::INTERLACED_INTERLACED as u8,
"the up-front progressive flag must be patched to interlaced by the majority"
);
}
#[test]
fn mkv_write_frame_creates_cluster() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None, None)
.unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::CLUSTER).is_some(),
"Cluster element not found after write_frame"
);
}
// Frames dropped before the first cluster opens must be COUNTED, and the count must survive
// to `finish()`. Regression test.
#[test]
fn frames_dropped_before_first_cluster_are_counted() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
// Two non-keyframes arrive before any track-0 keyframe: no cluster can be
// open, so both are dropped.
muxer
.write_frame(0, 0, false, &[0x01; 8], None, None)
.unwrap();
muxer
.write_frame(0, 1_000_000, false, &[0x02; 8], None, None)
.unwrap();
assert_eq!(
muxer.dropped_pre_cluster, 2,
"both pre-cluster frames must be counted, not silently lost"
);
assert_eq!(muxer.frame_count, 0, "neither frame was written");
// A keyframe then opens the cluster and is written; the drop count stands
// so `finish()` can still report it.
muxer
.write_frame(0, 2_000_000, true, &[0x03; 8], None, None)
.unwrap();
assert_eq!(muxer.frame_count, 1);
assert_eq!(
muxer.dropped_pre_cluster, 2,
"the drop count must survive to finish(), which reports it"
);
muxer
.finish()
.expect("a mux with one written frame succeeds");
}
#[test]
fn mkv_finish_writes_cues_element() {
// finish() consumes self and flushes the writer, so use the
// module-level SharedWriter to inspect the buffer afterwards.
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
assert!(
find_id(&data, ebml::CUES).is_some(),
"Cues element (0x1C53BB6B) not found after finish()"
);
}
// When no Cues element is written, the SeekHead must NOT retain a CUES entry that
// back-patches to a dangling offset; finish() Voids it.
#[test]
fn zero_cues_voids_seekhead_entry_no_dangling_pointer() {
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("NoCue"), 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
.unwrap();
// Force the zero-cue branch: drop every cue before finalizing.
let cues_entry_pos = muxer.cues_seek_entry_pos.expect("CUES seek entry recorded");
muxer.cues.clear();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// No Cues element is written.
assert!(
find_id(&data, ebml::CUES).is_none(),
"no Cues element expected when there are zero cue points"
);
// The recorded CUES Seek entry was overwritten with a Void (0xEC) of the
// remaining 19 bytes — it no longer begins a SEEK (0x4DBB) element.
let entry = &data[cues_entry_pos as usize..cues_entry_pos as usize + 2];
assert_eq!(
entry,
&[ebml::VOID as u8, 0x80 | 19],
"CUES Seek entry must be Void(19), not a live Seek pointer"
);
// Defensively confirm no Seek entry's SeekPosition resolves to the (now
// Tags/EOF) cues offset, and that the file still parses its real elements.
assert!(
find_id(&data, ebml::INFO).is_some() && find_id(&data, ebml::TRACKS).is_some(),
"Info and Tracks must still be present and seekable"
);
// Suppressing the CUES fixup must suppress ONLY that one: the other Seek entries
// still need back-patching to real offsets, or the SeekHead ships SeekPosition=0
// for Info/Tracks — an index pointing at the Segment header, not the elements.
let (_, seg_start) = locate_segment(&data);
let entries = parse_seekhead(&data);
for target in [ebml::INFO, ebml::TRACKS] {
let pos = entries
.iter()
.find(|(id, _)| *id == target)
.map(|(_, pos)| *pos)
.unwrap_or_else(|| panic!("SeekHead must still name 0x{target:X}"));
assert_ne!(pos, 0, "0x{target:X} SeekPosition must be back-patched");
let mut cursor = Cursor::new(&data[seg_start + pos as usize..]);
let (id, _, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(
id, target,
"0x{target:X} SeekPosition must resolve to that element"
);
}
}
#[test]
fn monotonic_ts_forces_strictly_increasing() {
// First frame passes through unchanged.
assert_eq!(monotonic_ts(None, 1000), 1000);
// A repeated millisecond is nudged to prev+1.
assert_eq!(monotonic_ts(Some(1000), 1000), 1001);
// A backwards tick is nudged forward, never earlier.
assert_eq!(monotonic_ts(Some(1001), 1000), 1002);
// A genuine advance is left alone.
assert_eq!(monotonic_ts(Some(1000), 1040), 1040);
// Simulate a stream of audio PTS that round to dup/back-tick ms and
// confirm the emitted sequence is strictly increasing.
let raw = [1000i64, 1000, 1000, 999, 1032, 1032, 1064];
let mut prev: Option<i64> = None;
let mut out = Vec::new();
for &p in &raw {
let t = monotonic_ts(prev, p);
out.push(t);
prev = Some(t);
}
assert!(
out.windows(2).all(|w| w[1] > w[0]),
"not strictly monotonic: {out:?}"
);
assert_eq!(out, [1000, 1001, 1002, 1003, 1032, 1033, 1064]);
}
#[test]
fn block_ts_exempts_video_from_monotonic_nudge() {
// VIDEO keeps its true PTS even when non-monotonic in storage order: a B-frame
// whose PTS sits below the prior stored frame must NOT be nudged to prev+1ms
// (that clobbering produced the "non monotonically increasing dts" decode flood).
assert_eq!(
block_ts(true, Some(1040), 1000),
1000,
"video B-frame PTS preserved"
);
assert_eq!(
block_ts(true, Some(1000), 1000),
1000,
"video dup-ms PTS preserved"
);
// A realistic decode-order GOP (I, then B-frames dipping below it):
// every value passes through untouched for video.
let gop = [1000i64, 960, 920, 1080, 1040];
let mut prev = None;
let out: Vec<i64> = gop
.iter()
.map(|&p| {
let t = block_ts(true, prev, p);
prev = Some(t);
t
})
.collect();
assert_eq!(out, gop, "video timestamps must be left exactly as-is");
// AUDIO/SUBTITLE still get the strictly-monotonic nudge — a same-ms
// collision there is a real defect.
assert_eq!(
block_ts(false, Some(1000), 1000),
1001,
"audio dup-ms nudged"
);
assert_eq!(
block_ts(false, Some(1001), 1000),
1002,
"subtitle back-tick nudged"
);
}
// Regression for the second-video-track bug: a Dolby Vision EL is video but NOT track 0;
// the exemption must follow track TYPE.
#[test]
fn second_video_track_pts_not_clobbered() {
use std::io::Cursor;
// Main video at index 0, a Dolby-Vision-EL-style second video at index 1.
let tracks = vec![make_video_track(), make_video_track()];
let buf = Cursor::new(Vec::new());
let mux = MkvMuxer::new(buf, &tracks, None, 0.0, &[]).unwrap();
// Both tracks must be flagged video so neither is nudged.
assert_eq!(mux.track_is_video, vec![true, true]);
// A B-frame dip on the EL (track 1) must pass through unchanged — keyed
// on track type, not index.
assert_eq!(block_ts(mux.track_is_video[1], Some(1040), 1000), 1000);
}
// End-to-end regression: a large clip-boundary reset with an interleaved straggler audio
// frame, driven through the full muxer.
#[test]
fn clip_boundary_with_straggler_yields_monotonic_clusters() {
let tracks = [make_video_track(), make_audio_track()];
// ms→ns helper for readability.
let ms = |m: i64| m * 1_000_000;
let frames: Vec<(usize, i64, bool, Vec<u8>)> = vec![
// Clip 1: video keyframes at 0s and 600s, audio alongside.
(0, ms(0), true, vec![0x01; 16]),
(1, ms(0), true, vec![0xA0; 8]),
(0, ms(600_000), true, vec![0x02; 16]), // 600s kf
(1, ms(600_000), true, vec![0xA1; 8]),
// Clip 2: video keyframe RESETS to 0 (the -600s boundary).
(0, ms(0), true, vec![0x03; 16]),
// Straggler: clip 1's tail audio (≈599.5s) arrives interleaved AFTER
// the reset — the exact frame class that caused the ratchet.
(1, ms(599_500), true, vec![0xA2; 8]),
// Clip 2 continues: audio at 0, video keyframe at 5s.
(1, ms(0), true, vec![0xA3; 8]),
(0, ms(5_000), true, vec![0x04; 16]), // clip2 + 5s
];
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
assert_eq!(frame_count, 8, "all frames written (none dropped)");
// Clusters are VIDEO-keyframe-driven, so they track the (rebased) video
// timeline only — the lagging audio straggler never opens a cluster.
// CLUSTER timestamps are in TimestampScale TICKS (0.1 ms).
let tick = |ms: i64| ms * 1_000_000 / TIMESTAMP_SCALE_NS; // ms → ticks
let clusters = find_clusters(&data);
let ts: Vec<u64> = clusters.iter().map(|&(_, _, t)| t).collect();
assert!(!ts.is_empty(), "expected clusters");
// Cluster timestamps must be monotonic non-decreasing (the boundary is
// rebased by VIDEO; no back-dated cluster, no non-monotonic band).
assert!(
ts.windows(2).all(|w| w[1] >= w[0]),
"cluster timestamps must be monotonic, got {ts:?}"
);
let max = *ts.iter().max().unwrap() as i64;
// Timeline reaches past the boundary (clip 2 present): ≥ ~600s.
assert!(
max >= tick(600_000),
"timeline must span past the boundary, got {max} ticks"
);
// And does NOT ratchet beyond clip1+clip2 (~605s), within a 1 s tolerance.
assert!(
max < tick(606_000),
"no ratchet: max cluster ts {max} ticks must stay within 1 s of 605 s"
);
}
// Regression for the hardcoded-track-0 epoch driver: M2TS/PMT may list audio before video,
// so the driver must follow PRIMARY VIDEO, not index 0.
#[test]
fn epoch_driver_follows_primary_video_not_index_zero() {
// Audio FIRST (index 0), video SECOND (index 1) — the M2TS/PMT ordering.
let tracks = [make_audio_track(), make_video_track()];
// The muxer must pick track 1 (first video) as the epoch driver.
{
let buf = Cursor::new(Vec::new());
let mux = MkvMuxer::new(buf, &tracks, None, 0.0, &[]).unwrap();
assert_eq!(
mux.primary_video_track,
Some(1),
"primary video must be the first VIDEO track (index 1), not 0"
);
}
let ms = |m: i64| m * 1_000_000;
// Track 0 = AUDIO, track 1 = VIDEO. Same clip-boundary + straggler shape
// as clip_boundary_with_straggler_yields_monotonic_clusters, but with the
// video at index 1.
let frames: Vec<(usize, i64, bool, Vec<u8>)> = vec![
(1, ms(0), true, vec![0x01; 16]), // video kf 0s
(0, ms(0), true, vec![0xA0; 8]), // audio 0s
(1, ms(600_000), true, vec![0x02; 16]), // video kf 600s
(0, ms(600_000), true, vec![0xA1; 8]), // audio 600s
// Clip 2: video keyframe RESETS to 0 (the -600s boundary).
(1, ms(0), true, vec![0x03; 16]),
// Straggler: clip 1's tail audio arrives interleaved after the reset.
(0, ms(599_500), true, vec![0xA2; 8]),
(0, ms(0), true, vec![0xA3; 8]), // clip2 audio at 0
(1, ms(5_000), true, vec![0x04; 16]), // clip2 + 5s video kf
];
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
assert_eq!(frame_count, 8, "all frames written (none dropped)");
let tick = |ms: i64| ms * 1_000_000 / TIMESTAMP_SCALE_NS;
let clusters = find_clusters(&data);
let ts: Vec<u64> = clusters.iter().map(|&(_, _, t)| t).collect();
assert!(!ts.is_empty(), "expected clusters");
assert!(
ts.windows(2).all(|w| w[1] >= w[0]),
"cluster timestamps must be monotonic, got {ts:?}"
);
let max = *ts.iter().max().unwrap() as i64;
// Timeline reaches past the boundary (clip 2 present): ≥ ~600s.
assert!(
max >= tick(600_000),
"timeline must span past the boundary, got {max} ticks"
);
// Must NOT ratchet far beyond clip1+clip2 (~605s). With the bug (audio
// index 0 driving epochs) the lagging-audio straggler ratchets the
// frontier and inflates the timeline well past this bound.
assert!(
max < tick(606_000),
"no ratchet: max cluster ts {max} ticks must stay within 1 s of 605 s"
);
}
// A Dolby Vision EL (second video track) must not drive epochs: its old-clip
// straggler after a seam would advance the frontier and open a spurious epoch.
#[test]
fn dv_enhancement_layer_straggler_does_not_open_a_spurious_epoch() {
let tracks = [make_video_track(), make_video_track()];
let ms = |m: i64| m * 1_000_000;
let frames: Vec<(usize, i64, bool, Vec<u8>)> = vec![
(0, ms(0), true, vec![0x01; 16]),
(1, ms(0), true, vec![0xB0; 8]),
(0, ms(600_000), true, vec![0x02; 16]),
(1, ms(600_000), true, vec![0xB1; 8]),
// Clip 2: BL resets to 0, then clip 1's EL tail straggles in.
(0, ms(0), true, vec![0x03; 16]),
(1, ms(599_500), true, vec![0xB2; 8]),
(1, ms(0), true, vec![0xB3; 8]),
(0, ms(5_000), true, vec![0x04; 16]),
];
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
assert_eq!(frame_count, 8, "all frames written (none dropped)");
let tick = |ms: i64| ms * 1_000_000 / TIMESTAMP_SCALE_NS;
let ts: Vec<u64> = find_clusters(&data).iter().map(|&(_, _, t)| t).collect();
assert!(ts.windows(2).all(|w| w[1] >= w[0]), "monotonic, got {ts:?}");
let max = *ts.iter().max().unwrap() as i64;
assert!(max >= tick(600_000), "timeline spans the seam, got {max}");
assert!(
max < tick(606_000),
"EL straggler must not ratchet the timeline, got {max} ticks"
);
}
#[test]
fn mkv_multiple_tracks() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track(), make_audio_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
// Write frames to both tracks
muxer
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None, None)
.unwrap();
muxer
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None, None)
.unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None, None)
.unwrap();
muxer
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None, None)
.unwrap();
// Should not panic
let data = muxer.writer.into_inner();
assert!(data.len() > 100, "output too small for multi-track MKV");
}
#[test]
fn mkv_keyframe_flag() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 10.0, &[]).unwrap();
// Record position before first frame
let pos_before_kf = muxer.writer.position();
muxer.write_frame(0, 0, true, &[0xAA], None, None).unwrap();
let pos_after_kf = muxer.writer.position();
muxer
.write_frame(0, 1_000_000, false, &[0xBB], None, None)
.unwrap();
let pos_after_nkf = muxer.writer.position();
let data = muxer.writer.into_inner();
// Extract the SimpleBlock regions
let kf_region = &data[pos_before_kf as usize..pos_after_kf as usize];
let nkf_region = &data[pos_after_kf as usize..pos_after_nkf as usize];
// In a SimpleBlock (ID 0xA3), the flags byte follows ID + size + track_vint +
// 2-byte timestamp. Keyframe flag = 0x80, non-keyframe = 0x00.
fn extract_flags(region: &[u8]) -> u8 {
// Find 0xA3 (SimpleBlock ID)
let sb_pos = region.iter().position(|&b| b == 0xA3).unwrap();
// After ID: size (variable), track vint (1 byte for track<128), ts (2 bytes), flags (1 byte)
// Size is 1 byte for small blocks (< 127 bytes)
let after_id = sb_pos + 1;
// Read VINT size: first byte has high bit set for 1-byte sizes
let size_byte = region[after_id];
let size_len = if size_byte & 0x80 != 0 { 1 } else { 2 };
// Track VINT: 1 byte (track 1 = 0x81)
let track_vint_pos = after_id + size_len;
let track_vint_len = 1; // track 1 encoded as 0x81
// 2-byte relative timestamp
let ts_pos = track_vint_pos + track_vint_len;
// flags byte
let flags_pos = ts_pos + 2;
region[flags_pos]
}
let kf_flags = extract_flags(kf_region);
let nkf_flags = extract_flags(nkf_region);
assert_eq!(
kf_flags & 0x80,
0x80,
"keyframe flag should be set (0x80), got 0x{:02X}",
kf_flags
);
assert_eq!(
nkf_flags & 0x80,
0x00,
"non-keyframe flag should be clear, got 0x{:02X}",
nkf_flags
);
}
#[test]
fn mkv_writes_chapters_element() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let chapters = vec![
Chapter {
time_secs: 0.0,
name: "Chapter 1".into(),
},
Chapter {
time_secs: 300.0,
name: "Chapter 2".into(),
},
Chapter {
time_secs: 600.0,
name: "Chapter 3".into(),
},
];
let muxer = MkvMuxer::new(buf, &tracks, Some("Chapter Test"), 900.0, &chapters).unwrap();
let data = muxer.writer.into_inner();
// Chapters element ID: 0x1043A770
assert!(
find_id(&data, ebml::CHAPTERS).is_some(),
"Chapters element (0x1043A770) not found in output"
);
// EditionEntry element ID: 0x45B9
assert!(
find_id(&data, ebml::EDITION_ENTRY).is_some(),
"EditionEntry element not found"
);
// ChapterAtom element ID: 0xB6
assert!(
find_id(&data, ebml::CHAPTER_ATOM).is_some(),
"ChapterAtom element not found"
);
}
#[test]
fn mkv_no_chapters_when_empty() {
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, Some("No Chapters"), 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::CHAPTERS).is_none(),
"Chapters element should not be present when no chapters given"
);
}
#[test]
fn mkv_default_flag_on_first_video_and_audio() {
// First video: is_default=true, first audio: is_default=true, second audio: is_default=false
let video = make_video_track(); // is_default: true
let audio1 = make_audio_track(); // is_default: true
let mut audio2 = make_audio_track();
audio2.is_default = false;
audio2.language = "fra".into();
let buf = Cursor::new(Vec::new());
let tracks = [video, audio1, audio2];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagDefault defaults to 1, so only the non-default third track carries FlagDefault=0.
let flags: Vec<Option<u64>> = track_entries(&data)
.iter()
.map(|&te| child_uint(&data, te, ebml::FLAG_DEFAULT))
.collect();
assert_eq!(flags, vec![None, None, Some(0)]);
}
/// Read back the value of the FIRST `Language` element in `data` as a
/// UTF-8 string. `LANGUAGE` (0x22B59C) is a 3-byte EBML ID; its size is
/// always a 1-byte VINT for the short strings this writer emits.
fn first_language_value(data: &[u8]) -> &str {
let pos = find_id(data, ebml::LANGUAGE).expect("Language element must be present");
let len = (data[pos + 3] & 0x7F) as usize;
std::str::from_utf8(&data[pos + 4..pos + 4 + len]).unwrap()
}
// RFC 9559 §12 restricts `Language` to ISO 639-2, never ISO 639-1; the DVD IFO block
// carries raw ISO 639-1 on disc.
#[test]
fn dvd_two_letter_language_becomes_iso_639_2_in_language_element() {
// AC-3 (coding_mode=0), 48 kHz, 6 channels, on-disc language "en" —
// the exact byte layout `ifo::audio_attr_parsing` pins.
let mut attr_bytes = vec![0u8; 8];
attr_bytes[0] = 0x00;
attr_bytes[1] = 0x05;
attr_bytes[2] = b'e';
attr_bytes[3] = b'n';
let attr = crate::ifo::parse_audio_attr(&attr_bytes, 0).unwrap();
assert_eq!(
attr.language, "eng",
"parse_audio_attr must already return the ISO 639-2 form"
);
let audio_stream = crate::disc::AudioStream {
pid: 0xBD80,
codec: attr.codec,
channels: crate::disc::AudioChannels::from_count(attr.channels),
language: attr.language,
sample_rate: crate::disc::SampleRate::from_hz(attr.sample_rate),
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
};
let track = MkvTrack::audio(&audio_stream);
let buf = Cursor::new(Vec::new());
let tracks = [track];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert_eq!(
first_language_value(&data),
"eng",
"a DVD-sourced ISO 639-1 code must be written as its ISO 639-2 \
equivalent in the Matroska Language element, per RFC 9559 §12"
);
}
// A source with no language leaves `language` EMPTY, but the muxer must never write a
// zero-length `Language`.
#[test]
fn a_source_with_no_language_emits_und_not_an_empty_language_element() {
// Exactly what `disc::hddvd::probe_evo_streams` builds.
let audio_stream = crate::disc::AudioStream {
pid: 0xBD80,
codec: Codec::Ac3Plus,
channels: crate::disc::AudioChannels::Surround51,
language: String::new(),
sample_rate: crate::disc::SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
};
let subtitle_stream = crate::disc::SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: String::new(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
};
for track in [
MkvTrack::audio(&audio_stream),
MkvTrack::subtitle(&subtitle_stream),
] {
let track_type = track.track_type;
let buf = Cursor::new(Vec::new());
let tracks = [track];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert_eq!(
first_language_value(&data),
"und",
"track type {track_type}: a stream with no known language must \
emit the ISO 639-2 'undetermined' code, never a zero-length \
Language element"
);
}
}
// An unmapped/absent DVD language code must degrade to `und`, never an empty string or raw
// 2-letter code.
#[test]
fn dvd_unmapped_or_empty_language_becomes_und_in_language_element() {
// Empty IFO language bytes (0x00 0x00).
let mut empty_bytes = vec![0u8; 8];
empty_bytes[0] = 0x00; // AC-3, 48k
empty_bytes[1] = 0x05; // 6ch
let empty_attr = crate::ifo::parse_audio_attr(&empty_bytes, 0).unwrap();
assert_eq!(
empty_attr.language, "und",
"IFO zero bytes (unspecified) must resolve to 'und', not empty"
);
// An IFO code with no known ISO 639-1 -> 639-2 mapping (e.g. a
// fictitious "zz").
let mut unmapped_bytes = vec![0u8; 8];
unmapped_bytes[0] = 0x00;
unmapped_bytes[1] = 0x05;
unmapped_bytes[2] = b'z';
unmapped_bytes[3] = b'z';
let unmapped_attr = crate::ifo::parse_audio_attr(&unmapped_bytes, 0).unwrap();
assert_eq!(
unmapped_attr.language, "und",
"an on-disc code with no known ISO 639-1 -> 639-2 mapping must \
degrade to 'und', never pass through raw"
);
for attr in [empty_attr, unmapped_attr] {
let audio_stream = crate::disc::AudioStream {
pid: 0xBD80,
codec: attr.codec,
channels: crate::disc::AudioChannels::from_count(attr.channels),
language: attr.language,
sample_rate: crate::disc::SampleRate::from_hz(attr.sample_rate),
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
};
let track = MkvTrack::audio(&audio_stream);
let buf = Cursor::new(Vec::new());
let tracks = [track];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert_eq!(
first_language_value(&data),
"und",
"an empty or unmapped DVD language code must degrade to 'und', \
never an empty string or an invalid ISO 639-1 code"
);
}
}
// Build an 8-byte DVD IFO audio-attribute block carrying `code`, run it through the real
// DVD pipeline, and return the emitted Matroska `Language` value.
fn emitted_language_for_dvd_code(code: &[u8; 2]) -> String {
let mut attr_bytes = vec![0u8; 8];
attr_bytes[0] = 0x00; // AC-3, 48 kHz
attr_bytes[1] = 0x05; // 6 channels
attr_bytes[2] = code[0];
attr_bytes[3] = code[1];
let attr = crate::ifo::parse_audio_attr(&attr_bytes, 0).unwrap();
let audio_stream = crate::disc::AudioStream {
pid: 0xBD80,
codec: attr.codec,
channels: crate::disc::AudioChannels::from_count(attr.channels),
language: attr.language,
sample_rate: crate::disc::SampleRate::from_hz(attr.sample_rate),
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
};
let track = MkvTrack::audio(&audio_stream);
let buf = Cursor::new(Vec::new());
let tracks = [track];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
first_language_value(&data).to_string()
}
// ISO 639-1 -> 639-2 conversion must cover the WHOLE of ISO 639-1, not just the
// menu-graphic vocabulary.
#[test]
fn dvd_language_outside_the_menu_vocabulary_is_still_mapped() {
assert_eq!(
emitted_language_for_dvd_code(b"ro"),
"ron",
"Romanian ('ro'), common on Region-2 discs, must reach the \
Matroska Language element as 'ron' — not 'und'"
);
// The rest of the set the menu-label table never knew, one per
// language so a single missing table row fails loudly.
for (code, expected) in [
(b"bg", "bul"),
(b"hr", "hrv"),
(b"sr", "srp"),
(b"sk", "slk"),
(b"sl", "slv"),
(b"he", "heb"),
(b"et", "est"),
(b"lv", "lav"),
(b"lt", "lit"),
(b"is", "isl"),
(b"id", "ind"),
(b"vi", "vie"),
(b"fa", "fas"),
] {
assert_eq!(
emitted_language_for_dvd_code(code),
expected,
"DVD language {:?} must map to {expected:?} in the emitted \
Language element",
std::str::from_utf8(code).unwrap()
);
}
}
// DVD-Video froze its language list at 1988 ISO 639-1 (Hebrew `iw`, Indonesian `in`,
// Yiddish `ji`).
#[test]
fn dvd_era_language_aliases_map_to_the_modern_code() {
assert_eq!(
emitted_language_for_dvd_code(b"iw"),
"heb",
"the DVD-era spelling of Hebrew ('iw') must emit 'heb'"
);
assert_eq!(emitted_language_for_dvd_code(b"in"), "ind");
assert_eq!(emitted_language_for_dvd_code(b"ji"), "yid");
// ...and agree with the modern spellings.
assert_eq!(emitted_language_for_dvd_code(b"he"), "heb");
assert_eq!(emitted_language_for_dvd_code(b"id"), "ind");
assert_eq!(emitted_language_for_dvd_code(b"yi"), "yid");
}
// Widening the table must not weaken the degradation guarantee: a code not in ISO 639-1
// still yields exactly `und`.
#[test]
fn unknown_dvd_language_still_yields_exactly_und() {
for code in [b"zz", b"qq", b"xx"] {
assert_eq!(
emitted_language_for_dvd_code(code),
"und",
"a code outside ISO 639-1 must degrade to exactly 'und'"
);
}
// Empty language bytes (0x00 0x00) likewise.
assert_eq!(emitted_language_for_dvd_code(&[0x00, 0x00]), "und");
}
#[test]
fn mkv_forced_flag_on_forced_subtitle() {
use crate::disc::SubtitleStream;
// PGS always reserves FlagForced, so the VALUE must carry is_forced; a forced VobSub
// writes FlagForced=1 and an unforced one omits it.
let sub = |codec, forced| {
MkvTrack::subtitle(&SubtitleStream {
pid: 0x20,
codec,
language: "eng".into(),
forced,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})
};
let tracks = [
sub(Codec::Pgs, true),
sub(Codec::Pgs, false),
sub(Codec::DvdSub, true),
sub(Codec::DvdSub, false),
];
assert!(tracks[0].is_forced);
let data = MkvMuxer::new(Cursor::new(Vec::new()), &tracks, None, 60.0, &[])
.unwrap()
.writer
.into_inner();
let flags: Vec<Option<u64>> = track_entries(&data)
.iter()
.map(|&te| child_uint(&data, te, ebml::FLAG_FORCED))
.collect();
assert_eq!(flags, vec![Some(1), Some(0), Some(1), None]);
}
#[test]
fn mkv_no_forced_flag_on_non_forced_subtitle() {
use crate::disc::SubtitleStream;
let video = make_video_track();
let sub = MkvTrack::subtitle(&SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
});
assert!(!sub.is_forced);
let buf = Cursor::new(Vec::new());
let tracks = [video, sub];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// A PGS subtitle track now RESERVES a FlagForced element (so PGS content
// can promote it later), but for a non-forced track its value is 0. The
// value byte sits after the 2-byte ID (0x55AA) + 1-byte size.
let pos =
find_id(&data, ebml::FLAG_FORCED).expect("PGS subtitle reserves a FlagForced element");
assert_eq!(
data[pos + 3],
0,
"reserved FlagForced value must be 0 for a non-forced subtitle"
);
}
/// A PGS display-set PES block (PCS) with one composition object, whose
/// forced_on_flag is set per `forced`.
#[cfg(test)]
fn pgs_display_set(forced: bool) -> Vec<u8> {
let mut pcs = vec![0u8; 18];
pcs[0] = 0x16; // segment type PCS
pcs[13] = 1; // number_of_composition_objects
pcs[17] = if forced { 0x40 } else { 0x00 }; // first object flags
pcs
}
/// Every `FlagForced` value byte in the file, in track order — the two-PGS-track
/// tests need per-track values, not just the first.
fn all_flag_forced_values(data: &[u8]) -> Vec<u8> {
let needle = ebml::FLAG_FORCED.to_be_bytes();
let needle = &needle[2..]; // FlagForced is a 2-byte EBML ID
data.windows(needle.len())
.enumerate()
.filter(|(_, w)| *w == needle)
.filter_map(|(i, _)| data.get(i + 3).copied())
.collect()
}
/// A PGS subtitle track carrying the vendor/scan forced flag `forced`.
fn pgs_subtitle_track(pid: u16, forced: bool) -> MkvTrack {
MkvTrack::subtitle(&crate::disc::SubtitleStream {
pid,
codec: Codec::Pgs,
language: "eng".into(),
forced,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})
}
// A wrong vendor forced label is CLEARED by the content, when a sibling track proves the
// authoring house sets `forced_on_flag`.
#[test]
fn mkv_pgs_wrong_forced_label_is_cleared_when_a_sibling_uses_the_flag() {
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let tracks = [
make_video_track(),
pgs_subtitle_track(0x1200, true), // mislabelled full track
pgs_subtitle_track(0x1201, false), // genuine forced track
];
let mut muxer =
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
.unwrap();
for i in 0..12 {
muxer
.write_frame(
1,
1_000_000 * (i + 1),
true,
&pgs_display_set(false),
Some(2_000_000),
None,
)
.unwrap();
}
for i in 0..3 {
muxer
.write_frame(
2,
1_000_000 * (i + 1),
true,
&pgs_display_set(true),
Some(2_000_000),
None,
)
.unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
assert_eq!(
all_flag_forced_values(&data),
vec![0, 1],
"the mislabelled track loses FlagForced; the genuinely forced one keeps it"
);
}
// The guard that stops that from being reckless: when no disc track ever sets
// `forced_on_flag`, the vendor label must survive.
#[test]
fn mkv_pgs_forced_label_survives_a_disc_that_never_sets_the_flag() {
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let tracks = [
make_video_track(),
pgs_subtitle_track(0x1200, true),
pgs_subtitle_track(0x1201, false),
];
let mut muxer =
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
.unwrap();
for track in 1..=2 {
for i in 0..12 {
muxer
.write_frame(
track,
1_000_000 * (i + 1),
true,
&pgs_display_set(false),
Some(2_000_000),
None,
)
.unwrap();
}
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
assert_eq!(
all_flag_forced_values(&data),
vec![1, 0],
"with the flag unused disc-wide, both labels stand as authored"
);
}
#[test]
fn mkv_pgs_forced_promoted_when_all_display_sets_forced() {
// End-to-end: a PGS subtitle track whose every display set is forced is
// promoted to FlagForced=1 at finish(), even though the scan flag was
// false (no vendor metadata).
use crate::disc::SubtitleStream;
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let sub = MkvTrack::subtitle(&SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
});
let tracks = [make_video_track(), sub];
let mut muxer =
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
.unwrap(); // video keyframe opens a cluster
muxer
.write_frame(
1,
1_000_000,
true,
&pgs_display_set(true),
Some(2_000_000),
None,
)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
let pos = find_id(&data, ebml::FLAG_FORCED).expect("reserved FlagForced");
assert_eq!(
data[pos + 3],
1,
"all-forced PGS track promoted to FlagForced=1"
);
}
#[test]
fn mkv_pgs_not_promoted_when_a_display_set_is_not_forced() {
// A track with ANY non-forced display set is a full track, not forced
// narrative — FlagForced stays 0.
use crate::disc::SubtitleStream;
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let sub = MkvTrack::subtitle(&SubtitleStream {
pid: 0x1200,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
});
let tracks = [make_video_track(), sub];
let mut muxer =
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0u8; 16], Some(40_000_000), None)
.unwrap();
muxer
.write_frame(
1,
1_000_000,
true,
&pgs_display_set(true),
Some(2_000_000),
None,
)
.unwrap();
muxer
.write_frame(
1,
3_000_000,
true,
&pgs_display_set(false),
Some(4_000_000),
None,
)
.unwrap(); // a normal (non-forced) subtitle → not a forced track
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
let pos = find_id(&data, ebml::FLAG_FORCED).expect("reserved FlagForced");
assert_eq!(data[pos + 3], 0, "mixed PGS track stays FlagForced=0");
}
// ============================================================
// Seekability tests: SeekHead, keyframe-aligned clusters, Cues
// ============================================================
use std::sync::{Arc, Mutex};
/// Writer that lets the test inspect the buffer after `finish()` consumes the muxer.
struct SharedWriter(Arc<Mutex<Cursor<Vec<u8>>>>);
impl Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
impl Seek for SharedWriter {
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
self.0.lock().unwrap().seek(pos)
}
}
/// Build interleaved frames at 24 fps video (IDR every gop_secs) + 48 kHz audio (1024 samples per frame).
fn frames_for(duration_secs: f64, gop_secs: f64) -> Vec<(usize, i64, bool, Vec<u8>)> {
let video_interval_ns: i64 = 1_000_000_000 / 24;
let audio_interval_ns: i64 = (1024i64 * 1_000_000_000) / 48_000;
let gop_frames = (gop_secs * 24.0).round() as i64;
let mut out: Vec<(usize, i64, bool, Vec<u8>)> = Vec::new();
let total_ns = (duration_secs * 1_000_000_000.0) as i64;
let mut vi: i64 = 0;
loop {
let pts = vi * video_interval_ns;
if pts >= total_ns {
break;
}
let keyframe = vi % gop_frames == 0;
out.push((0, pts, keyframe, vec![0xAB; 64]));
vi += 1;
}
let mut ai: i64 = 0;
loop {
let pts = ai * audio_interval_ns;
if pts >= total_ns {
break;
}
out.push((1, pts, true, vec![0xCD; 32]));
ai += 1;
}
out.sort_by_key(|f| f.1);
out
}
/// Mux frames through a SharedWriter and return the final buffer.
fn mux_to_bytes(
tracks: &[MkvTrack],
chapters: &[Chapter],
frames: &[(usize, i64, bool, Vec<u8>)],
) -> (Vec<u8>, u64) {
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
for (t, pts, kf, data) in frames {
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
}
let frame_count = muxer.frame_count;
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
(data, frame_count)
}
/// Find the Segment header in the buffer and return (segment_id_pos, segment_start_pos).
/// segment_start = position immediately after Segment's id + size bytes.
fn locate_segment(data: &[u8]) -> (usize, usize) {
let segment_id_pos = find_id(data, ebml::SEGMENT).expect("segment id not found");
// Segment is written via write_id + write_unknown_size: 4 byte id + 8 byte size
(segment_id_pos, segment_id_pos + 4 + 8)
}
/// Walk Segment's top-level children. Returns Vec<(id, data_start_offset, data_size)>
/// where data_start_offset is absolute file offset and data_size is the element body size.
fn segment_children(data: &[u8]) -> Vec<(u32, usize, u64)> {
let (_, seg_start) = locate_segment(data);
let mut out = Vec::new();
let mut cursor = Cursor::new(&data[seg_start..]);
while (cursor.position() as usize) < data.len() - seg_start {
let pos_before = cursor.position();
let (id, size, hdr_len) = match ebml::read_element_header(&mut cursor) {
Ok(v) => v,
Err(_) => break,
};
let data_abs = seg_start + pos_before as usize + hdr_len;
out.push((id, data_abs, size));
// Skip the body to advance to the next element.
cursor
.seek(io::SeekFrom::Current(size as i64))
.expect("seek past element body");
}
out
}
// Walk the direct children of a master element body: (id, body_start_abs,
// body_size). Unlike find_id's flat byte-scan, this respects EBML
// nesting: a child id buried inside a deeper master is NOT reported here.
fn master_children(data: &[u8], body_start: usize, body_size: usize) -> Vec<(u32, usize, u64)> {
let mut out = Vec::new();
let body = &data[body_start..body_start + body_size];
let mut cursor = Cursor::new(body);
while (cursor.position() as usize) < body.len() {
let pos_before = cursor.position();
let (id, size, hdr_len) = match ebml::read_element_header(&mut cursor) {
Ok(v) => v,
Err(_) => break,
};
let child_abs = body_start + pos_before as usize + hdr_len;
out.push((id, child_abs, size));
cursor
.seek(io::SeekFrom::Current(size as i64))
.expect("seek past child body");
}
out
}
/// Locate the first `TrackEntry` master and return the offset/size of its
/// body. Walks Segment → Tracks → TrackEntry, never a flat byte-scan, so the
/// returned range is the genuine TrackEntry body.
fn first_track_entry(data: &[u8]) -> (usize, usize) {
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 (_, te_start, te_size) = master_children(data, tracks_start, tracks_size)
.into_iter()
.find(|(id, _, _)| *id == ebml::TRACK_ENTRY)
.expect("TrackEntry present");
(te_start, te_size as usize)
}
// Every TrackEntry body (start, size), walked through Segment -> Tracks.
fn track_entries(data: &[u8]) -> Vec<(usize, usize)> {
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");
master_children(data, tracks_start, tracks_size)
.into_iter()
.filter(|(id, _, _)| *id == ebml::TRACK_ENTRY)
.map(|(_, off, sz)| (off, sz as usize))
.collect()
}
// The uint value of the direct child `id` of a master body, if present.
fn child_uint(data: &[u8], (start, size): (usize, usize), id: u32) -> Option<u64> {
let (_, off, sz) = master_children(data, start, size)
.into_iter()
.find(|(cid, _, _)| *cid == id)?;
ebml::read_uint_val(&mut Cursor::new(&data[off..]), sz as usize).ok()
}
/// Find every Cluster: returns Vec<(cluster_data_start_abs, cluster_data_size, cluster_timestamp_ms)>.
fn find_clusters(data: &[u8]) -> Vec<(usize, u64, u64)> {
let mut out = Vec::new();
for (id, body_start, body_size) in segment_children(data) {
if id == ebml::CLUSTER {
let mut cursor = Cursor::new(&data[body_start..body_start + body_size as usize]);
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(
tid,
ebml::CLUSTER_TIMESTAMP,
"cluster must start with timestamp"
);
let ts = ebml::read_uint_val(&mut cursor, tsize as usize).unwrap();
out.push((body_start, body_size, ts));
}
}
out
}
/// Parse the first SimpleBlock that appears in a cluster body slice.
/// Returns (track_num, flags_byte). track_num decoded from VINT.
fn first_simple_block(cluster_body: &[u8]) -> (u64, u8) {
let mut cursor = Cursor::new(cluster_body);
loop {
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
if id == ebml::SIMPLE_BLOCK {
let body_start = cursor.position() as usize;
// Decode track VINT.
let b0 = cluster_body[body_start];
let (track_num, vint_len) = if b0 & 0x80 != 0 {
((b0 & 0x7F) as u64, 1usize)
} else if b0 & 0x40 != 0 {
let b1 = cluster_body[body_start + 1];
((((b0 & 0x3F) as u64) << 8) | b1 as u64, 2)
} else {
panic!("unsupported track vint width");
};
let flags = cluster_body[body_start + vint_len + 2];
return (track_num, flags);
}
// Skip non-SimpleBlock child.
cursor.seek(io::SeekFrom::Current(size as i64)).unwrap();
}
}
/// Parse the Cues element body into Vec<(cue_time, cue_track, cue_cluster_position)>.
fn parse_cues(data: &[u8]) -> Vec<(u64, u64, u64)> {
let mut out = Vec::new();
let (cues_id, cues_body_start, cues_body_size) = segment_children(data)
.into_iter()
.find(|(id, _, _)| *id == ebml::CUES)
.expect("cues element not found");
assert_eq!(cues_id, ebml::CUES);
let cues_body = &data[cues_body_start..cues_body_start + cues_body_size as usize];
let mut cursor = Cursor::new(cues_body);
while (cursor.position() as usize) < cues_body.len() {
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(id, ebml::CUE_POINT);
let cp_end = cursor.position() + size;
let mut cue_time = 0u64;
let mut cue_track = 0u64;
let mut cue_pos = 0u64;
while cursor.position() < cp_end {
let (sid, ssize, _) = ebml::read_element_header(&mut cursor).unwrap();
match sid {
ebml::CUE_TIME => {
cue_time = ebml::read_uint_val(&mut cursor, ssize as usize).unwrap();
}
ebml::CUE_TRACK_POSITIONS => {
let ctp_end = cursor.position() + ssize;
while cursor.position() < ctp_end {
let (iid, isize_, _) = ebml::read_element_header(&mut cursor).unwrap();
match iid {
ebml::CUE_TRACK => {
cue_track =
ebml::read_uint_val(&mut cursor, isize_ as usize).unwrap();
}
ebml::CUE_CLUSTER_POSITION => {
cue_pos =
ebml::read_uint_val(&mut cursor, isize_ as usize).unwrap();
}
_ => {
cursor.seek(io::SeekFrom::Current(isize_ as i64)).unwrap();
}
}
}
}
_ => {
cursor.seek(io::SeekFrom::Current(ssize as i64)).unwrap();
}
}
}
out.push((cue_time, cue_track, cue_pos));
}
out
}
/// Parse the SeekHead body into Vec<(seek_id, seek_position)>.
fn parse_seekhead(data: &[u8]) -> Vec<(u32, u64)> {
let mut out = Vec::new();
let (sh_id, sh_body_start, sh_body_size) = segment_children(data)
.into_iter()
.find(|(id, _, _)| *id == ebml::SEEK_HEAD)
.expect("seekhead not found");
assert_eq!(sh_id, ebml::SEEK_HEAD);
let sh_body = &data[sh_body_start..sh_body_start + sh_body_size as usize];
let mut cursor = Cursor::new(sh_body);
while (cursor.position() as usize) < sh_body.len() {
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
// A Seek entry finish() neutralised (the unused CUES pointer) is
// overwritten in place with a Void of the same total width — skip it
// rather than treating it as a malformed SeekHead.
if id == ebml::VOID {
cursor.seek(io::SeekFrom::Current(size as i64)).unwrap();
continue;
}
assert_eq!(id, ebml::SEEK);
let seek_end = cursor.position() + size;
let mut seek_id_val: u32 = 0;
let mut seek_pos_val: u64 = 0;
while cursor.position() < seek_end {
let (sid, ssize, _) = ebml::read_element_header(&mut cursor).unwrap();
match sid {
ebml::SEEK_ID => {
let raw = ebml::read_uint_val(&mut cursor, ssize as usize).unwrap();
seek_id_val = raw as u32;
}
ebml::SEEK_POSITION => {
seek_pos_val = ebml::read_uint_val(&mut cursor, ssize as usize).unwrap();
}
_ => {
cursor.seek(io::SeekFrom::Current(ssize as i64)).unwrap();
}
}
}
out.push((seek_id_val, seek_pos_val));
}
out
}
#[test]
fn keyframe_driven_clusters_start_on_video_keyframe() {
// COMMON case: a keyframe-driven cluster (opened when a video keyframe crosses
// the cluster-duration boundary) must begin with that keyframe. With a 1s GOP and
// 3s clusters, no forced i16-split clusters appear, so EVERY cluster is aligned.
let tracks = [make_video_track(), make_audio_track()];
let frames = frames_for(30.0, 1.0);
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let clusters = find_clusters(&data);
assert!(!clusters.is_empty(), "expected at least one cluster");
for (body_start, body_size, _ts) in clusters {
let body = &data[body_start..body_start + body_size as usize];
// Skip past the CLUSTER_TIMESTAMP element first.
let mut cursor = Cursor::new(body);
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
let after_ts = cursor.position() as usize;
let (track_num, flags) = first_simple_block(&body[after_ts..]);
assert_eq!(
track_num, 1,
"first block in cluster must be track 1 (video)"
);
assert_eq!(
flags & 0x80,
0x80,
"first block in cluster must have keyframe flag set, got 0x{:02X}",
flags
);
}
}
#[test]
fn cue_count_equals_cluster_count() {
// Regression for the cluster-split Cue gap: EVERY cluster (keyframe-opened OR
// forced by the i16 block-relative split) must carry a Cue, so cluster count
// equals cue count and the seek index has no multi-second holes.
let tracks = [make_video_track(), make_audio_track()];
let frames = frames_for(30.0, 1.0);
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let clusters = find_clusters(&data);
let cues = parse_cues(&data);
assert_eq!(
clusters.len(),
cues.len(),
"every cluster must have a cue: cluster count {} != cue count {}",
clusters.len(),
cues.len()
);
// For 30s @ 2s nominal cluster duration with 1s GOP, expect 15 clusters.
assert_eq!(
clusters.len(),
15,
"expected 15 clusters for 30s @ 2s cluster duration"
);
}
#[test]
fn cue_count_equals_cluster_count_blockgroup_vfr() {
// DVD (MPEG-2) seek-index regression: VFR frames are BlockGroups (not the
// SimpleBlocks prior cue tests cover), which must still emit one cue per cluster
// or a DVD MKV scrubs nowhere. Driven by the REAL `Mpeg2Parser` end-to-end.
use crate::mux::codec::CodecParser;
use crate::mux::codec::mpeg2::Mpeg2Parser;
use crate::mux::ts::PesPacket;
// Minimal MPEG-2 ES builders (mirroring the mpeg2 parser's own test
// fixtures): a 720x480 / 29.97 sequence header, a GOP delimiter, and a
// frame-picture access unit (picture header + coding extension + slice).
fn seq_header() -> Vec<u8> {
let (w, h, aspect, fr): (u16, u16, u8, u8) = (720, 480, 2, 4);
let mut hdr = vec![0x00, 0x00, 0x01, 0xB3u8];
hdr.push((w >> 4) as u8);
hdr.push((((w & 0x0F) as u8) << 4) | (((h >> 8) & 0x0F) as u8));
hdr.push((h & 0xFF) as u8);
hdr.push((aspect << 4) | (fr & 0x0F));
hdr.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0x00]);
hdr
}
fn gop() -> Vec<u8> {
vec![0x00, 0x00, 0x01, 0xB8u8, 0x00, 0x00, 0x00, 0x00]
}
fn pic(coding_type: u8, tr: u16) -> Vec<u8> {
let b4 = ((tr >> 2) & 0xFF) as u8;
let b5 = (((tr & 0x03) as u8) << 6) | ((coding_type & 0x07) << 3);
let mut au = vec![0x00, 0x00, 0x01, 0x00u8, b4, b5, 0x00, 0x00];
// Picture coding extension: frame picture, no pulldown → 2 fields.
au.extend_from_slice(&[0x00, 0x00, 0x01, 0xB5u8, 0x80, 0x00, 0x03, 0x00, 0x80]);
au.extend_from_slice(&[0xAA; 32]);
au
}
let mut parser = Mpeg2Parser::new();
let field_ns = 1_000_000_000i64 * 1001 / 30000 / 2;
let frame_ns = 2 * field_ns;
let mut frames: Vec<crate::mux::codec::Frame> = Vec::new();
// 80 GOPs × 12 frames ≈ 32 s of video — well past the 2 s cluster span,
// so many clusters open and every one must carry a cue.
let gop_len = 12u16;
for g in 0..80i64 {
let mut es = seq_header();
es.extend_from_slice(&gop());
es.extend_from_slice(&pic(1, 0)); // I-frame (tr0, keyframe)
for tr in 1..gop_len {
// Mix of P (tr%3==0) and B frames at climbing display order.
let ct = if tr % 3 == 0 { 2 } else { 3 };
es.extend_from_slice(&pic(ct, tr));
}
// One PES PTS anchor per GOP (90 kHz), as a real VOBU stamps.
let gop_pts = g * gop_len as i64 * frame_ns * 90_000 / 1_000_000_000;
frames.extend(parser.parse(&PesPacket {
source: None,
pid: 0x1011,
pts: Some(gop_pts),
dts: None,
data: es,
discontinuity: false,
}));
}
frames.extend(parser.flush());
// Confirm the DVD frame shape: every frame carries a duration (→
// BlockGroup, NOT SimpleBlock), and the I-frames are flagged keyframes.
assert!(
frames.iter().all(|f| f.duration_ns.is_some()),
"DVD VFR frames must carry per-frame durations (BlockGroup path)"
);
assert_eq!(
frames.iter().filter(|f| f.keyframe).count(),
80,
"one I-frame keyframe per GOP"
);
let tracks = [make_video_track()];
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
for f in &frames {
muxer
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns, None)
.unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// The muxer branches on `duration_ns` (Some -> BlockGroup, None -> SimpleBlock).
// Every frame above carries Some, so confirm the BlockGroup path structurally by
// walking the first cluster's children (a BlockGroup present, no SimpleBlock).
let clusters = find_clusters(&data);
{
let (c0_start, c0_size, _) = clusters[0];
let mut kinds = Vec::new();
let mut cur = Cursor::new(&data[c0_start..c0_start + c0_size as usize]);
while (cur.position() as usize) < c0_size as usize {
let (id, size, _) = ebml::read_element_header(&mut cur).unwrap();
kinds.push(id);
cur.seek(io::SeekFrom::Current(size as i64)).unwrap();
}
assert!(
kinds.contains(&ebml::BLOCK_GROUP),
"DVD VFR cluster must contain a BlockGroup"
);
assert!(
!kinds.contains(&ebml::SIMPLE_BLOCK),
"DVD VFR cluster must NOT contain a SimpleBlock"
);
}
let cues = parse_cues(&data);
assert!(
clusters.len() > 1,
"expected many clusters for 32 s of video"
);
assert_eq!(
clusters.len(),
cues.len(),
"BlockGroup/VFR (DVD) seek index: every cluster must have a cue — \
cluster count {} != cue count {}",
clusters.len(),
cues.len()
);
// And each cue must resolve to a real cluster (no dangling positions).
let (_, seg_start) = locate_segment(&data);
for (_time, _track, pos) in &cues {
let abs = seg_start + *pos as usize;
let mut cursor = Cursor::new(&data[abs..]);
let (id, _size, _hdr_len) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(
id,
ebml::CLUSTER,
"cue position 0x{:X} did not resolve to a cluster",
pos
);
}
}
#[test]
fn cue_positions_resolve_to_clusters() {
let tracks = [make_video_track(), make_audio_track()];
let frames = frames_for(30.0, 1.0);
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let (_, seg_start) = locate_segment(&data);
let cues = parse_cues(&data);
assert!(!cues.is_empty());
for (_time, track, pos) in cues {
let abs = seg_start + pos as usize;
let mut cursor = Cursor::new(&data[abs..]);
let (id, _size, _hdr_len) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(
id,
ebml::CLUSTER,
"cue position 0x{:X} did not resolve to a cluster",
pos
);
// CueTrack is a 1-based Matroska TrackNumber, not the muxer's 0-based index.
// Clusters here are opened by video (index 0), so cues must name track 1;
// CueTrack=0 names no track and players ignore the whole seek index.
assert_eq!(
track, 1,
"CueTrack must be the 1-based TrackNumber of the cluster-opening \
video track, not its 0-based index"
);
}
}
#[test]
fn cue_times_match_cluster_timestamps() {
let tracks = [make_video_track(), make_audio_track()];
let frames = frames_for(30.0, 1.0);
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let (_, seg_start) = locate_segment(&data);
let cues = parse_cues(&data);
for (time, _track, pos) in cues {
let abs = seg_start + pos as usize;
let mut cursor = Cursor::new(&data[abs..]);
let (id, size, _hdr_len) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(id, ebml::CLUSTER);
let body_start = abs + (cursor.position() as usize);
let body = &data[body_start..body_start + size as usize];
let mut bc = Cursor::new(body);
let (tid, tsize, _) = ebml::read_element_header(&mut bc).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
let cluster_ts = ebml::read_uint_val(&mut bc, tsize as usize).unwrap();
assert_eq!(
cluster_ts, time,
"cluster timestamp {} != cue time {}",
cluster_ts, time
);
}
}
#[test]
fn opening_keyframe_with_nonzero_disc_pts_anchors_base_not_corrupted() {
// Opening-GOP PTS regression: a DVD title opens on an I-frame at its REAL PTS
// (~10s, non-zero). The muxer must anchor `base` there so cluster 0 is timestamp 0
// (the `.max(0)` floor must not wrap it) and the next frame lands one interval later.
let tracks = [make_video_track()];
const OPEN_PTS: i64 = 10_000_000_000; // 10 s opening anchor
let frames = vec![
(0usize, OPEN_PTS, true, vec![0xAAu8; 8]), // opening I-frame
(0usize, OPEN_PTS + 40_000_000, false, vec![0xBBu8; 8]), // +40 ms
];
let (data, count) = mux_to_bytes(&tracks, &[], &frames);
assert_eq!(
count, 2,
"both opening frames written (none dropped/floored away)"
);
// Read the FIRST cluster's timestamp — must be 0 (base == opening PTS).
let (_, seg_start) = locate_segment(&data);
let cluster_abs = seg_start
+ segment_children(&data)
.iter()
.find(|(id, _, _)| *id == ebml::CLUSTER)
.map(|(_, off, _)| *off - seg_start)
.expect("a cluster was written");
let mut bc = Cursor::new(&data[cluster_abs..]);
let (tid, tsize, _) = ebml::read_element_header(&mut bc).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
let cluster_ts = ebml::read_uint_val(&mut bc, tsize as usize).unwrap();
assert_eq!(
cluster_ts, 0,
"opening cluster timestamp must be 0 (base anchored on the opening keyframe's real PTS)"
);
// The first cue (opening keyframe) is at tick 0 — not the absolute disc PTS.
let cues = parse_cues(&data);
assert_eq!(cues[0].0, 0, "opening cue at t=0, disc PTS rebased to base");
}
#[test]
fn seekhead_is_first_child_of_segment() {
let tracks = [make_video_track(), make_audio_track()];
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(10.0, 1.0));
let children = segment_children(&data);
assert!(!children.is_empty());
assert_eq!(
children[0].0,
ebml::SEEK_HEAD,
"first child of segment must be SeekHead, got id 0x{:X}",
children[0].0
);
}
/// Scan for the first DURATION element (`0x4489`, 8-byte float payload) and
/// return its value in TimestampScale ticks.
fn find_duration_ticks(data: &[u8]) -> Option<f64> {
data.windows(3)
.position(|w| w == [0x44, 0x89, 0x88])
.and_then(|i| data.get(i + 3..i + 11))
.map(|b| f64::from_be_bytes(b.try_into().unwrap()))
}
#[test]
fn duration_placeholder_voided_when_timeline_never_advances() {
// Degenerate recovery: one keyframe at tick 0 with no duration leaves
// max_block_ticks at 0, so the reserved DURATION placeholder must be VOIDED
// (omitted) rather than left as a bogus 0.0 that players read as zero-length.
let tracks = [make_video_track()];
let one_frame = vec![(0usize, 0i64, true, vec![0xAAu8; 16])];
let (data, _) = mux_to_bytes(&tracks, &[], &one_frame);
assert_eq!(
find_duration_ticks(&data),
None,
"no DURATION element (placeholder voided), not a 0.0 duration"
);
}
#[test]
fn duration_backpatched_from_timeline_when_source_gives_none() {
// `mux_to_bytes` muxes with `duration_secs = 0.0` (as an HD-DVD title
// does), so DURATION is reserved as a placeholder and must be
// back-patched from the muxed timeline at finish() — not left 0/absent.
let tracks = [make_video_track(), make_audio_track()];
let frames = frames_for(5.0, 1.0); // ~5 s of 24 fps video
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let dur = find_duration_ticks(&data).expect("DURATION element present");
let expect = 5.0 * 1_000_000_000.0 / TIMESTAMP_SCALE_NS as f64; // ~50000 ticks
assert!(dur > 0.0, "duration back-patched from timeline, not 0");
assert!(
(dur - expect).abs() < 60.0,
"duration ~ real runtime: got {dur} ticks, expected ~{expect}"
);
}
#[test]
fn probe_round_trips_the_writer_header() {
let tracks = [make_video_track(), make_audio_track()];
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(10.0, 1.0));
let p = crate::mux::probe_mkv(Cursor::new(&data)).unwrap();
assert_eq!(p.muxing_app.as_deref(), Some(crate::MUX_APP));
assert_eq!(p.writing_app.as_deref(), Some(crate::MUX_APP));
let want: Vec<u32> = env!("FREEMKV_VERSION")
.split(['.', '-'])
.take(3)
.map(|n| n.parse().unwrap())
.collect();
let (a, b, c) = crate::mux::parse_freemkv_version(crate::MUX_APP).unwrap();
assert_eq!(vec![a, b, c], want);
assert_eq!(p.timestamp_scale, TIMESTAMP_SCALE_NS as u64);
assert!((p.duration_secs.unwrap() - 10.0).abs() < 0.1, "{p:?}");
let ids: Vec<&str> = p.tracks.iter().map(|t| t.codec_id.as_str()).collect();
assert_eq!(ids, [ebml::CODEC_H264, ebml::CODEC_AC3]);
assert_eq!(p.tracks[1].language, "eng");
assert_eq!(p.last_cue_secs, None);
let p = crate::mux::probe_mkv_with_cues(Cursor::new(&data)).unwrap();
let cue = p.last_cue_secs.expect("writer emits Cues");
assert!((8.0..=10.0).contains(&cue), "last cue {cue}");
}
#[test]
fn declared_duration_is_kept_not_measured() {
// A title that declares its runtime keeps that value in the header even
// when fewer seconds were muxed; only the Cues reflect the real timeline.
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let tracks = [make_video_track(), make_audio_track()];
let mut muxer =
MkvMuxer::new(SharedWriter(shared.clone()), &tracks, None, 100.0, &[]).unwrap();
for (t, pts, kf, d) in frames_for(5.0, 1.0) {
muxer.write_frame(t, pts, kf, &d, None, None).unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
let p = crate::mux::probe_mkv_with_cues(Cursor::new(&data)).unwrap();
assert!((p.duration_secs.unwrap() - 100.0).abs() < 1e-6, "{p:?}");
assert!(p.last_cue_secs.unwrap() < 5.0, "{p:?}");
}
#[test]
fn seekhead_points_to_real_elements() {
let tracks = [make_video_track(), make_audio_track()];
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(10.0, 1.0));
let (_, seg_start) = locate_segment(&data);
let entries = parse_seekhead(&data);
let required = [ebml::INFO, ebml::TRACKS, ebml::CUES];
for &want_id in &required {
let entry = entries
.iter()
.find(|(id, _)| *id == want_id)
.unwrap_or_else(|| panic!("seekhead missing entry for id 0x{:X}", want_id));
let abs = seg_start + entry.1 as usize;
let mut cursor = Cursor::new(&data[abs..]);
let (got_id, _, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(
got_id, want_id,
"seekhead entry for 0x{:X} resolves to wrong id 0x{:X}",
want_id, got_id
);
}
}
#[test]
fn seekhead_omits_chapters_when_empty() {
let tracks = [make_video_track(), make_audio_track()];
let (data, _) = mux_to_bytes(&tracks, &[], &frames_for(5.0, 1.0));
let entries = parse_seekhead(&data);
assert_eq!(
entries.len(),
3,
"expected 3 seek entries (Info, Tracks, Cues), got {}",
entries.len()
);
assert!(
entries.iter().all(|(id, _)| *id != ebml::CHAPTERS),
"seekhead should not contain Chapters entry when chapters are empty"
);
}
// Collect every (cluster_ts, block_relative_ts_i16, absolute_ts) for all
// SimpleBlocks, so a test can assert the reconstructed absolute
// timestamp is correct and no relative_ts ever wrapped the i16 range.
fn all_block_timestamps(data: &[u8]) -> Vec<(i64, i16, i64)> {
let mut out = Vec::new();
for (body_start, body_size, cluster_ts) in find_clusters(data) {
let body = &data[body_start..body_start + body_size as usize];
let mut cursor = Cursor::new(body);
// Skip CLUSTER_TIMESTAMP.
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
while (cursor.position() as usize) < body.len() {
let (id, sz, _) = ebml::read_element_header(&mut cursor).unwrap();
if id == ebml::SIMPLE_BLOCK {
let bstart = cursor.position() as usize;
let b0 = body[bstart];
let vint_len = if b0 & 0x80 != 0 { 1 } else { 2 };
let ts_pos = bstart + vint_len;
let rel = i16::from_be_bytes([body[ts_pos], body[ts_pos + 1]]);
out.push((cluster_ts as i64, rel, cluster_ts as i64 + rel as i64));
}
cursor.seek(io::SeekFrom::Current(sz as i64)).unwrap();
}
}
out
}
#[test]
fn long_audio_gap_forces_cluster_no_i16_overflow() {
// Regression for `(pts_ms - cluster_ts_ms) as i16` truncation: one keyframe at
// t=0 opens a cluster, then an audio-only stretch drifts past i16::MAX ms
// (~32.767s); without the overflow guard blocks would write a wrapped relative ts.
let tracks = [make_video_track(), make_audio_track()];
let mut frames: Vec<(usize, i64, bool, Vec<u8>)> = Vec::new();
// One video keyframe at t=0 (opens the first cluster).
frames.push((0, 0, true, vec![0xAB; 16]));
// Audio frames every 100 ms out to 60 s — past the 32.767 s i16 limit
// and past two i16 spans, with NO further video keyframe.
let mut t_ms = 0i64;
while t_ms <= 60_000 {
frames.push((1, t_ms * 1_000_000, true, vec![0xCD; 16]));
t_ms += 100;
}
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let blocks = all_block_timestamps(&data);
assert!(!blocks.is_empty());
// Every block's relative timestamp must be within i16 range (it is by
// type), AND must reconstruct to a non-negative, monotonic-ish
// absolute timestamp matching the source — i.e. no silent wrap.
for (cluster_ts, rel, abs) in &blocks {
assert!(
*rel as i64 >= 0 && (*rel as i64) <= MAX_BLOCK_REL,
"block relative_ts {rel} out of [0, i16::MAX] range \
(cluster_ts={cluster_ts}, abs={abs}) — i16 overflow"
);
}
// The latest audio frame is at 60_000 ms = 600_000 ticks (0.1 ms scale);
// its reconstructed absolute timestamp must equal that, proving no
// truncation occurred.
let max_abs = blocks.iter().map(|(_, _, abs)| *abs).max().unwrap();
let tick = |ms: i64| ms * 1_000_000 / TIMESTAMP_SCALE_NS;
assert_eq!(
max_abs,
tick(60_000),
"last block must reconstruct to 600_000 ticks (60_000 ms)"
);
// The overflow guard must have opened more than one cluster (the
// single keyframe alone would otherwise yield exactly one).
let clusters = find_clusters(&data);
assert!(
clusters.len() >= 2,
"expected the i16 guard to force extra clusters, got {}",
clusters.len()
);
// CLUSTER-SPLIT CUE REGRESSION: an i16-forced split cluster is NOT
// keyframe-aligned, yet MUST still carry a Cue or a player seeking into
// the long audio stretch lands in a multi-second seek-index hole.
let cues = parse_cues(&data);
assert_eq!(
cues.len(),
clusters.len(),
"every i16-split cluster must emit a cue (cues {} != clusters {})",
cues.len(),
clusters.len()
);
// And the cue times must cover the full span, including past the first
// i16 boundary (~3.27 s), so the back half of the stream is seekable.
let max_cue = cues.iter().map(|(t, _, _)| *t).max().unwrap() as i64;
assert!(
max_cue > MAX_BLOCK_REL,
"cue coverage must extend past the first i16 boundary, max cue {max_cue}"
);
// A forced-split cue must be as usable as a keyframe cue: CueClusterPosition is
// SEGMENT-RELATIVE and must land on its cluster, and CueTrack must be the
// 1-based TrackNumber that forced the split — else the seek index points at nothing.
let (_, seg_start) = locate_segment(&data);
let cluster_starts: Vec<usize> = clusters
.iter()
.map(|(body_start, _, _)| {
// Walk back from the body to the element start by re-reading the
// header at the recorded cluster position instead of guessing.
*body_start
})
.collect();
for (_time, track, pos) in &cues {
let abs = seg_start + *pos as usize;
let mut cursor = Cursor::new(&data[abs..]);
let (id, _size, hdr_len) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(
id,
ebml::CLUSTER,
"i16-split cue position 0x{pos:X} must resolve to a Cluster"
);
assert!(
cluster_starts.contains(&(abs + hdr_len)),
"cue position 0x{pos:X} must name one of the real clusters"
);
assert!(
*track == 1 || *track == 2,
"CueTrack must be a 1-based TrackNumber (1 = video, 2 = audio), got {track}"
);
}
// At least one cue must belong to the AUDIO track: every cluster after the first
// is opened by an audio frame. A `track_idx * 1` slip would report 0 (no track)
// and a `track_idx - 1` slip would underflow or report the video track.
assert!(
cues.iter().any(|(_, track, _)| *track == 2),
"the audio-forced splits must emit cues naming the audio TrackNumber (2)"
);
}
#[test]
fn pre_first_keyframe_frames_dropped() {
let tracks = [make_video_track()];
let frames = vec![
(0usize, 0i64, false, vec![0x11; 16]),
(0usize, 41_000_000i64, true, vec![0x22; 16]),
];
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
assert_eq!(frame_count, 1, "muxer.frame_count must equal 1");
let clusters = find_clusters(&data);
assert_eq!(clusters.len(), 1, "expected exactly one cluster");
let (body_start, body_size, _ts) = clusters[0];
let body = &data[body_start..body_start + body_size as usize];
let mut cursor = Cursor::new(body);
// Skip CLUSTER_TIMESTAMP.
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
let mut sb_count = 0;
while (cursor.position() as usize) < body.len() {
let (id, sz, _) = ebml::read_element_header(&mut cursor).unwrap();
if id == ebml::SIMPLE_BLOCK {
sb_count += 1;
}
cursor.seek(io::SeekFrom::Current(sz as i64)).unwrap();
}
assert_eq!(sb_count, 1, "expected exactly one SimpleBlock in output");
}
#[test]
fn no_track0_keyframe_yields_error_not_empty_file() {
// If track 0 never delivers a keyframe, every frame is dropped. finish()
// must surface this rather than emitting a structurally valid empty MKV.
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, 0.0, &[]).unwrap();
// Audio frames (track 1) and non-keyframe video — no track-0 keyframe.
muxer
.write_frame(1, 0, true, &[0xAA; 8], None, None)
.unwrap();
muxer
.write_frame(0, 10_000_000, false, &[0xBB; 8], None, None)
.unwrap();
muxer
.write_frame(1, 20_000_000, true, &[0xCC; 8], None, None)
.unwrap();
let err = muxer.finish().unwrap_err();
// Exactly MkvInvalid: the code that marks an empty title as a skippable stub.
assert_eq!(
crate::error::error_code(&err),
Some(crate::error::E_MKV_INVALID)
);
assert!(crate::error::is_skippable_title_stub(&err));
}
// A seam plan that drops MORE frames than were written fails SeamPlanDroppedMost; dropping
// exactly as many as were written still succeeds.
#[test]
fn seam_plan_dropping_most_frames_fails_the_mux() {
let clip = |id: &str, secs: u32| crate::disc::Clip {
feed_span: None,
clip_id: id.into(),
in_time: secs * 45_000,
out_time: (secs + 100) * 45_000,
duration_secs: 100.0,
source_packets: 0,
};
let clips = [clip("00000", 100), clip("00001", 200)];
let run = |kept: &[i64]| {
let mut m = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
100.0,
&[],
)
.unwrap();
m.set_clips(&clips, crate::disc::ContentFormat::BdTs);
// 0 s and 1 s sit before the first IN mark: both dropped.
for pts in [0, 1_000_000_000].iter().chain(kept) {
let _ = m.write_frame(0, *pts, true, &[0x01; 16], None, None);
}
m.finish().map(|_| ())
};
let e = run(&[150_000_000_000]).unwrap_err();
assert_eq!(
crate::error::error_code(&e),
Some(crate::error::E_SEAM_PLAN_DROPPED_MOST)
);
run(&[150_000_000_000, 151_000_000_000]).expect("2 dropped, 2 written");
}
#[test]
fn finish_with_no_frames_errors() {
// A muxer that received no frames at all must surface MkvInvalid on
// finish() rather than writing a structurally-empty MKV.
let buf = Cursor::new(Vec::new());
let tracks = [make_video_track()];
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
let err = muxer.finish().unwrap_err();
assert_eq!(
crate::error::error_code(&err),
Some(crate::error::E_MKV_INVALID)
);
assert!(crate::error::is_skippable_title_stub(&err));
}
// A seam-plan clip list whose marks exclude every frame must fail with `SinkWroteNothing`,
// NOT `MkvInvalid`.
#[test]
fn a_title_the_seam_plan_emptied_is_not_reported_as_a_skippable_stub() {
let tracks = [make_video_track()];
// TWO clips (SeamPlan::from_clips requires >=2, strictly increasing IN marks);
// every frame below sits far before the first IN, so the plan drops them all.
let clips = [
crate::disc::Clip {
feed_span: None,
clip_id: "00000".into(),
in_time: 100 * 45_000,
out_time: 200 * 45_000,
duration_secs: 100.0,
source_packets: 0,
},
crate::disc::Clip {
feed_span: None,
clip_id: "00001".into(),
in_time: 200 * 45_000,
out_time: 300 * 45_000,
duration_secs: 100.0,
source_packets: 0,
},
];
let mut muxer = MkvMuxer::new(Cursor::new(Vec::new()), &tracks, None, 100.0, &[]).unwrap();
muxer.set_clips(&clips, crate::disc::ContentFormat::BdTs);
// 0s and 1s — both before the clip's IN mark.
let _ = muxer.write_frame(0, 0, true, &[0x01; 16], None, None);
let _ = muxer.write_frame(0, 1_000_000_000, true, &[0x02; 16], None, None);
let err = muxer.finish().unwrap_err();
assert_eq!(
crate::error::error_code(&err),
Some(crate::error::E_SINK_WROTE_NOTHING),
"an emptied seam title must report SinkWroteNothing"
);
assert!(
!crate::error::is_skippable_title_stub(&err),
"it must NOT be classified as a skippable stub — that is what makes an \
all-titles rip drop a real feature and still exit 0"
);
}
#[test]
fn backjumped_audio_handled_by_i16_split_no_wrap() {
// A NON-VIDEO frame back-jumping below the open cluster does NOT drive an
// epoch (only video does, per the rc3 fix); instead the i16 block-relative
// guard forces a fresh Cue-carrying cluster floored at t=0, avoiding wrap.
let tracks = [make_video_track(), make_audio_track()];
let frames = vec![
(0usize, 0i64, true, vec![0x01; 16]),
(0usize, 40_000_000_000i64, true, vec![0x02; 16]), // 40s
(1usize, 0i64, true, vec![0x03; 16]), // back-jumped audio
];
let (data, frame_count) = mux_to_bytes(&tracks, &[], &frames);
assert_eq!(frame_count, 3);
let clusters = find_clusters(&data);
// Cluster timestamps stay non-negative (the `as u64` write is safe) — the
// back-jumped audio's cluster is floored at 0, never wraps.
let ts: Vec<u64> = clusters.iter().map(|(_, _, t)| *t).collect();
for t in &ts {
assert!(*t <= i64::MAX as u64, "cluster ts must not have wrapped");
}
// Every block's cluster ts + i16 rel reconstructs its own pts (0.1 ms ticks): a
// wrapped rel from the 40s back-jump would land the audio elsewhere.
let abs: Vec<i64> = all_block_timestamps(&data)
.iter()
.map(|&(_, _, abs)| abs)
.collect();
assert_eq!(abs, vec![0, 400_000, 0], "video 0 s, video 40 s, audio 0 s");
// Every cluster carries a Cue (including the back-dated split cluster),
// so the seek index has no hole.
assert_eq!(
parse_cues(&data).len(),
clusters.len(),
"every cluster (incl. the i16-split) must have a cue"
);
}
#[test]
fn negative_pts_audio_after_keyframe_does_not_wrap() {
// Video keyframe at 5s anchors base=5000ms; audio at raw 4s gives
// raw_ms - base = -1000ms, which must floor to 0 rather than wrapping the
// `as u64` cluster/cue write or overflowing the i16 relative cast.
let tracks = [make_video_track(), make_audio_track()];
let frames_in_order = [
(0usize, 5_000_000_000i64, true, vec![0xBB; 16]), // video kf at 5s
(1usize, 4_000_000_000i64, true, vec![0xAA; 8]), // audio at 4s (< base)
];
// Do NOT sort — preserve the out-of-order arrival.
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
for (t, pts, kf, data) in &frames_in_order {
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
let clusters = find_clusters(&data);
assert!(!clusters.is_empty());
for (_, _, ts) in &clusters {
// A wrapped negative would be a huge near-u64::MAX value.
assert!(*ts < 1_000_000_000, "cluster timestamp wrapped: {}", ts);
}
}
#[test]
fn track_vint_encodes_one_two_and_three_byte_forms() {
// 1-byte form for track numbers < 0x80, high bit set.
let (b, n) = track_vint(1);
assert_eq!(&b[..n], &[0x81]);
let (b, n) = track_vint(0x7F);
assert_eq!(&b[..n], &[0xFF]);
// 2-byte form at/above 0x80, 0x40 length marker in the top byte.
let (b, n) = track_vint(0x80);
assert_eq!(&b[..n], &[0x40, 0x80]);
let (b, n) = track_vint(0x3FFF);
assert_eq!(&b[..n], &[0x7F, 0xFF]);
// 3-byte form at/above 0x4000, 0x20 length marker in the top byte —
// handled in RELEASE (no silent marker-bit clobber), not just debug.
let (b, n) = track_vint(0x4000);
assert_eq!(&b[..n], &[0x20, 0x40, 0x00]);
let (b, n) = track_vint(0x1F_FFFF);
assert_eq!(&b[..n], &[0x3F, 0xFF, 0xFF]);
}
// SimpleBlock byte layout (Matroska §6.2.3): declared size must equal
// track_vint_len + 2 (rel ts) + 1 (flags) + data, with rel-ts a signed 16-bit
// big-endian field. A wrong size desyncs elements; wrong byte order corrupts A/V sync.
/// Locate the first SimpleBlock and return (declared_size, track_vint_len,
/// rel_ts, flags, data_slice) by decoding its header inline.
fn first_simple_block_full(data: &[u8]) -> (u64, usize, i16, u8, Vec<u8>) {
let clusters = find_clusters(data);
let (body_start, body_size, _ts) = clusters[0];
let body = &data[body_start..body_start + body_size as usize];
let mut cursor = Cursor::new(body);
// Skip CLUSTER_TIMESTAMP.
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
loop {
let (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
if id == ebml::SIMPLE_BLOCK {
let p = cursor.position() as usize;
let b0 = body[p];
let vl = if b0 & 0x80 != 0 { 1 } else { 2 };
let rel = i16::from_be_bytes([body[p + vl], body[p + vl + 1]]);
let flags = body[p + vl + 2];
let dat = body[p + vl + 3..p + size as usize].to_vec();
return (size, vl, rel, flags, dat);
}
cursor.seek(io::SeekFrom::Current(size as i64)).unwrap();
}
}
/// A frame for `mux_with_durations`: (track, pts_ns, keyframe, data,
/// duration_ns). Aliased to keep clippy's type-complexity lint happy.
type DurFrame = (usize, i64, bool, Vec<u8>, Option<u64>);
/// Mux frames through a SharedWriter and return the finalized buffer, so
/// the final cluster is closed (size back-patched) before inspection.
fn mux_with_durations(tracks: &[MkvTrack], frames: &[DurFrame]) -> Vec<u8> {
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, &[]).unwrap();
for (t, pts, kf, data, dur) in frames {
muxer.write_frame(*t, *pts, *kf, data, *dur, None).unwrap();
}
muxer.finish().unwrap();
shared.lock().unwrap().clone().into_inner()
}
#[test]
fn simple_block_declared_size_covers_exactly_the_payload() {
let tracks = [make_video_track()];
let payload = vec![0x11u8, 0x22, 0x33, 0x44, 0x55];
let data = mux_with_durations(&tracks, &[(0, 0, true, payload.clone(), None)]);
let (size, vl, rel, flags, dat) = first_simple_block_full(&data);
// size = vint(vl) + ts(2) + flags(1) + data(5).
assert_eq!(size as usize, vl + 2 + 1 + payload.len());
assert_eq!(rel, 0, "first frame at cluster base → rel ts 0");
assert_eq!(flags & 0x80, 0x80, "keyframe flag set");
assert_eq!(dat, payload, "data must be the exact frame bytes");
}
#[test]
fn simple_block_rel_ts_is_signed_big_endian() {
// A frame 1000 ms after the keyframe-anchored cluster (within the 3 s
// cluster window) must encode rel ts = 1000 ms in TICKS = 10_000.
let tracks = [make_video_track()];
let data = mux_with_durations(
&tracks,
&[
(0, 0, true, vec![0xAA], None),
(0, 1_000_000_000, false, vec![0xBB], None),
],
);
// The second block is in the same cluster (1000ms < 3000ms boundary, and
// 10_000 ticks < the i16 span).
let clusters = find_clusters(&data);
assert_eq!(clusters.len(), 1, "1s < 3s cluster window → one cluster");
let blocks = all_block_timestamps(&data);
// Two blocks: rel 0 and rel 10_000 (1000 ms at the 0.1 ms scale).
let rels: Vec<i16> = blocks.iter().map(|(_, r, _)| *r).collect();
assert!(
rels.contains(&10_000),
"second block rel ts must be 10_000 ticks"
);
}
// Open-GOP B-frames shown before the cluster's keyframe carry a NEGATIVE rel ts, and the
// positive i16 edge 32767 stays in the cluster. (A step back past the 3 s discontinuity
// threshold is re-based, so -32768 is not reachable for video.)
#[test]
fn simple_block_rel_ts_negative_and_i16_edge() {
let data = mux_with_durations(
&[make_video_track()],
&[
(0, 0, true, vec![0xAA], None),
(0, 4_000_000_000, true, vec![0xAB], None), // cluster 2 at 40_000 ticks
(0, 3_600_000_000, false, vec![0xAC], None), // -4_000
(0, 1_100_000_000, false, vec![0xAD], None), // -29_000
(0, 7_276_700_000, false, vec![0xAE], None), // 40_000 + 32_767
],
);
let second: Vec<(i16, i64)> = all_block_timestamps(&data)
.iter()
.filter(|&&(c, _, _)| c == 40_000)
.map(|&(_, r, a)| (r, a))
.collect();
assert_eq!(
second,
vec![
(0, 40_000),
(-4_000, 36_000),
(-29_000, 11_000),
(32_767, 72_767)
]
);
}
// BlockGroup (Matroska §6.2.4): a Block inside a BlockGroup carries BlockDuration,
// and its keyframe flag bit (0x80) MUST be 0 (keyframe-ness is signalled by
// absence of ReferenceBlock). PGS subtitle frames take this path.
/// One parsed BlockGroup.
struct BlockGroupInfo {
/// Inner BLOCK payload after the track vint, timestamp and flags.
data: Vec<u8>,
/// BlockDuration in ticks, or 0 when the element is absent.
duration: u64,
/// The Block's flags byte (0xFF when no Block was seen).
flags: u8,
/// Block-relative timestamp (signed, from the Block header).
rel_ts: i16,
/// The ReferenceBlock value in ticks, or `None` when the element is
/// absent — which is precisely how Matroska signals a keyframe inside a
/// BlockGroup (RFC 9559 §5.1.3.5).
reference: Option<i64>,
/// The Block's Matroska TrackNumber (1-based), decoded from its leading
/// VINT. A block filed under the wrong (or a nonexistent) TrackNumber is
/// a dropped track, not a malformed file — players simply ignore it.
track: u64,
}
// Parse EVERY BlockGroup in the output, in emission order — a presence check alone can't
// say WHICH blocks carry ReferenceBlock.
fn all_block_groups(data: &[u8]) -> Vec<BlockGroupInfo> {
let mut out = Vec::new();
for (body_start, body_size, _ts) in find_clusters(data) {
let body = &data[body_start..body_start + body_size as usize];
let mut cursor = Cursor::new(body);
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
while (cursor.position() as usize) < body.len() {
// Stop at a non-whole-element tail (padding/buffer end) rather than
// unwrapping; callers assert the BlockGroup COUNT, so a desync here
// fails there instead of passing quietly.
let Ok((id, size, _)) = ebml::read_element_header(&mut cursor) else {
break;
};
let start = cursor.position() as usize;
if start + size as usize > body.len() {
break;
}
if id == ebml::BLOCK_GROUP {
let bg = &body[start..start + size as usize];
let mut bc = Cursor::new(bg);
let mut info = BlockGroupInfo {
data: Vec::new(),
duration: 0,
flags: 0xFF,
rel_ts: 0,
reference: None,
track: 0,
};
while (bc.position() as usize) < bg.len() {
let (cid, cs, _) = ebml::read_element_header(&mut bc).unwrap();
let cstart = bc.position() as usize;
if cid == ebml::BLOCK {
let blk = &bg[cstart..cstart + cs as usize];
let vl = if blk[0] & 0x80 != 0 { 1 } else { 2 };
info.track = if vl == 1 {
(blk[0] & 0x7F) as u64
} else {
(((blk[0] & 0x3F) as u64) << 8) | blk[1] as u64
};
info.rel_ts = i16::from_be_bytes([blk[vl], blk[vl + 1]]);
info.flags = blk[vl + 2];
info.data = blk[vl + 3..].to_vec();
} else if cid == ebml::BLOCK_DURATION {
info.duration = ebml::read_uint_val(&mut bc, cs as usize).unwrap();
continue;
} else if cid == ebml::REFERENCE_BLOCK {
// Two's-complement big-endian, minimal width: sign-extend
// from the top bit of the first byte.
let raw = &bg[cstart..cstart + cs as usize];
let mut v: i64 = if raw[0] & 0x80 != 0 { -1 } else { 0 };
for &b in raw {
v = (v << 8) | b as i64;
}
info.reference = Some(v);
}
bc.seek(io::SeekFrom::Current(cs as i64)).unwrap();
}
out.push(info);
}
cursor.seek(io::SeekFrom::Current(size as i64)).unwrap();
}
}
out
}
fn first_block_group(data: &[u8]) -> (Vec<u8>, u64, u8) {
// Returns (inner BLOCK payload bytes after vint+ts+flags, block_duration_ms, flags).
let g = all_block_groups(data);
let first = g.first().expect("no BlockGroup found");
(first.data.clone(), first.duration, first.flags)
}
#[test]
fn block_group_emits_block_duration_and_clears_keyframe_flag() {
// A frame written with a duration becomes a BlockGroup. The inner Block
// MUST have flags 0x00 (the 0x80 keyframe bit is reserved/zero inside a
// BlockGroup per the spec), and BlockDuration must equal the ms value.
let tracks = [make_video_track()];
// Open a cluster with a keyframe (track 0), then a frame carrying a
// duration. Pass keyframe=true to prove the flag is still forced to 0.
let data = mux_with_durations(
&tracks,
&[
(0, 0, true, vec![0xAA], None),
(0, 40_000_000, true, vec![0xCC, 0xDD], Some(40_000_000)),
],
);
let (block_data, dur_ticks, flags) = first_block_group(&data);
assert_eq!(block_data, vec![0xCC, 0xDD]);
// BlockDuration is in ticks: 40_000_000 ns / 100_000 = 400 ticks (40 ms).
assert_eq!(dur_ticks, 400, "BlockDuration must be 400 ticks (40 ms)");
assert_eq!(
flags & 0x80,
0x00,
"Block inside BlockGroup must clear the keyframe flag (got 0x{flags:02X})"
);
}
#[test]
fn block_duration_floored_to_at_least_one_tick() {
// A sub-tick duration (e.g. 50_000 ns = 0.05 ms, under the 0.1 ms tick)
// must floor to 1 tick, never 0 — a 0-duration BlockGroup would tell
// players to remove the artifact instantly.
let tracks = [make_video_track()];
let data = mux_with_durations(
&tracks,
&[
(0, 0, true, vec![0xAA], None),
(0, 10_000_000, true, vec![0xBB], Some(50_000)),
],
);
let (_, dur_ticks, _) = first_block_group(&data);
assert_eq!(
dur_ticks, 1,
"sub-tick duration must floor to 1 tick, not 0"
);
}
// ── issue #52: every subtitle block carries a BlockDuration ──────────────
fn make_subtitle_track() -> MkvTrack {
MkvTrack {
track_type: ebml::TRACK_TYPE_SUBTITLE,
codec_id: ebml::CODEC_PGS,
language: "eng".into(),
name: String::new(),
codec_private: None,
is_default: false,
is_forced: false,
pixel_width: 0,
pixel_height: 0,
default_duration_ns: 0,
display_width: 0,
display_height: 0,
colour_matrix: 0,
colour_transfer: 0,
colour_primaries: 0,
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
/// Matroska TrackNumbers (1-based) of every SimpleBlock in the output, in
/// emission order. The subtitle invariant is that NONE of these is a
/// subtitle track.
fn all_simple_block_tracks(data: &[u8]) -> Vec<u64> {
let mut out = Vec::new();
for (body_start, body_size, _ts) in find_clusters(data) {
let body = &data[body_start..body_start + body_size as usize];
let mut cursor = Cursor::new(body);
let (tid, tsize, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(tid, ebml::CLUSTER_TIMESTAMP);
cursor.seek(io::SeekFrom::Current(tsize as i64)).unwrap();
while (cursor.position() as usize) < body.len() {
let Ok((id, size, _)) = ebml::read_element_header(&mut cursor) else {
break;
};
let start = cursor.position() as usize;
if start + size as usize > body.len() {
break;
}
if id == ebml::SIMPLE_BLOCK {
let blk = &body[start..start + size as usize];
let track = if blk[0] & 0x80 != 0 {
(blk[0] & 0x7F) as u64
} else {
(((blk[0] & 0x3F) as u64) << 8) | blk[1] as u64
};
out.push(track);
}
cursor.seek(io::SeekFrom::Current(size as i64)).unwrap();
}
}
out
}
#[test]
fn subtitle_frame_with_no_duration_becomes_block_group_with_block_duration() {
// issue #52: a SUBTITLE frame with duration=None must never be a bare
// SimpleBlock (no DefaultDuration => unbounded cue). The muxer substitutes a
// minimum fallback and emits a BlockGroup WITH a BlockDuration.
let tracks = [make_subtitle_track()];
let data = mux_with_durations(&tracks, &[(0, 0, true, vec![0x16, 0xAA], None)]);
let groups = all_block_groups(&data);
assert_eq!(
groups.len(),
1,
"the subtitle frame must be a BlockGroup, not a SimpleBlock"
);
assert!(
groups[0].duration > 0,
"the BlockGroup must carry a BlockDuration (got {})",
groups[0].duration
);
assert!(
all_simple_block_tracks(&data).is_empty(),
"no subtitle frame may be emitted as a bare SimpleBlock"
);
}
#[test]
fn every_block_on_both_subtitle_tracks_has_a_block_duration() {
// Models a Blu-ray's two PGS tracks: a DENSE dialogue track and a SPARSE
// forced-narrative one. EVERY block on BOTH must be a BlockGroup carrying a
// BlockDuration, even those handed in with duration=None.
let tracks = [make_subtitle_track(), make_subtitle_track()];
let frames: Vec<DurFrame> = vec![
// Track 0 (dense): opens the cluster, then two more, mixing a real
// duration with two None durations.
(0, 0, true, vec![0x16, 0x01], Some(2_000_000_000)),
(0, 1_000_000_000, true, vec![0x16, 0x02], None),
(0, 2_000_000_000, true, vec![0x16, 0x03], None),
// Track 1 (sparse): a single forced cue, handed in with no duration.
(1, 500_000_000, true, vec![0x16, 0xFF], None),
];
let data = mux_with_durations(&tracks, &frames);
assert!(
all_simple_block_tracks(&data).is_empty(),
"no subtitle frame on EITHER track may be a bare SimpleBlock"
);
let groups = all_block_groups(&data);
assert_eq!(groups.len(), 4, "all four subtitle frames are BlockGroups");
for g in &groups {
assert!(
g.duration > 0,
"every subtitle block must carry a BlockDuration (track {}, dur {})",
g.track,
g.duration
);
}
// Both subtitle tracks (Matroska TrackNumbers 1 and 2) are represented.
assert!(groups.iter().any(|g| g.track == 1), "dense track present");
assert!(groups.iter().any(|g| g.track == 2), "sparse track present");
}
// A VobSub SPU: 4-byte header, 2 pixel bytes, then a STA_DSP DCSQ (delay 0)
// chained to a STP_DSP DCSQ at `stop_delay` (units of 1024/90000 s).
fn vobsub_spu(stop_delay: u16) -> Vec<u8> {
let mut spu = vec![0, 18, 0, 6, 0xAB, 0xCD];
spu.extend_from_slice(&[0, 0, 0, 12, 0x01, 0xFF]);
spu.extend_from_slice(&stop_delay.to_be_bytes());
spu.extend_from_slice(&[0, 12, 0x02, 0xFF]);
spu
}
#[test]
fn vobsub_block_duration_comes_from_the_spu_stop_command() {
let mut track = make_subtitle_track();
track.codec_id = ebml::CODEC_VOBSUB;
// 0x0100 * 1024 / 90 kHz = 2.9127 s -> 29_127 ticks at 0.1 ms.
let data = mux_with_durations(&[track], &[(0, 0, true, vobsub_spu(0x0100), None)]);
let groups = all_block_groups(&data);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].duration, 29_127, "SPU stop time, not a 1 s guess");
}
// A VobSub SPU with no stop command ends at the next SPU on its track, else
// after a 10 s cap.
#[test]
fn vobsub_without_a_stop_ends_at_the_next_spu_or_the_cap() {
let mut track = make_subtitle_track();
track.codec_id = ebml::CODEC_VOBSUB;
// One DCSQ at offset 6: delay 0, next = itself, STA_DSP, CMD_END.
let spu = vec![0, 12, 0, 6, 0xAB, 0xCD, 0, 0, 0, 6, 0x01, 0xFF];
let data = mux_with_durations(
&[track],
&[
(0, 0, true, spu.clone(), None),
(0, 2_000_000_000, true, spu, None),
],
);
let groups = all_block_groups(&data);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].duration, 20_000, "ends at the next SPU");
assert_eq!(groups[1].duration, 100_000, "last one capped at 10 s");
}
#[test]
fn a_durationless_pgs_block_is_not_cut_to_one_second() {
let data = mux_with_durations(
&[make_subtitle_track()],
&[(0, 0, true, vec![0x16, 0xAA], None)],
);
let groups = all_block_groups(&data);
assert_eq!(groups.len(), 1);
assert!(
groups[0].duration > 10_000,
"a self-terminating cue must not be hidden after 1 s (got {} ticks)",
groups[0].duration
);
}
// Cluster boundary (CLUSTER_DURATION_TICKS): a new cluster opens on a video
// keyframe once >= the cluster duration has elapsed since the open cluster's
// timestamp; one just under the boundary stays in the current cluster.
#[test]
fn keyframe_at_cluster_boundary_opens_new_cluster() {
let tracks = [make_video_track()];
// Keyframe at exactly 2000 ms (== the 2 s cluster window) → new cluster.
let data = mux_with_durations(
&tracks,
&[
(0, 0, true, vec![0xAA], None),
(0, 2_000_000_000, true, vec![0xBB], None),
],
);
assert_eq!(
find_clusters(&data).len(),
2,
"keyframe at the 2s boundary must open a second cluster"
);
}
#[test]
fn keyframe_just_under_cluster_window_stays_in_cluster() {
let tracks = [make_video_track()];
// Keyframe at 1999 ms (< 2000 nominal, and 19990 ticks < i16 span) → same
// cluster.
let data = mux_with_durations(
&tracks,
&[
(0, 0, true, vec![0xAA], None),
(0, 1_999_000_000, true, vec![0xBB], None),
],
);
assert_eq!(
find_clusters(&data).len(),
1,
"keyframe under the cluster window must stay in the open cluster"
);
}
// monotonic_ts saturating add — at i64::MAX the +1 must saturate, not
// overflow-panic; the strictly-monotonic invariant relies on saturating_add.
#[test]
fn monotonic_ts_saturates_at_i64_max() {
// prev = i64::MAX, pts equal → saturating_add(1) caps at i64::MAX rather
// than wrapping to i64::MIN.
assert_eq!(monotonic_ts(Some(i64::MAX), i64::MAX), i64::MAX);
// A pts already above prev+1 is left alone.
assert_eq!(monotonic_ts(Some(10), 100), 100);
}
// SeekHead entries use fixed-width SeekID (4-byte binary, 0x84) and SeekPosition (8-byte
// uint, 0x88) so they can be back-patched in place. dolby_vision_config (dvcC) bit packing:
#[test]
fn dolby_vision_config_packs_level_and_compat_id() {
// profile 7, level 6 (0b00110), bl_compat_id 1.
let c = dolby_vision_config(7, 6, 1);
assert_eq!(c.len(), 24);
// level high bit = (6 >> 5) & 1 = 0 → byte2 low bit 0; profile 7 in top.
// byte2 = profile(7) << 1 | level_high_bit(0).
assert_eq!(c[2], 7 << 1);
assert_eq!(c[2] & 0x01, 0, "level bit 5 is 0 for level 6");
// byte3: (6 & 0x1F) << 3 | rpu|el|bl = (6<<3) | 0b111 = 0x30 | 0x07.
assert_eq!(c[3], (6 << 3) | 0b111);
// byte4: bl_compat_id 1 in the top nibble.
assert_eq!(c[4], 1 << 4);
// Reserved tail is zero.
assert!(c[5..].iter().all(|&b| b == 0), "v[5..24] reserved = 0");
}
#[test]
fn dolby_vision_config_high_level_sets_byte2_low_bit() {
// A level with bit 5 set (>= 32) must place that bit in byte2's LSB.
// level 0x20 → (0x20 >> 5) & 1 = 1.
let c = dolby_vision_config(7, 0x20, 0);
assert_eq!(c[2] & 0x01, 1, "level bit 5 belongs in byte2 LSB");
// and byte3 carries the low 5 bits (0x20 & 0x1F = 0) << 3.
assert_eq!(c[3] >> 3, 0);
}
// Full round-trip: mux frames -> MKV bytes -> MkvStream reader -> frames. The
// strongest "never silently truncate" property: every written frame must be
// readable back with the same track, keyframe flag and data.
#[test]
fn muxed_frames_round_trip_through_reader() {
use crate::pes::Stream as _;
let tracks = [make_video_track(), make_audio_track()];
// Two video keyframes + interleaved audio, all within one cluster.
let frames = vec![
(0usize, 0i64, true, vec![0x01, 0x02, 0x03]),
(1usize, 0i64, false, vec![0x0B, 0x77, 0x00]),
(0usize, 1_000_000_000i64, false, vec![0x04, 0x05]),
];
let (data, count) = mux_to_bytes(&tracks, &[], &frames);
assert_eq!(count, 3, "all three frames must be written");
let mut stream = super::super::mkvstream::MkvStream::open(Cursor::new(data)).unwrap();
let mut read_back = Vec::new();
while let Some(f) = stream.read().unwrap() {
read_back.push((f.track, f.keyframe, f.data));
}
// All three frames survive the round trip (no silent drop/truncation).
assert_eq!(read_back.len(), 3, "every muxed frame must read back");
// Track 0 video keyframe with its exact bytes is present.
assert!(
read_back
.iter()
.any(|(t, kf, d)| *t == 0 && *kf && d == &[0x01, 0x02, 0x03])
);
// Track 1 audio frame bytes survive.
assert!(
read_back
.iter()
.any(|(t, _, d)| *t == 1 && d == &[0x0B, 0x77, 0x00])
);
}
#[test]
fn audio_track_emits_sampling_frequency_and_channels() {
// An audio TrackEntry must contain an Audio element (0xE1) with
// SamplingFrequency (0xB5, an 8-byte float) and Channels (0x9F).
// Without these, players can't configure the audio decoder.
let tracks = [make_video_track(), make_audio_track()];
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &tracks, None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::AUDIO).is_some(),
"Audio element present"
);
assert!(
find_id(&data, ebml::SAMPLING_FREQUENCY).is_some(),
"SamplingFrequency present"
);
assert!(find_id(&data, ebml::CHANNELS).is_some(), "Channels present");
}
#[test]
fn video_colour_element_emitted_only_when_hdr_metadata_present() {
// A video track with colour metadata (matrix/transfer) must emit the
// Colour element (0x55B0); a plain SDR track with all-zero colour must
// not. The conditional is `colour_matrix > 0 || colour_transfer > 0`.
let mut hdr_video = make_video_track();
hdr_video.colour_matrix = 9; // bt2020nc
hdr_video.colour_transfer = 16; // PQ
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[hdr_video], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::COLOUR).is_some(),
"Colour element must be emitted for HDR track"
);
// make_video_track has zero colour fields → no Colour element.
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::COLOUR).is_none(),
"no Colour element when colour metadata is all zero"
);
}
// HDR10 static metadata, when measured, is emitted inside Colour as
// MasteringMetadata + MaxCLL/MaxFALL — OMITTED for SDR. Asserts the
// exact H.265 → Matroska unit conversions.
#[test]
fn video_emits_hdr10_mastering_and_cll_with_correct_scaling() {
use crate::mux::codec::Hdr10Metadata;
// Raw SEI integers (SEI primary order G, B, R):
// primaries chromaticity ×0.00002, luminance ×0.0001, CLL/FALL verbatim.
let h = Hdr10Metadata {
display_primaries_x: [8500, 6550, 35400], // G, B, R
display_primaries_y: [39850, 2300, 14600],
white_point_x: 15635,
white_point_y: 16450,
max_display_mastering_luminance: 10_000_000, // 1000 cd/m²
min_display_mastering_luminance: 1, // 0.0001 cd/m²
max_content_light_level: 1000,
max_pic_average_light_level: 400,
};
let mut v = make_video_track();
v.colour_matrix = 9;
v.colour_transfer = 16;
v.colour_primaries = 9;
v.colour_range = 1;
v.hdr10 = Some(h);
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// Read the 8-byte BE f64 that follows a float element ID (+ 1-byte size).
let read_float = |id: u32| -> f64 {
let off = find_id(&data, id).unwrap_or_else(|| panic!("element {id:#x} present"));
assert_eq!(
data[off + 2],
0x88,
"float element {id:#x} declares 8 bytes"
);
let mut b = [0u8; 8];
b.copy_from_slice(&data[off + 3..off + 11]);
f64::from_be_bytes(b)
};
let read_uint = |id: u32| -> u64 {
let off = find_id(&data, id).unwrap_or_else(|| panic!("element {id:#x} present"));
let len = (data[off + 2] & 0x7F) as usize;
let mut val = 0u64;
for &byte in &data[off + 3..off + 3 + len] {
val = (val << 8) | byte as u64;
}
val
};
assert!(
find_id(&data, ebml::MASTERING_METADATA).is_some(),
"MasteringMetadata present"
);
// R is SEI index 2, G index 0, B index 1.
assert_eq!(
read_float(ebml::PRIMARY_R_CHROMATICITY_X),
35400.0 * 0.00002
);
assert_eq!(
read_float(ebml::PRIMARY_R_CHROMATICITY_Y),
14600.0 * 0.00002
);
assert_eq!(read_float(ebml::PRIMARY_G_CHROMATICITY_X), 8500.0 * 0.00002);
assert_eq!(
read_float(ebml::PRIMARY_G_CHROMATICITY_Y),
39850.0 * 0.00002
);
assert_eq!(read_float(ebml::PRIMARY_B_CHROMATICITY_X), 6550.0 * 0.00002);
assert_eq!(read_float(ebml::PRIMARY_B_CHROMATICITY_Y), 2300.0 * 0.00002);
assert_eq!(
read_float(ebml::WHITE_POINT_CHROMATICITY_X),
15635.0 * 0.00002
);
assert_eq!(
read_float(ebml::WHITE_POINT_CHROMATICITY_Y),
16450.0 * 0.00002
);
// Luminance: 10_000_000 × 0.0001 = 1000.0 cd/m²; 1 × 0.0001 = 0.0001.
assert_eq!(read_float(ebml::LUMINANCE_MAX), 1000.0);
assert_eq!(read_float(ebml::LUMINANCE_MIN), 0.0001);
// MaxCLL / MaxFALL are cd/m² uints, verbatim.
assert_eq!(read_uint(ebml::MAX_CLL), 1000);
assert_eq!(read_uint(ebml::MAX_FALL), 400);
}
/// An SDR track (no measured HDR10) must NOT emit MasteringMetadata, MaxCLL,
/// or MaxFALL — the metadata is never fabricated.
#[test]
fn sdr_video_omits_hdr10_metadata() {
// A track with CICP signalling but hdr10 = None: Colour is emitted, but
// none of the HDR10 children are.
let mut v = make_video_track();
v.colour_matrix = 1; // bt709
v.colour_transfer = 1;
assert!(v.hdr10.is_none());
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::COLOUR).is_some(),
"Colour still emitted"
);
assert!(
find_id(&data, ebml::MASTERING_METADATA).is_none(),
"no MasteringMetadata for SDR"
);
assert!(find_id(&data, ebml::MAX_CLL).is_none(), "no MaxCLL for SDR");
assert!(
find_id(&data, ebml::MAX_FALL).is_none(),
"no MaxFALL for SDR"
);
}
#[test]
fn video_emits_flag_interlaced_and_field_order() {
// 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_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");
// [id=0x9A][size=0x81][value]
assert_eq!(
data[fi + 2],
ebml::INTERLACED_INTERLACED as u8,
"FlagInterlaced must be 1 (interlaced)"
);
let fo = find_id(&data, ebml::FIELD_ORDER).expect("FieldOrder present");
assert_eq!(
data[fo + 2],
ebml::FIELD_ORDER_TFF,
"FieldOrder value must round-trip through the writer"
);
// Progressive track: FlagInterlaced=2, no FieldOrder.
let muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
let data = muxer.writer.into_inner();
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present");
assert_eq!(
data[fi + 2],
ebml::INTERLACED_PROGRESSIVE as u8,
"FlagInterlaced must be 2 (progressive)"
);
assert!(
find_id(&data, ebml::FIELD_ORDER).is_none(),
"no FieldOrder for progressive content"
);
}
#[test]
fn video_576i_field_order_undetermined_at_track_build() {
// Field order is a bitstream property the IFO/MPLS scan cannot know, so the
// track is built UNDETERMINED — never a scan-time guess; the mux stream sets
// the MEASURED value from the first coded picture (apply_coding_to_track).
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(),
measured_cicp: None,
};
let t = MkvTrack::video(&v);
assert!(t.interlaced, "576i is interlaced (FlagInterlaced=1)");
assert_eq!(
t.field_order,
ebml::FIELD_ORDER_UNDETERMINED,
"field order is not known at scan — set later from the measured picture"
);
}
#[test]
fn interlaced_576i_omits_default_decoded_field_duration_keeps_full_frame_duration() {
// Windows-fps regression: a 576i25 track must carry FULL-FRAME DefaultDuration
// (40ms -> 25fps) and NOT emit DefaultDecodedFieldDuration. rc.5.1's 20ms field
// duration made Explorer report 12.5fps/VFR; a known-good rip omits it (25fps/CFR).
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(),
measured_cicp: None,
};
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, 0,
"field duration must be 0 so the element is suppressed"
);
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 → drives the 25 fps report.
let dd = find_id(&data, ebml::DEFAULT_DURATION).expect("DefaultDuration present");
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 must be ABSENT — this is the fix.
assert!(
find_id(&data, ebml::DEFAULT_DECODED_FIELD_DURATION).is_none(),
"DefaultDecodedFieldDuration must NOT be written (Windows halves the rate when it is)"
);
// Interlace signalling is RETAINED so deinterlacers still engage and
// a media analyzer (which also reads scan type from the MPEG-2 ES) agrees.
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present");
assert_eq!(
data[fi + 2],
ebml::INTERLACED_INTERLACED as u8,
"FlagInterlaced=1 retained"
);
// FieldOrder is set at mux time from the first coded picture's measured
// field order. A track built directly (no measured picture) carries
// UNDETERMINED, so the element is omitted — never a scan-time guess.
assert!(
find_id(&data, ebml::FIELD_ORDER).is_none(),
"FieldOrder omitted until measured — no guess at track build"
);
}
#[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 field_duration_when_set_is_direct_trackentry_child_not_in_video() {
// GUARDS nesting for the retained (now non-default) writer path: if a caller
// sets field_duration_ns > 0, DefaultDecodedFieldDuration must be a DIRECT
// child of TrackEntry, NOT nested in Video, per the Matroska schema.
let mut t = make_video_track();
t.interlaced = true;
t.field_order = ebml::FIELD_ORDER_TFF;
t.field_duration_ns = 20_000_000;
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
let (te_start, te_size) = first_track_entry(&data);
let te_children = master_children(&data, te_start, te_size);
// Direct child of TrackEntry — present.
assert!(
te_children
.iter()
.any(|(id, _, _)| *id == ebml::DEFAULT_DECODED_FIELD_DURATION),
"DefaultDecodedFieldDuration must be a DIRECT child of TrackEntry"
);
// Locate the Video master (a direct child of TrackEntry) and confirm the
// field-duration element is NOT inside it.
let (_, vid_start, vid_size) = te_children
.iter()
.copied()
.find(|(id, _, _)| *id == ebml::VIDEO)
.expect("Video master present");
let vid_children = master_children(&data, vid_start, vid_size as usize);
assert!(
!vid_children
.iter()
.any(|(id, _, _)| *id == ebml::DEFAULT_DECODED_FIELD_DURATION),
"DefaultDecodedFieldDuration must NOT be nested inside the Video master"
);
// And, for completeness, DefaultDuration is also a TrackEntry child (not
// in Video) — pins the pair together so a future edit can't move either.
assert!(
te_children
.iter()
.any(|(id, _, _)| *id == ebml::DEFAULT_DURATION),
"DefaultDuration must be a direct child of TrackEntry"
);
assert!(
!vid_children
.iter()
.any(|(id, _, _)| *id == ebml::DEFAULT_DURATION),
"DefaultDuration must NOT be nested inside the Video master"
);
// FlagInterlaced / FieldOrder ARE Video children (the spec's split).
assert!(
vid_children
.iter()
.any(|(id, _, _)| *id == ebml::FLAG_INTERLACED),
"FlagInterlaced is a Video child"
);
}
/// Helper: read the 1-byte value of a `[id][size=0x81][value]` uint element
/// among the direct children of the Video master of the first TrackEntry.
fn video_child_u8(data: &[u8], id: u32) -> Option<u8> {
let (te_start, te_size) = first_track_entry(data);
let (_, vid_start, vid_size) = master_children(data, te_start, te_size)
.into_iter()
.find(|(c, _, _)| *c == ebml::VIDEO)?;
let (_, child_start, child_size) = master_children(data, vid_start, vid_size as usize)
.into_iter()
.find(|(c, _, _)| *c == id)?;
// 1-byte uint value sits at the child body start.
(child_size == 1).then(|| data[child_start])
}
#[test]
fn pal_576i_emits_bt470bg_colour_codes() {
// GUARDS audit §2 colour-code gap: dvd.rs only asserts at the stream layer
// (ColorSpace::Bt470bg), not the actual CICP tuple emitted in the MKV, so
// a swap with NTSC's (6,6,6) would go uncaught without this test.
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(),
measured_cicp: None,
};
let t = MkvTrack::video(&v);
assert_eq!(
(
t.colour_matrix,
t.colour_transfer,
t.colour_primaries,
t.colour_range
),
(5, 5, 5, 1),
"PAL SD must map to BT.470BG (5,5,5,1)"
);
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// Depth-scoped: Colour master inside Video, with the exact CICP codes.
let (te_start, te_size) = first_track_entry(&data);
let (_, vid_start, vid_size) = master_children(&data, te_start, te_size)
.into_iter()
.find(|(id, _, _)| *id == ebml::VIDEO)
.expect("Video master");
let (_, col_start, col_size) = master_children(&data, vid_start, vid_size as usize)
.into_iter()
.find(|(id, _, _)| *id == ebml::COLOUR)
.expect("Colour master present for PAL SD");
let col = master_children(&data, col_start, col_size as usize);
let val = |id: u32| -> u8 {
let (_, off, sz) = col.iter().copied().find(|(c, _, _)| *c == id).unwrap();
assert_eq!(sz, 1, "single-byte CICP value");
data[off]
};
assert_eq!(
val(ebml::MATRIX_COEFFICIENTS),
5,
"PAL matrix = BT.470BG (5)"
);
assert_eq!(
val(ebml::TRANSFER_CHARACTERISTICS),
5,
"PAL transfer = BT.470BG (5)"
);
assert_eq!(val(ebml::PRIMARIES), 5, "PAL primaries = BT.470BG (5)");
assert_eq!(val(ebml::RANGE), 1, "PAL range = limited (1)");
}
#[test]
fn ntsc_480i_emits_smpte170m_colour_codes() {
// Mirror of the PAL test: NTSC SD must emit (6,6,6,1) — SMPTE-170M /
// BT.601-525 — not BT.470BG's (5,5,5). Together the two tests pin the
// PAL/NTSC colour split at the emitted-byte layer.
let v = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
frame_rate: crate::disc::FrameRate::F29_97,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Smpte170m,
display_aspect: Some((4, 3)),
secondary: false,
label: String::new(),
measured_cicp: None,
};
let t = MkvTrack::video(&v);
assert_eq!(
(
t.colour_matrix,
t.colour_transfer,
t.colour_primaries,
t.colour_range
),
(6, 6, 6, 1),
"NTSC SD must map to SMPTE-170M (6,6,6,1)"
);
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
let (te_start, te_size) = first_track_entry(&data);
let (_, vid_start, vid_size) = master_children(&data, te_start, te_size)
.into_iter()
.find(|(id, _, _)| *id == ebml::VIDEO)
.expect("Video master");
let (_, col_start, col_size) = master_children(&data, vid_start, vid_size as usize)
.into_iter()
.find(|(id, _, _)| *id == ebml::COLOUR)
.expect("Colour master present for NTSC SD");
let col = master_children(&data, col_start, col_size as usize);
let val = |id: u32| -> u8 {
let (_, off, sz) = col.iter().copied().find(|(c, _, _)| *c == id).unwrap();
assert_eq!(sz, 1, "single-byte CICP value");
data[off]
};
assert_eq!(
val(ebml::MATRIX_COEFFICIENTS),
6,
"NTSC matrix = SMPTE-170M (6)"
);
assert_eq!(
val(ebml::TRANSFER_CHARACTERISTICS),
6,
"NTSC transfer = SMPTE-170M (6)"
);
assert_eq!(val(ebml::PRIMARIES), 6, "NTSC primaries = SMPTE-170M (6)");
assert_eq!(val(ebml::RANGE), 1, "NTSC range = limited (1)");
}
#[test]
fn ntsc_480i_duration_metadata_and_field_order_undetermined_at_build() {
// NTSC 480i duration metadata (Windows-fps fix) PLUS field-order honesty: a
// track built without a measured picture carries FieldOrder=UNDETERMINED
// (omitted), never a hardcoded TFF guess — see apply_coding_to_track.
let v = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
frame_rate: crate::disc::FrameRate::F29_97,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Smpte170m,
display_aspect: Some((4, 3)),
secondary: false,
label: String::new(),
measured_cicp: None,
};
let t = MkvTrack::video(&v);
assert!(t.interlaced, "480i is interlaced");
assert_eq!(
t.field_order,
ebml::FIELD_ORDER_UNDETERMINED,
"field order is not known at scan time — never guessed at track build"
);
// 480i @ 29.97: frame = 1001/30000 s = 33_366_666 ns; field = half.
assert_eq!(
t.default_duration_ns, 33_366_666,
"480i frame duration is ~33.37 ms (29.97 fps, not halved)"
);
assert_eq!(
t.field_duration_ns, 0,
"field duration is suppressed (DefaultDecodedFieldDuration omitted — Windows-fps fix)"
);
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// FlagInterlaced is still encoded (480i IS interlaced); FieldOrder is
// omitted until measured — never a hardcoded guess.
assert_eq!(
video_child_u8(&data, ebml::FLAG_INTERLACED),
Some(ebml::INTERLACED_INTERLACED as u8),
"480i must encode FlagInterlaced = 1"
);
assert_eq!(
video_child_u8(&data, ebml::FIELD_ORDER),
None,
"FieldOrder omitted until measured — not a hardcoded guess"
);
}
// Parse the Tags master into (TagTrackUID, BPS) pairs, decoding the BPS TagString to a
// number rather than substring-matching the whole file.
fn parse_bps_tags(data: &[u8]) -> Vec<(u64, u64)> {
let mut out = Vec::new();
let Some((_, tags_start, tags_size)) = segment_children(data)
.into_iter()
.find(|(id, _, _)| *id == ebml::TAGS)
else {
return out;
};
let read_uint = |start: usize, size: u64| -> u64 {
ebml::read_uint_val(&mut Cursor::new(&data[start..]), size as usize).unwrap()
};
let read_str = |start: usize, size: u64| {
String::from_utf8(data[start..start + size as usize].to_vec())
};
for (tag_id, tag_start, tag_size) in master_children(data, tags_start, tags_size as usize) {
if tag_id != ebml::TAG {
continue;
}
let mut uid = None;
let mut name = None;
let mut value = None;
for (cid, cstart, csize) in master_children(data, tag_start, tag_size as usize) {
match cid {
ebml::TARGETS => {
for (tid, tstart, tsize) in master_children(data, cstart, csize as usize) {
if tid == ebml::TAG_TRACK_UID {
uid = Some(read_uint(tstart, tsize));
}
}
}
ebml::SIMPLE_TAG => {
for (sid, sstart, ssize) in master_children(data, cstart, csize as usize) {
match sid {
ebml::TAG_NAME => name = read_str(sstart, ssize).ok(),
ebml::TAG_STRING => value = read_str(sstart, ssize).ok(),
_ => {}
}
}
}
_ => {}
}
}
if name.as_deref() == Some("BPS")
&& let (Some(uid), Some(value)) = (uid, value)
{
out.push((uid, value.parse().expect("BPS TagString must be a number")));
}
}
out
}
#[test]
fn finalize_emits_per_track_bps_tags() {
// At finalize a Tags master carries per-track BPS = bytes*8/duration_secs,
// using the SOURCE-declared duration (muxed timeline only used if none given).
// Here the source declares 10s while frames span 2s, so the tag must use 10s.
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();
// Two video keyframes (1000 bytes each) 2 s apart; one audio frame of
// 500 bytes.
muxer
.write_frame(0, 0, true, &vec![0xABu8; 1000], None, None)
.unwrap();
muxer
.write_frame(1, 0, false, &vec![0xCDu8; 500], None, None)
.unwrap();
muxer
.write_frame(0, 2_000_000_000, true, &vec![0xABu8; 1000], None, 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"
);
// TrackUID is `(index + 1) | 0x100_0000` (see MkvMuxer::new).
let video_uid = 1u64 | 0x100_0000;
let audio_uid = 2u64 | 0x100_0000;
let bps = parse_bps_tags(&data);
assert_eq!(
bps,
vec![(video_uid, 1600), (audio_uid, 400)],
"BPS = bytes*8/declared_duration: video 2000*8/10 = 1600, \
audio 500*8/10 = 400"
);
}
#[test]
fn bps_tags_use_the_backpatched_duration_when_the_source_declares_none() {
// No declared duration -> DURATION is back-patched from the muxed timeline at
// finish(), and BPS derives from that runtime (the max_block_ticks -> seconds ->
// bytes*8/seconds chain was previously unasserted). Ten 40ms frames -> 0.4s runtime.
let tracks = [make_video_track()];
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
for i in 0..10i64 {
muxer
.write_frame(
0,
i * 40_000_000,
true,
&vec![0xABu8; 500],
Some(40_000_000),
None,
)
.unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// The Segment DURATION is the block END of the last frame, in ticks —
// not its start (which would understate the runtime by one frame).
assert_eq!(
find_duration_ticks(&data),
Some(4000.0),
"DURATION must cover the final frame's full presentation: 3600 + 400 ticks"
);
// And the BPS tag must use that same 0.4 s: 10 frames * 500 bytes * 8
// bits / 0.4 s = 100_000 bps.
let video_uid = 1u64 | 0x100_0000;
assert_eq!(
parse_bps_tags(&data),
vec![(video_uid, 100_000)],
"BPS = 5000 bytes * 8 / 0.4 s"
);
}
#[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"
);
}
// MP2 Channels as muxed: `frames` are Layer II frames written in order after a video
// keyframe; returns the Tracks/Audio/Channels byte and the diag lines emitted.
fn mp2_channels_written(claimed: u8, frames: &[Vec<u8>]) -> (u8, Vec<String>) {
let mut audio = make_audio_track();
audio.codec_id = ebml::CODEC_MP2;
audio.channels = claimed;
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let ((), ev) = crate::testlog::capture(|| {
let mut muxer =
MkvMuxer::new(writer, &[make_video_track(), audio], None, 0.0, &[]).unwrap();
muxer.write_frame(0, 0, true, &[1, 2], None, None).unwrap();
for f in frames {
muxer.write_frame(1, 0, false, f, None, None).unwrap();
}
muxer.finish().unwrap();
});
let data = shared.lock().unwrap().clone().into_inner();
let (start, 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 = &data[start..start + size];
let ch = find_id(tracks, ebml::CHANNELS).expect("Channels element present");
let diag = ev
.iter()
.filter(|e| e.target == "freemkv::diag")
.map(|e| e.message().to_string())
.collect();
(tracks[ch + 2], diag)
}
fn mp2(s: super::super::codec::mp2_channels::tests::Spec) -> Vec<u8> {
super::super::codec::mp2_channels::tests::write(s).0
}
use super::super::codec::mp2_channels::tests::{MC_3_2_LFE, Mc, STEREO_256, Spec};
// 13818-3 §2.5.3.1 CRC-gated, committed after RUN_FRAMES matching frames in a row.
fn run(f: Vec<u8>) -> Vec<Vec<u8>> {
vec![f; super::super::codec::mp2_channels::RUN_FRAMES as usize]
}
fn mc_frame(mc: Mc) -> Vec<u8> {
mp2(Spec {
mc: Some(mc),
..STEREO_256
})
}
// 192 kbit/s: 11172-3 §2.4.2.3 Table 3-B.2 allows single_channel only up to 192.
const MONO_192: Spec = Spec {
mode: 0b11,
bitrate_index: 0b1010,
..STEREO_256
};
#[test]
fn mp2_ifo_6_with_extension_stream_writes_the_stereo_base() {
let f = mc_frame(Mc {
ext: true,
..MC_3_2_LFE
});
// 13818-3 §2.5.2.13: "'1' extension bit stream present" - not in the muxed 0xC0 track.
assert_eq!(mp2_channels_written(6, &[f]).0, 2);
}
#[test]
fn mp2_ifo_6_mono_base_writes_1() {
let s = Spec {
mc: Some(Mc {
ext: true,
..MC_3_2_LFE
}),
..MONO_192
};
// 11172-3 §2.4.2.3 mode "'11' single_channel"; 13818-3 "nch ... equal to 1".
assert_eq!(mp2_channels_written(6, &[mp2(s)]).0, 1);
}
#[test]
fn mp2_ifo_6_dual_channel_base_writes_2() {
let s = Spec {
mode: 0b10,
mc: Some(Mc {
ext: true,
..MC_3_2_LFE
}),
..STEREO_256
};
// 11172-3 §2.4.2.3 mode "'10' dual_channel"; 13818-3 "nch ... 2 in other modes".
assert_eq!(mp2_channels_written(6, &[mp2(s)]).0, 2);
}
/// This is per spec; do not change without a spec citation proving otherwise.
#[test]
fn mp2_ext_0_3_2_lfe_keeps_all_six() {
// 13818-3 §2.5.2.13: "'0' no extension stream present"; "'10' stereo surround", lfe '1'.
assert_eq!(mp2_channels_written(6, &run(mc_frame(MC_3_2_LFE))).0, 6);
}
#[test]
fn mp2_ext_0_3_0_writes_3() {
let m = Mc {
centre: 0b01,
surround: 0b00,
lfe: false,
..MC_3_2_LFE
};
// 13818-3 §2.5.2.13: "'01' centre channel present", "'00' no surround".
assert_eq!(mp2_channels_written(6, &run(mc_frame(m))).0, 3);
}
/// This is per spec; do not change without a spec citation proving otherwise.
#[test]
fn mp2_matching_ifo_is_unchanged() {
// 11172-3 §2.4.2.3 mode "'00' stereo" = the declared 2; nothing to correct.
assert_eq!(mp2_channels_written(2, &[mp2(STEREO_256)]).0, 2);
// "'11' single_channel" = the declared 1.
assert_eq!(mp2_channels_written(1, &[mp2(MONO_192)]).0, 1);
}
/// Review scenario 1: coding mode 2 ("MPEG-1 or MPEG-2 without extension bit stream",
/// EP0867877A2) with a bogus 6 nibble. Ancillary bits never become channels without a
/// valid mc_crc_check (13818-3 §2.5.3.1). This is per spec; do not change without a spec
/// citation proving otherwise.
#[test]
fn mp2_bogus_ifo_6_on_mpeg1_writes_the_header_count() {
let lookalike = mp2(Spec {
bad_mc_crc: true,
mc: Some(MC_3_2_LFE),
..STEREO_256
});
assert_eq!(mp2_channels_written(6, &[lookalike]).0, 2);
}
/// Review scenario 2: a nibble of 2 over a verified 5.1 base (ext '0') is 6: §2.5.3.1
/// "If the mandatory CRC-check yields a valid result, then multichannel decoding will be
/// started." This is per spec; do not change without a spec citation proving otherwise.
#[test]
fn mp2_ifo_2_over_verified_5_1_writes_6() {
assert_eq!(mp2_channels_written(2, &run(mc_frame(MC_3_2_LFE))).0, 6);
}
#[test]
fn mp2_plain_mpeg1_declared_2_but_mono_writes_1() {
// 13818-3 symbols: "nch ... equal to 1 for single_channel mode".
assert_eq!(mp2_channels_written(2, &[mp2(MONO_192)]).0, 1);
}
/// Opus defect 1: a zero-filled first frame, then good 5.1 ext '0' frames, gives 6.
#[test]
fn mp2_zero_filled_first_frame_does_not_lock_in_a_count() {
let good = mc_frame(MC_3_2_LFE);
let zeros = vec![0u8; good.len()];
// 11172-3 §2.4.2.3: no "syncword - the bit string '1111 1111 1111'." in the first.
assert_eq!(
mp2_channels_written(6, &[zeros, good.clone(), good.clone(), good]).0,
6
);
}
#[test]
fn mp2_damaged_frames_keep_the_ifo_value_until_one_proves_otherwise() {
let mut cut = mc_frame(MC_3_2_LFE);
cut.truncate(20); // ends inside the Layer II allocation (11172-3 §2.4.1.6)
// Nothing verifiable: the declared value stands.
assert_eq!(mp2_channels_written(6, std::slice::from_ref(&cut)).0, 6);
let (ch, diag) = mp2_channels_written(6, &[cut, mp2(STEREO_256)]);
// A clean MPEG-1 frame: "nch ... 2 in other modes", once the track ends.
assert_eq!(ch, 2);
assert!(
diag.iter()
.any(|m| m.contains("tag=mp2.channels") && m.contains("stored=2")),
"{diag:?}"
);
}
#[test]
fn mp2_no_header_keeps_the_declared_count_until_a_frame_parses() {
// 11172-3 §2.4.2.3: "syncword - the bit string '1111 1111 1111'." - absent here.
assert_eq!(mp2_channels_written(6, &[vec![0u8; 8]]).0, 6);
let later = mc_frame(Mc {
ext: true,
..MC_3_2_LFE
});
assert_eq!(mp2_channels_written(6, &[vec![0u8; 8], later]).0, 2);
}
#[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, None)
.unwrap();
muxer.write_frame(1, 0, false, &ac3, None, 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)
// carrying the dvcC so players recognise Dolby Vision.
let mut dv = make_video_track();
dv.dv_config = Some(dolby_vision_config(7, 6, 0));
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[dv], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_some(),
"DV track must emit BlockAdditionMapping"
);
// Without dv_config, 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());
}
#[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());
// Over-length param sets (> 65535) would mis-frame the 16-bit length
// field; both must refuse rather than emit a truncated record.
let huge = vec![0u8; 0x1_0000];
assert!(mvc_decoder_config_record(&huge, &pps).is_none());
assert!(mvc_decoder_config_record(&subset_sps, &huge).is_none());
}
#[test]
fn mvc_frame_emits_blockgroup_additional_and_reference() {
// A track declaring an mvcC mapping: a keyframe with a dependent AU emits
// BlockGroup > BlockAdditions > BlockMore {BlockAddID=2, BlockAdditional=dep};
// a following non-keyframe adds a ReferenceBlock so it's not mistaken for a seek point.
let mut v = make_video_track();
v.mvc_params = Some((
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
vec![0x68, 0xEE, 0x3C],
));
// finish() so cluster sizes are back-patched and the output can be PARSED
// per-block rather than only byte-scanned.
use std::sync::{Arc, Mutex};
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let mut muxer = MkvMuxer::new(SharedWriter(shared.clone()), &[v], None, 0.0, &[]).unwrap();
let dep_kf = [0xDEu8, 0xAD, 0xBE, 0xEF];
let dep_p = [0xCAu8, 0xFE];
muxer
.write_frame(0, 0, true, &[0x65, 0x01, 0x02], None, Some(&dep_kf))
.unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x41, 0x03], None, Some(&dep_p))
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITIONS).is_some(),
"BlockAdditions (0x75A1) present"
);
// BlockAddID = 2 (uint): element 0xEE, size 0x81, value 0x02.
assert!(
data.windows(3).any(|w| w == [0xEE, 0x81, 0x02]),
"BlockAddID must be 2"
);
assert!(
data.windows(4).any(|w| w == dep_kf),
"keyframe dependent AU present as BlockAdditional"
);
assert!(
data.windows(2).any(|w| w == dep_p),
"non-keyframe dependent AU present"
);
// Per-block, not a byte scan: "one exists somewhere in the file" says nothing
// about WHICH block carries ReferenceBlock — a mutant emitting it on every
// BlockGroup (keyframe included) passed the old presence-only check.
let groups = all_block_groups(&data);
assert_eq!(
groups.len(),
2,
"one keyframe + one non-keyframe MVC BlockGroup"
);
assert_eq!(
groups[0].reference, None,
"the MVC keyframe must carry NO ReferenceBlock — that absence IS the keyframe signal"
);
// The MVC BlockGroup writer takes the track number on its own call path; it
// must be the same 1-based TrackNumber the TrackEntry declared, or a base-view
// block filed under the wrong track is a 3D title whose left eye silently vanishes.
assert!(
groups.iter().all(|g| g.track == 1),
"every MVC base-view Block belongs to TrackNumber 1, got {:?}",
groups.iter().map(|g| g.track).collect::<Vec<_>>()
);
// Assert the OFFSET, not just presence — a mutant emitting a constant or
// wrong-signed offset would pass a presence-only check; the keyframe sits at
// tick 0, so the offset is the negated block-relative timestamp.
let off = groups[1]
.reference
.expect("the non-keyframe MVC base frame must carry a ReferenceBlock");
assert_eq!(
off,
-(groups[1].rel_ts as i64),
"the MVC ReferenceBlock must point back to the keyframe at tick 0"
);
}
#[test]
fn additional_dropped_when_track_has_no_mvc_mapping() {
// If a track did NOT declare an mvcC mapping (mvc_params None), a stray
// block_additional must be dropped (no orphaned BlockAddID=2 / no
// BlockAdditions) so the file stays conforming.
let mut muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
muxer
.write_frame(0, 0, true, &[0x65, 0x01], None, Some(&[0xDE, 0xAD]))
.unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITIONS).is_none(),
"no BlockAdditions without a declared mvcC mapping"
);
}
#[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 / analyzers 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());
}
#[test]
fn mvc_codec_private_appends_mvcc_extension_block() {
// avcC ‖ u32be(4 + record.len()) ‖ "mvcC" ‖ record (Matroska Codec Spec §4.3.9).
let avcc = vec![0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0xAA];
let record = vec![0x01, 0x80, 0x00, 0x33, 0xBF, 0x01, 0xCC]; // 7 bytes
let out = mvc_codec_private(&avcc, &record);
assert_eq!(&out[..avcc.len()], &avcc[..], "avcC preserved verbatim");
// size field = 4 ("mvcC") + 7 (record) = 11 = extension block size minus 4.
assert_eq!(&out[avcc.len()..avcc.len() + 4], &11u32.to_be_bytes());
assert_eq!(&out[avcc.len() + 4..avcc.len() + 8], b"mvcC");
assert_eq!(
&out[avcc.len() + 8..],
&record[..],
"record after the mvcC fourcc"
);
}
#[test]
fn mvc_track_codec_private_carries_avcc_plus_mvcc() {
// An MVC base track's CodecPrivate must be the base avcC followed by the
// mvcC extension — the track-level signal analyzers/decoders read.
let avcc = vec![
0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99,
];
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
let pps = vec![0x68, 0xEE, 0x3C];
let mut v = make_video_track();
v.codec_private = Some(avcc.clone());
v.mvc_params = Some((subset_sps.clone(), pps.clone()));
let data = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[])
.unwrap()
.writer
.into_inner();
let record = mvc_decoder_config_record(&subset_sps, &pps).unwrap();
let expected = mvc_codec_private(&avcc, &record);
assert!(
data.windows(expected.len())
.any(|w| w == expected.as_slice()),
"emitted CodecPrivate must be avcC + mvcC extension"
);
// A non-MVC (2D) track writes its avcC VERBATIM — no mvcC appended.
let mut v2 = make_video_track();
v2.codec_private = Some(avcc.clone());
let d2 = MkvMuxer::new(Cursor::new(Vec::new()), &[v2], None, 0.0, &[])
.unwrap()
.writer
.into_inner();
assert!(
!d2.windows(4).any(|w| w == b"mvcC"),
"2D track CodecPrivate must not carry an mvcC extension"
);
}
// CodecPrivate emission (avcC/hvcC/VC-1/MPEG-2): `MkvTrack::video` builds with
// `codec_private: None`, filled in later by the PES pipeline. Verify the muxer
// emits it as a DIRECT TrackEntry child (never nested in Video), byte-for-byte.
/// Build a representative video track for `codec` whose codecPrivate is set
/// to `cp` (mirroring what the mux pipeline supplies up-front).
fn video_track_with_codec_private(codec: Codec, cp: Vec<u8>) -> MkvTrack {
let v = VideoStream {
pid: 0xE0,
codec,
resolution: Resolution::R1080p,
frame_rate: crate::disc::FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
let mut t = MkvTrack::video(&v);
t.codec_private = Some(cp);
t
}
/// Read the body bytes of a direct TrackEntry child element by ID.
fn track_entry_child_body(data: &[u8], id: u32) -> Option<&[u8]> {
let (te_start, te_size) = first_track_entry(data);
let (_, body_start, body_size) = master_children(data, te_start, te_size)
.into_iter()
.find(|(c, _, _)| *c == id)?;
Some(&data[body_start..body_start + body_size as usize])
}
#[test]
fn codec_private_emitted_verbatim_for_each_video_codec() {
// A distinctive payload per codec so a mix-up (wrong track/truncation) is
// caught. The muxer treats codecPrivate as opaque binary, so the contract
// under test is "emit exactly what you were given, intact."
let cases: [(Codec, &str, Vec<u8>); 4] = [
// avcC (H.264): configurationVersion=1, AVCProfileIndication=0x64
// (High), profile_compat=0x00, AVCLevelIndication=0x28 (4.0), then
// the reserved/length-size byte. A real-shaped, minimal avcC head.
(
Codec::H264,
ebml::CODEC_H264,
vec![
0x01, 0x64, 0x00, 0x28, 0xFF, 0xE1, 0x00, 0x04, 0x67, 0x64, 0x00, 0x28,
],
),
// hvcC (HEVC): configurationVersion=1, then a real-shaped head.
(
Codec::Hevc,
ebml::CODEC_HEVC,
vec![
0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5A,
],
),
// VC-1: VFW BITMAPINFOHEADER blob (opaque to the muxer).
(
Codec::Vc1,
ebml::CODEC_VC1,
vec![
0x28, 0x00, 0x00, 0x00, 0x80, 0x07, 0x00, 0x00, 0x38, 0x04, 0x00, 0x00,
],
),
// MPEG-2: sequence header start code + payload.
(
Codec::Mpeg2,
ebml::CODEC_MPEG2,
vec![0x00, 0x00, 0x01, 0xB3, 0x14, 0x00, 0xF0, 0xC4, 0x02],
),
];
for (codec, expected_codec_id, cp) in cases {
let t = video_track_with_codec_private(codec, cp.clone());
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// CodecPrivate must be a DIRECT child of TrackEntry (not in Video) and
// reproduce the supplied payload byte-for-byte.
let body = track_entry_child_body(&data, ebml::CODEC_PRIVATE).unwrap_or_else(|| {
panic!("{codec:?}: CodecPrivate must be a direct TrackEntry child")
});
assert_eq!(
body,
&cp[..],
"{codec:?}: emitted CodecPrivate must equal the supplied bytes verbatim"
);
// The CodecPrivate must NOT also appear inside the Video master.
let (te_start, te_size) = first_track_entry(&data);
let (_, vid_start, vid_size) = master_children(&data, te_start, te_size)
.into_iter()
.find(|(c, _, _)| *c == ebml::VIDEO)
.expect("Video master present");
assert!(
!master_children(&data, vid_start, vid_size as usize)
.iter()
.any(|(c, _, _)| *c == ebml::CODEC_PRIVATE),
"{codec:?}: CodecPrivate must not be nested in the Video master"
);
// And the registered CodecID for this codec must be emitted.
let cid = track_entry_child_body(&data, ebml::CODEC_ID)
.expect("CodecID present")
.to_vec();
assert_eq!(
String::from_utf8_lossy(&cid),
*expected_codec_id,
"{codec:?}: wrong CodecID emitted"
);
}
}
#[test]
fn codec_private_omitted_when_none() {
// The guard at the writer is `if let Some(cp) = ...`: a track with no
// codecPrivate (audio commonly, or video before fill) must emit NO
// CodecPrivate element at all — not an empty one.
let muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_audio_track()],
None,
0.0,
&[],
)
.unwrap();
let data = muxer.writer.into_inner();
assert!(
track_entry_child_body(&data, ebml::CODEC_PRIVATE).is_none(),
"no CodecPrivate element when codec_private is None"
);
}
// DefaultDuration ns by frame rate (emitted bytes): existing tests only pin
// 25/29.97 fps. This fills the remaining rates, asserting the value read back
// from the muxer (not just the track field) to catch serializer regressions too.
/// Read the DefaultDuration value (ns) emitted for a single-video-track mux.
fn emitted_default_duration_ns(frame_rate: crate::disc::FrameRate) -> u64 {
let v = VideoStream {
pid: 0xE0,
codec: Codec::Hevc,
resolution: Resolution::R1080p,
frame_rate,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
let t = MkvTrack::video(&v);
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
let body = track_entry_child_body(&data, ebml::DEFAULT_DURATION)
.expect("DefaultDuration present for a known frame rate");
// EBML uint: big-endian, variable width.
body.iter().fold(0u64, |acc, &b| (acc << 8) | b as u64)
}
// The Chapters Seek entry, the only fixup whose offset comes from an Option, must be
// back-patched too.
#[test]
fn seekhead_chapters_entry_resolves_to_the_chapters_element() {
let tracks = [make_video_track(), make_audio_track()];
let chapters = vec![
Chapter {
time_secs: 0.0,
name: "Chapter 1".into(),
},
Chapter {
time_secs: 300.0,
name: "Chapter 2".into(),
},
];
let (data, _) = mux_to_bytes(&tracks, &chapters, &frames_for(10.0, 1.0));
let (_, seg_start) = locate_segment(&data);
let pos = parse_seekhead(&data)
.into_iter()
.find(|(id, _)| *id == ebml::CHAPTERS)
.map(|(_, pos)| pos)
.expect("a title with chapters must carry a CHAPTERS Seek entry");
assert_ne!(pos, 0, "the CHAPTERS SeekPosition must be back-patched");
let mut cursor = Cursor::new(&data[seg_start + pos as usize..]);
let (id, _, _) = ebml::read_element_header(&mut cursor).unwrap();
assert_eq!(
id,
ebml::CHAPTERS,
"the CHAPTERS SeekPosition must resolve to the Chapters element"
);
}
// MkvTrack::video divides by disc-supplied numbers (frame-rate numerator, aspect
// denominator); zero must not panic.
#[test]
fn video_track_survives_a_zero_frame_rate_and_zero_aspect_denominator() {
let base = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R576i,
frame_rate: crate::disc::FrameRate::Unknown, // numerator 0
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
// An unknown frame rate has no numerator: DefaultDuration is 0 (element
// omitted), NOT a divide by zero.
assert_eq!(
MkvTrack::video(&base).default_duration_ns,
0,
"an unknown frame rate must omit DefaultDuration, not divide by zero"
);
// A zero aspect denominator must fall back to the square-pixel display
// size rather than dividing by it.
let zero_den = VideoStream {
frame_rate: crate::disc::FrameRate::F25,
display_aspect: Some((16, 0)),
..base.clone()
};
let t = MkvTrack::video(&zero_den);
assert_eq!(
(t.display_width, t.display_height),
(720, 576),
"a zero aspect denominator falls back to display == pixel"
);
// A zero numerator is equally nonsense and takes the same fallback.
let zero_num = VideoStream {
frame_rate: crate::disc::FrameRate::F25,
display_aspect: Some((0, 9)),
..base.clone()
};
let t = MkvTrack::video(&zero_num);
assert_eq!(
(t.display_width, t.display_height),
(720, 576),
"a zero aspect numerator falls back to display == pixel"
);
// And an unknown resolution (height 0) cannot derive a display width
// from a height it does not have.
let no_height = VideoStream {
frame_rate: crate::disc::FrameRate::F25,
resolution: Resolution::Unknown,
display_aspect: Some((16, 9)),
..base.clone()
};
let t = MkvTrack::video(&no_height);
assert_eq!(
(t.display_width, t.display_height),
(0, 0),
"no pixel height → no derived display size"
);
}
// FlagDefault must never select a SECONDARY stream (DV EL, commentary).
#[test]
fn secondary_streams_are_never_the_default_track() {
let v = VideoStream {
pid: 0xE0,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: crate::disc::FrameRate::F24,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
assert!(
MkvTrack::video(&v).is_default,
"the primary video track is the default"
);
assert!(
!MkvTrack::video(&VideoStream {
secondary: true,
..v.clone()
})
.is_default,
"a secondary video track (e.g. a Dolby Vision EL) is NEVER the default"
);
let a = audio_stream(Codec::Ac3);
assert!(
MkvTrack::audio(&a).is_default,
"the primary audio track is the default"
);
assert!(
!MkvTrack::audio(&AudioStream {
secondary: true,
..a.clone()
})
.is_default,
"a secondary audio track (e.g. a commentary) is NEVER the default"
);
}
// DVD subtitles are VobSub, Blu-ray are PGS; a lost DvdSub arm would silently mislabel
// every DVD track as PGS.
#[test]
fn subtitle_codec_id_distinguishes_vobsub_from_pgs() {
let s = SubtitleStream {
pid: 0x20,
codec: Codec::DvdSub,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
};
assert_eq!(
MkvTrack::subtitle(&s).codec_id,
ebml::CODEC_VOBSUB,
"a DVD subtitle track is S_VOBSUB"
);
assert_eq!(
MkvTrack::subtitle(&SubtitleStream {
codec: Codec::Pgs,
..s.clone()
})
.codec_id,
ebml::CODEC_PGS,
"a Blu-ray subtitle track is S_HDMV/PGS"
);
}
// The HDR transfer override must fire even when the disc's color_space nibble disagrees
// (BT.709 + detected HDR10).
#[test]
fn hdr_format_overrides_the_transfer_even_on_a_non_bt2020_color_space() {
let v = VideoStream {
pid: 0xE0,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: crate::disc::FrameRate::F24,
hdr: HdrFormat::Hdr10,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
};
let (m, t, p, _) = cicp_for_video(&v);
assert_eq!(
(m, t, p),
(CICP_MATRIX_BT709, CICP_TRANSFER_PQ, CICP_PRIMARIES_BT709),
"HDR10 forces the PQ transfer; matrix/primaries stay with the enum"
);
// And the track built from it carries the overridden transfer.
assert_eq!(MkvTrack::video(&v).colour_transfer, CICP_TRANSFER_PQ);
// HLG takes the HLG transfer, and SDR is left alone.
assert_eq!(
cicp_for_video(&VideoStream {
hdr: HdrFormat::Hlg,
..v.clone()
})
.1,
CICP_TRANSFER_HLG
);
assert_eq!(
cicp_for_video(&VideoStream {
hdr: HdrFormat::Sdr,
..v.clone()
})
.1,
CICP_TRANSFER_BT709
);
}
// mvc_decoder_config_record's length bounds (4 bytes min, 0xFFFF max) are exact; off by one
// loses the mvcC mapping.
#[test]
fn mvc_decoder_config_record_boundaries_are_inclusive_and_exact() {
let sps4 = [0x6F, 0x64, 0x00, 0x1F];
let pps = [0x68, 0xEE];
let record = mvc_decoder_config_record(&sps4, &pps)
.expect("a 4-byte subset SPS is the shortest readable one, not a reject");
assert_eq!(
&record[1..4],
&sps4[1..4],
"profile/compat/level come from subset SPS bytes 1..=3"
);
// Three bytes is genuinely too short — bytes 1..=3 do not exist.
assert_eq!(mvc_decoder_config_record(&sps4[..3], &pps), None);
// An empty PPS has nothing to frame.
assert_eq!(mvc_decoder_config_record(&sps4, &[]), None);
// 0xFFFF is representable in the 16-bit length field; 0x1_0000 is not.
let max_pps = vec![0x68u8; 0xFFFF];
assert!(
mvc_decoder_config_record(&sps4, &max_pps).is_some(),
"a 65535-byte PPS still fits the 16-bit length field"
);
assert_eq!(
mvc_decoder_config_record(&sps4, &vec![0x68u8; 0x1_0000]),
None
);
let max_sps = vec![0x6Fu8; 0xFFFF];
assert!(mvc_decoder_config_record(&max_sps, &pps).is_some());
assert_eq!(
mvc_decoder_config_record(&vec![0x6Fu8; 0x1_0000], &pps),
None
);
// The PPS length is written big-endian across two bytes. Every earlier
// case used a PPS under 256 bytes, where the high byte is 0 whatever the
// shift does — so use one that is not.
let pps300 = vec![0xAAu8; 300];
let record = mvc_decoder_config_record(&sps4, &pps300).unwrap();
let len_hi = record.len() - 300 - 2;
assert_eq!(
(record[len_hi], record[len_hi + 1]),
(1, 44),
"300 = 0x012C must be framed as high byte 0x01, low byte 0x2C"
);
}
#[test]
fn default_duration_ns_matches_frame_rate_for_all_rates() {
use crate::disc::FrameRate;
// Expected ns/frame = 1e9 * den / num for each (num, den) fraction, e.g.
// 23.976 = 24000/1001 -> 41_708_333, 29.97 = 30000/1001 -> 33_366_666.
let cases = [
(FrameRate::F23_976, 41_708_333u64),
(FrameRate::F24, 41_666_666),
(FrameRate::F25, 40_000_000),
(FrameRate::F29_97, 33_366_666),
(FrameRate::F30, 33_333_333),
(FrameRate::F50, 20_000_000),
(FrameRate::F59_94, 16_683_333),
(FrameRate::F60, 16_666_666),
];
for (fr, expected) in cases {
assert_eq!(
emitted_default_duration_ns(fr),
expected,
"{fr:?}: emitted DefaultDuration ns mismatch"
);
}
}
#[test]
fn mvc_track_declares_max_block_addition_id() {
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::MAX_BLOCK_ADDITION_ID).is_some(),
"MVC mapping needs MaxBlockAdditionID"
);
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::MAX_BLOCK_ADDITION_ID).is_none());
}
#[test]
fn write_frame_rejects_undeclared_track() {
let mut muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
let err = muxer
.write_frame(3, 0, true, &[0x65, 0x01], None, None)
.unwrap_err();
let inner = err
.get_ref()
.and_then(|e| e.downcast_ref::<crate::error::Error>());
assert!(matches!(
inner,
Some(crate::error::Error::MuxTrackRange {
track: 3,
tracks: 1
})
));
}
#[test]
fn forced_split_on_non_key_video_frame_adds_no_cue() {
// One keyframe, then P frames 1 s apart: the i16 guard splits the cluster on
// a non-key frame, which must not become a seek point.
let frames: Vec<(usize, i64, bool, Vec<u8>)> = (0..10)
.map(|i| (0, i * 1_000_000_000, i == 0, vec![0xAB; 16]))
.collect();
let (data, _) = mux_to_bytes(&[make_video_track()], &[], &frames);
assert!(find_clusters(&data).len() >= 2, "split expected");
assert_eq!(parse_cues(&data).len(), 1, "only the keyframe is a cue");
}
fn uhd_video(hdr: HdrFormat, color_space: ColorSpace) -> VideoStream {
VideoStream {
pid: 0x1011,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: crate::disc::FrameRate::F23_976,
hdr,
color_space,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
}
}
// An SDR BT.2020 UHD title (MPLS dynamic_range 0) is BT.2020-10 transfer, not PQ; only an
// HDR format may select PQ/HLG.
#[test]
fn sdr_bt2020_is_bt2020_transfer_not_pq() {
let (m, t, p, _) = cicp_for_video(&uhd_video(HdrFormat::Sdr, ColorSpace::Bt2020));
assert_eq!(
(m, t, p),
(
CICP_MATRIX_BT2020NC,
CICP_TRANSFER_BT2020_10,
CICP_PRIMARIES_BT2020
),
"SDR BT.2020 must not be tagged PQ"
);
assert_eq!(
cicp_for_video(&uhd_video(HdrFormat::Hdr10, ColorSpace::Bt2020)).1,
CICP_TRANSFER_PQ
);
assert_eq!(
cicp_for_video(&uhd_video(HdrFormat::Hlg, ColorSpace::Bt2020)).1,
CICP_TRANSFER_HLG
);
// Agrees with the enum-only mapping the FVI header documents.
let c = crate::mux::videomap::Colour::from_color_space(ColorSpace::Bt2020);
assert_eq!(c.transfer, t);
}
#[derive(Clone)]
struct SharedBuf(std::sync::Arc<std::sync::Mutex<Cursor<Vec<u8>>>>);
impl Write for SharedBuf {
fn write(&mut self, b: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(b)
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl Seek for SharedBuf {
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
self.0.lock().unwrap().seek(pos)
}
}
// A stream whose codec has no Matroska CodecID is left out of the file instead of being
// declared A_AC3 / S_HDMV/PGS / V_MPEG2; the mappable tracks keep their frames.
#[test]
fn mkv_stream_leaves_out_unmappable_codecs() {
use crate::pes::Stream as _;
let audio = |codec| {
crate::disc::Stream::Audio(AudioStream {
pid: 0x1100,
codec,
channels: crate::disc::AudioChannels::Stereo,
language: "eng".into(),
sample_rate: crate::disc::SampleRate::S48,
secondary: false,
purpose: crate::disc::LabelPurpose::Normal,
label: String::new(),
})
};
let sub = |codec| {
crate::disc::Stream::Subtitle(SubtitleStream {
pid: 0x1200,
codec,
language: "eng".into(),
forced: false,
qualifier: crate::disc::LabelQualifier::None,
codec_data: None,
})
};
let mut v = uhd_video(HdrFormat::Sdr, ColorSpace::Bt709);
v.codec = Codec::H264;
let title = crate::disc::DiscTitle {
streams: vec![
crate::disc::Stream::Video(v),
audio(Codec::Unknown(0)),
sub(Codec::Srt),
audio(Codec::Ac3),
],
codec_privates: vec![None; 4],
..crate::disc::DiscTitle::empty()
};
let out = SharedBuf(std::sync::Arc::new(std::sync::Mutex::new(Cursor::new(
Vec::new(),
))));
let mut s = super::super::MkvStream::create(Box::new(out.clone()), &title, None).unwrap();
s.set_track_timing(1, Default::default()).unwrap();
for (track, keyframe) in [(0, true), (1, true), (2, true), (3, true)] {
s.write(&crate::pes::PesFrame {
discard_padding_ns: 0,
coding: None,
source: None,
track,
pts: 0,
keyframe,
data: vec![0x10 + track as u8; 8],
duration_ns: None,
})
.unwrap();
}
s.finish().unwrap();
let bytes = out.0.lock().unwrap().get_ref().clone();
let mut back = super::super::MkvStream::open(Cursor::new(bytes)).unwrap();
let codecs: Vec<Codec> = (back.info().streams.iter())
.map(|s| match s {
crate::disc::Stream::Video(v) => v.codec,
crate::disc::Stream::Audio(a) => a.codec,
crate::disc::Stream::Subtitle(s) => s.codec,
})
.collect();
assert_eq!(
codecs,
vec![Codec::H264, Codec::Ac3],
"only mappable tracks"
);
let mut got = Vec::new();
while let Some(f) = back.read().unwrap() {
got.push((f.track, f.data));
}
assert_eq!(got, vec![(0, vec![0x10; 8]), (1, vec![0x13; 8])]);
}
// dvcC level follows the 2160p frame rate; unknown rate keeps the 2160p24 level.
#[test]
fn dv_level_follows_the_frame_rate() {
use crate::disc::FrameRate as F;
for (fr, want) in [
(F::F23_976, 6),
(F::F24, 6),
(F::F25, 7),
(F::F29_97, 7),
(F::F50, 9),
(F::F59_94, 9),
(F::Unknown, 6),
] {
let mut v = uhd_video(HdrFormat::DolbyVision, ColorSpace::Bt2020);
v.frame_rate = fr;
let c = MkvTrack::video(&v).dv_config.unwrap();
assert_eq!(((c[2] & 1) << 5) | (c[3] >> 3), want, "{fr:?}");
}
}
// A huge caller-supplied display aspect must not overflow DisplayWidth; an
// unrepresentable width falls back to the pixel grid. DVD 16:9 still widens.
#[test]
fn display_aspect_overflow_keeps_pixel_grid() {
let mut v = uhd_video(HdrFormat::Sdr, ColorSpace::Bt709);
v.display_aspect = Some((u32::MAX, 1));
let t = MkvTrack::video(&v);
assert_eq!((t.display_width, t.display_height), (3840, 2160));
v.display_aspect = Some((u32::MAX / 2, u32::MAX / 4));
assert_eq!(MkvTrack::video(&v).display_width, 4320, "2:1 of 2160");
}
// The CHAPTERS SeekHead back-patch with no recorded offset is a typed error, not a
// panic after the whole mux.
#[test]
fn chapters_fixup_without_offset_is_an_error() {
let ch = [Chapter {
time_secs: 0.0,
name: "1".into(),
}];
let mut m = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&ch,
)
.unwrap();
m.write_frame(0, 0, true, &[0x65, 1], None, None).unwrap();
m.chapters_offset = None;
let e = m.finish().unwrap_err();
assert_eq!(
crate::error::error_code(&e),
Some(crate::error::E_MKV_UNENCODABLE)
);
}
// Untrusted SPU chains: a 2-node cycle, a next offset past the end, a 0x07 length overrun
// and a sub-header SPU all end as None. The cycle runs on a thread with a deadline so an
// unbounded walk fails instead of hanging the suite.
#[test]
fn vobsub_display_ns_rejects_malformed_chains() {
// DCSQ A at 4 -> B at 10 -> A, each STA_DSP + CMD_END, no stop.
let cycle = vec![0, 16, 0, 4, 0, 0, 0, 10, 0x01, 0xFF, 0, 0, 0, 4, 0x01, 0xFF];
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || tx.send(vobsub_display_ns(&cycle)));
let got = rx.recv_timeout(std::time::Duration::from_secs(5));
assert_eq!(got, Ok(None), "2-node cycle must terminate with None");
let past_end = [0, 9, 0, 4, 0, 0, 0, 200, 0xFF];
assert_eq!(vobsub_display_ns(&past_end), None);
let overrun = [0, 12, 0, 4, 0, 0, 0, 4, 0x07, 0xFF, 0xFF, 0x02];
assert_eq!(vobsub_display_ns(&overrun), None);
assert_eq!(vobsub_display_ns(&[0, 3, 0]), None);
// Control: the stop in the SECOND DCSQ is found.
let ok = [
0, 16, 0, 4, 0, 0, 0, 10, 0x01, 0xFF, 0, 10, 0, 10, 0x02, 0xFF,
];
assert_eq!(vobsub_display_ns(&ok), Some(10 * SPU_DELAY_NS));
}
}