Triage of a 10-lens code audit of the 3D branch. Fixes for real defects; rejected three spec false-positives that matched the ISO/IEC 14496-15 §7.6.2 record verbatim. Robustness / correctness: - Never panic when a title's only video is the MVC dependent view: the base is now the first NON-dependent video, so a dependent-only title sets up no merge (muxed as an ordinary track) instead of hitting an `expect` on the skipped track slot. - Drop a per-frame BlockAdditional (BlockAddID=2) when the track declared no mvcC mapping (dependent params not captured before the header) — a plain block keeps the file conforming instead of an orphaned add. - A non-keyframe MVC base frame always carries a ReferenceBlock (fall back to a 0 offset in the pre-first-keyframe corner) so it is never mistaken for a seek point. - Reference the last keyframe on the PRIMARY video track only, so a secondary video track's keyframe can't become a cross-track reference. - dep_by_pts overflow: bound BEFORE inserting so the just-arrived dependent survives the drift-clear; count a displaced duplicate-PTS dependent as an orphan instead of losing it silently. API / docs: - Fold write_frame_with_additional into write_frame(..., Option<&[u8]>) per the "no foo_with_X" convention. - Fix mvc_params doc (StereoMode is intentionally not emitted); remove a stale PAT/PMT comment describing an approach that was never taken. Tests: MVCDecoderConfigurationRecord over-length guards; write_int minimal two's-complement widths; BlockGroup/BlockAdditions/BlockAdditional + ReferenceBlock emission; additional dropped without a mapping; h264 MVC passthrough keeps param sets in-band; extract_mvc_params no-panic on truncated/empty input; pairing window + dep-overflow edges; no-panic on a dependent-only title.
4803 lines
215 KiB
Rust
4803 lines
215 KiB
Rust
//! 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;
|
||
use super::timeline::TimelineContinuity;
|
||
use crate::disc::{
|
||
AudioChannels, AudioStream, Chapter, Codec, ColorSpace, HdrFormat, Resolution, SampleRate,
|
||
SubtitleStream, VideoStream,
|
||
};
|
||
use std::io::{self, Seek, Write};
|
||
|
||
// ── CICP colour codes (ITU-T H.273) ──────────────────────────────────────────
|
||
//
|
||
// The Matroska Colour element (RFC 9559) carries MatrixCoefficients,
|
||
// TransferCharacteristics and Primaries verbatim as the integer code-points
|
||
// defined by ITU-T H.273 ("Coding-independent code points", CICP). Hoisting the
|
||
// codes to named constants keeps the colour match arms self-documenting and the
|
||
// values traceable to the public spec table that defines each.
|
||
|
||
/// 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 = 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" — the MVCDecoderConfigurationRecord fourcc, big-endian
|
||
/// ASCII 'm''v''c''C'. Matroska BlockAdditionMapping/BlockAddIDType for a
|
||
/// Blu-ray 3D MVC configuration (RFC 9559 + Matroska Codec Specifications
|
||
/// §4.1.5; equals ISO/IEC 14496-15 MVCConfigurationBox('mvcC')). The per-frame
|
||
/// dependent (right-eye) view rides as a BlockAdditional under this mapping.
|
||
const BLOCK_ADD_ID_TYPE_MVCC: u64 = 0x6D76_6343;
|
||
|
||
/// BlockAddIDValue for the MVC mapping — the value each per-frame `BlockAddID`
|
||
/// references (RFC 9559 requires ≥ 2; 1 is the default plain BlockAdditional).
|
||
const BLOCK_ADD_ID_VALUE_MVC: u64 = 2;
|
||
|
||
/// Build an `MVCDecoderConfigurationRecord` (ISO/IEC 14496-15:2013 §7.6.2) from
|
||
/// the dependent view's subset-SPS (NAL type 15) and PPS NAL units. This is the
|
||
/// `BlockAddIDExtraData` for the `mvcC` BlockAdditionMapping (and, in the ISO
|
||
/// container, the `MVCConfigurationBox('mvcC')` payload).
|
||
///
|
||
/// Layout mirrors the AVCDecoderConfigurationRecord except byte[4] repurposes
|
||
/// the AVC record's `bit(6) reserved` as
|
||
/// `complete_representation(1) | explicit_au_track(1) | reserved '1111'(4)`.
|
||
/// `profile`/`compat`/`level` describe the WHOLE MVC stream and come from the
|
||
/// subset SPS (bytes 1..=3). `length_size_minus_one` MUST match the base avcC
|
||
/// (freemkv always emits 4-byte length prefixes → 3). SPSs precede subset SPSs
|
||
/// in the array; here the array carries the dependent view's parameter sets.
|
||
///
|
||
/// Returns `None` if either param set is absent, too short, or exceeds the
|
||
/// 16-bit length field (a param set > 65535 bytes is non-conforming and would
|
||
/// mis-frame the record).
|
||
fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
|
||
if subset_sps.len() < 4 || subset_sps.len() > 0xFFFF || pps.is_empty() || pps.len() > 0xFFFF {
|
||
return None;
|
||
}
|
||
let mut record = vec![
|
||
1, // configurationVersion
|
||
subset_sps[1], // AVCProfileIndication (whole MVC stream, from subset SPS)
|
||
subset_sps[2], // profile_compatibility
|
||
subset_sps[3], // AVCLevelIndication
|
||
// complete_representation(1)=1 | explicit_au_track(1)=0 |
|
||
// reserved '1111'(4) | lengthSizeMinusOne(2)=3(11) → 1_0_1111_11 = 0xBF.
|
||
0xBF,
|
||
// reserved '0'(1) | numOfSequenceParameterSets(7)=1 → 0_0000001 = 0x01.
|
||
// NB: distinct from the AVC record's byte[5] (reserved(3)|numSPS(5)).
|
||
0x01,
|
||
(subset_sps.len() >> 8) as u8,
|
||
subset_sps.len() as u8,
|
||
];
|
||
record.extend_from_slice(subset_sps);
|
||
record.push(1); // numOfPictureParameterSets
|
||
record.push((pps.len() >> 8) as u8);
|
||
record.push(pps.len() as u8);
|
||
record.extend_from_slice(pps);
|
||
Some(record)
|
||
}
|
||
|
||
/// Resolve a video stream's CICP colour code points — `(matrix, transfer,
|
||
/// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink
|
||
/// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can
|
||
/// never drift:
|
||
///
|
||
/// 1. **Measured CICP** read from the bitstream (HEVC/H.264 VUI
|
||
/// `colour_description` or MPEG-2 `sequence_display_extension`) is
|
||
/// AUTHORITATIVE — copied through verbatim when present.
|
||
/// 2. Otherwise fall back to the coarse `color_space` enum (a playlist nibble /
|
||
/// PAL-NTSC guess), THEN apply the HDR-driven transfer override: BT.2020 only
|
||
/// appears on HDR UHD, where the real transfer is PQ (16) for
|
||
/// HDR10/HDR10+/DV or HLG (18) for HLG — never the SDR transfer 14 the enum
|
||
/// alone would emit.
|
||
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 {
|
||
ColorSpace::Bt2020 => (
|
||
CICP_MATRIX_BT2020NC,
|
||
CICP_TRANSFER_PQ,
|
||
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" (code point 2) for matrix,
|
||
// transfer, and primaries, with limited range (the disc norm). Both the
|
||
// MKV sink and the FVI sidecar emit 2 so the two sinks of one title
|
||
// agree (matches `Colour::from_color_space`'s Unknown mapping).
|
||
ColorSpace::Unknown => (
|
||
CICP_MATRIX_UNSPECIFIED,
|
||
CICP_TRANSFER_UNSPECIFIED,
|
||
CICP_PRIMARIES_UNSPECIFIED,
|
||
COLOUR_RANGE_LIMITED,
|
||
),
|
||
};
|
||
// Override the transfer for HDR signalled by the HdrFormat (the coarse enum
|
||
// can't express PQ/HLG). Only applies on the enum fallback; a measured CICP
|
||
// 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 — the same deferred path
|
||
/// `hdr10`/FieldOrder use — never at construction. When `Some`, the per-frame
|
||
/// dependent view rides as a `BlockAdditional`. (No `StereoMode` is written:
|
||
/// RFC 9559 assigns no StereoMode value to MVC-in-BlockAdditional; the mvcC
|
||
/// mapping is the 3D signal.)
|
||
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
|
||
}
|
||
|
||
/// 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 the Matroska `Colour` element:
|
||
/// `MasteringMetadata` (chromaticity / luminance floats) plus `MaxCLL` /
|
||
/// `MaxFALL` (uints). Called ONLY when the metadata was measured from the
|
||
/// bitstream SEI, so it is never written for SDR content.
|
||
///
|
||
/// Unit conversions (Rec. ITU-T H.265 D.3.28 → RFC 9559 / Matroska):
|
||
/// - chromaticity: SEI integer × 0.00002 → Matroska float in [0, 1]
|
||
/// - luminance: SEI integer × 0.0001 → Matroska float in cd/m²
|
||
/// - MaxCLL / MaxFALL: already cd/m² integers → written as uints verbatim
|
||
///
|
||
/// The SEI primary order is c=0 Green, c=1 Blue, c=2 Red (D.3.28); the
|
||
/// `Hdr10Metadata` arrays preserve that SEI order, so index 0 → G, 1 → B, 2 → R
|
||
/// is mapped onto the Matroska R/G/B element layout here.
|
||
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(())
|
||
}
|
||
|
||
impl MkvTrack {
|
||
/// Build a video track from a [`VideoStream`]. Language defaults to `"und"`;
|
||
/// colour metadata is derived from the stream's colour space and HDR format
|
||
/// (PQ for HDR10/HDR10+/DV, HLG for HLG). When `hdr == DolbyVision` a dvcC
|
||
/// BlockAdditionMapping is attached automatically so players recognise the
|
||
/// Dolby Vision layer.
|
||
pub fn video(v: &VideoStream) -> Self {
|
||
let codec_id = match v.codec {
|
||
Codec::H264 => ebml::CODEC_H264,
|
||
Codec::Hevc => ebml::CODEC_HEVC,
|
||
Codec::Vc1 => ebml::CODEC_VC1,
|
||
Codec::Mpeg2 => ebml::CODEC_MPEG2,
|
||
_ => ebml::CODEC_MPEG2,
|
||
};
|
||
// An Unknown resolution has no real dimensions — emit (0, 0) so the
|
||
// serializer omits PixelWidth/PixelHeight (Matroska marks them
|
||
// optional) rather than writing a fabricated 1920x1080 default.
|
||
let (w, h) = if matches!(v.resolution, Resolution::Unknown) {
|
||
(0, 0)
|
||
} else {
|
||
v.resolution.pixels()
|
||
};
|
||
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);
|
||
// Display dimensions. For square-pixel video (HD/UHD/BD) the display
|
||
// aspect equals the pixel grid, so display == pixel. For anamorphic
|
||
// content (DVD: 720x480/576 pixels shown as 16:9 or 4:3) the coded
|
||
// pixels are NOT square — keep the coded height and derive the width so
|
||
// DisplayWidth:DisplayHeight carries the intended DAR (e.g. 720x576
|
||
// 16:9 → 1024x576). Without this, players use the square-pixel ratio
|
||
// and show the disc as 5:4 / 3:2 instead of 16:9.
|
||
let (display_width, display_height) = match v.display_aspect {
|
||
Some((an, ad)) if an > 0 && ad > 0 && h > 0 => ((h * an + ad / 2) / ad, h),
|
||
_ => (w, h),
|
||
};
|
||
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 (Matroska 0x9D) is a bitstream property
|
||
// (`top_field_first`) the IFO/MPLS scan cannot know, so it is NOT set
|
||
// here — it would only ever be a guess. Default to UNDETERMINED; the
|
||
// mux stream (`MkvStream`) sets the MEASURED value from the first
|
||
// coded picture's `PictureInfo` before the muxer writes the header.
|
||
// If an interlaced track ever reaches the muxer still UNDETERMINED,
|
||
// that is a parser/source gap and is logged loudly — never faked.
|
||
field_order: ebml::FIELD_ORDER_UNDETERMINED,
|
||
// DefaultDecodedFieldDuration is DELIBERATELY NOT emitted (0 here
|
||
// suppresses the element; see the writer in `MkvMuxer::new`).
|
||
//
|
||
// rc.5.1 added it (= half the frame period, 20 ms for 576i25) to try
|
||
// to fix the Windows-fps report, on the theory that Windows derives
|
||
// fps from it. The captured SOTL evidence proves the opposite: with
|
||
// FlagInterlaced=1 + DefaultDuration=40 ms + DefaultDecodedFieldDuration=20 ms,
|
||
// Windows Explorer reports 12.5 fps (half), and MediaInfo flips the
|
||
// track to "Frame rate mode: Variable" with no clean rate. MakeMKV's
|
||
// correct rip of the same disc OMITS DefaultDecodedFieldDuration,
|
||
// keeps FlagInterlaced=1 + FieldOrder=TFF + DefaultDuration=40 ms, and
|
||
// Explorer reports the full 25 fps with MediaInfo "Constant". ffmpeg's
|
||
// matroskaenc.c does the same (full-frame DefaultDuration, no field
|
||
// duration). The lone frame-rate signal every tool actually trusts is
|
||
// `1 / DefaultDuration`; that full-frame value (40 ms → 25 fps) is kept
|
||
// below. Dropping the field-duration element removes the per-field
|
||
// signal that made Explorer halve the rate.
|
||
//
|
||
// Trade-off: the container no longer carries an explicit per-field
|
||
// decoded duration. Nothing is lost in practice — the interlace
|
||
// signaling that deinterlacers and MediaInfo rely on lives in the
|
||
// MPEG-2 elementary stream's picture_coding_extension (picture_structure /
|
||
// top_field_first), which MediaInfo reads directly (so it still reports
|
||
// "Interlaced / Top Field First"), and the container still flags
|
||
// FlagInterlaced=1 + FieldOrder=TFF so players keep deinterlacing.
|
||
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, 6, 0))
|
||
} else {
|
||
None
|
||
},
|
||
// HDR10 static metadata is measured from the HEVC SEI at mux time,
|
||
// not known at construction. The mux stream sets it from the first
|
||
// coded picture's PictureInfo before the header is written (the same
|
||
// deferred path FieldOrder uses). `None` here → omitted unless seen.
|
||
hdr10: None,
|
||
mvc_params: None,
|
||
}
|
||
}
|
||
|
||
/// Build an audio track from an [`AudioStream`]. The codec ID follows the
|
||
/// Matroska registry; every DTS family member (core, DTS-HD HR, DTS-HD MA)
|
||
/// maps to the single registered `A_DTS` ID (see the note below).
|
||
pub fn audio(a: &AudioStream) -> Self {
|
||
// The Matroska codec-ID registry defines `A_DTS` for the entire
|
||
// DTS family — the spec text for `A_DTS` explicitly states it
|
||
// "Supports DTS, DTS-ES, DTS-96/26, DTS-HD High Resolution Audio
|
||
// and DTS-HD Master Audio." Players distinguish core vs HD-HRA vs
|
||
// HD-MA by parsing the DTS bitstream extension substreams, not by
|
||
// the container codec ID. The previously-emitted `A_DTS/MA` and
|
||
// `A_DTS/HR` suffixes are NOT registered codec IDs; strict parsers
|
||
// (libmatroska) and some hardware renderers fail to recognise the
|
||
// track at all. Emit plain `A_DTS` for every DTS variant — the
|
||
// lossless MA / HRA payload bytes are unchanged, only the
|
||
// container codec-ID string differs.
|
||
let codec_id = match a.codec {
|
||
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,
|
||
_ => ebml::CODEC_AC3,
|
||
};
|
||
// Unknown sample rate / channel layout: emit 0 so the serializer omits
|
||
// the SamplingFrequency / Channels element (Matroska supplies its own
|
||
// spec default) rather than writing a fabricated 48000 Hz / 6-channel
|
||
// value into the file.
|
||
let sr = if matches!(a.sample_rate, SampleRate::Unknown) {
|
||
0.0
|
||
} else {
|
||
a.sample_rate.hz()
|
||
};
|
||
let ch = if matches!(a.channels, AudioChannels::Unknown) {
|
||
0
|
||
} else {
|
||
a.channels.count()
|
||
};
|
||
|
||
let name = a.label.clone();
|
||
|
||
Self {
|
||
track_type: ebml::TRACK_TYPE_AUDIO,
|
||
codec_id,
|
||
language: a.language.clone(),
|
||
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,
|
||
bit_depth: 0,
|
||
dv_config: None,
|
||
hdr10: None,
|
||
mvc_params: None,
|
||
}
|
||
}
|
||
|
||
/// Build a subtitle track from a [`SubtitleStream`]. PGS maps to
|
||
/// `S_HDMV/PGS` and DVD VobSub to `S_VOBSUB`; the stream's `codec_data`
|
||
/// (the VobSub `.idx` palette header for DVD) becomes the track's
|
||
/// CodecPrivate. The forced-display flag is propagated from the stream.
|
||
pub fn subtitle(s: &SubtitleStream) -> Self {
|
||
let codec_id = match s.codec {
|
||
Codec::DvdSub => ebml::CODEC_VOBSUB,
|
||
_ => ebml::CODEC_PGS,
|
||
};
|
||
Self {
|
||
track_type: ebml::TRACK_TYPE_SUBTITLE,
|
||
codec_id,
|
||
language: s.language.clone(),
|
||
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,
|
||
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/ffmpeg 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>,
|
||
/// 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 (a cluster only opens on a track-0 video keyframe). If this is
|
||
/// non-zero at `finish()` and not a single frame was ever written, the
|
||
/// caller produced an empty MKV — surfaced as an error rather than a
|
||
/// silently empty file. See `write_frame` for the track-0 invariant.
|
||
dropped_pre_cluster: u64,
|
||
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
|
||
/// primary video track. A non-keyframe MVC base frame lives in a BlockGroup
|
||
/// (to carry its dependent-view BlockAdditional) and needs a `ReferenceBlock`
|
||
/// so players don't mistake it for a keyframe; it references this keyframe.
|
||
last_video_keyframe_ticks: Option<i64>,
|
||
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
|
||
/// is unreliable, so the channel count written in the track header is
|
||
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
|
||
/// track. Each entry records the file offset of the 1-byte Channels value
|
||
/// (to patch in place) and the IFO-claimed count (to warn on disagreement);
|
||
/// `corrected` flips once patched so we only act on the first frame.
|
||
ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup>,
|
||
/// `--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>,
|
||
}
|
||
|
||
/// Deferred AC-3 channel-count correction: the track header's `Channels` byte
|
||
/// is written up-front from the (unreliable) IFO count; on the first AC-3 frame
|
||
/// for the track the value is rewritten from the bitstream `acmod`.
|
||
struct Ac3ChannelFixup {
|
||
/// Absolute file offset of the 1-byte Channels value in the Tracks element.
|
||
value_offset: u64,
|
||
/// Channel count the IFO claimed (already written at `value_offset`).
|
||
claimed: u8,
|
||
/// True once the first frame has been parsed and the value finalised.
|
||
corrected: bool,
|
||
}
|
||
|
||
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
|
||
///
|
||
/// The classic 1 ms scale truncates two distinct cadences onto the same tick:
|
||
/// - 23.976 fps video frames are ~41.7 ms apart, but with B-frame reorder two
|
||
/// neighbouring frames can round to the same whole millisecond — a decoder
|
||
/// then derives colliding DTS ("non monotonically increasing dts").
|
||
/// - TrueHD audio access units are 0.833 ms (1/1200 s); at 1 ms granularity
|
||
/// every AU truncates to a 1 ms grid and the per-track monotonic nudge has to
|
||
/// space them at a fabricated 1 ms instead of their true 0.833 ms.
|
||
///
|
||
/// 0.1 ms resolves both: 41.7 ms and 0.833 ms each map to distinct ticks, so
|
||
/// frames stop colliding and audio keeps its real cadence. Player/parser
|
||
/// support for sub-millisecond TimestampScale is universal (it is the spec
|
||
/// default mechanism). The cost is a smaller per-cluster i16 span (see
|
||
/// `MAX_BLOCK_REL`), handled by splitting clusters and emitting a Cue for the
|
||
/// split (see `write_frame`).
|
||
const TIMESTAMP_SCALE_NS: i64 = 100_000;
|
||
|
||
/// Nominal new-cluster interval (2 s) expressed in TimestampScale ticks.
|
||
///
|
||
/// A keyframe only OPENS a new cluster once this much has elapsed since the open
|
||
/// cluster's timestamp, so the actual cluster span runs from this value up to
|
||
/// roughly this value plus one GOP (the next keyframe lands a GOP later). With a
|
||
/// typical ≤ 1 s GOP that worst-case span (~3 s ≈ 30_000 ticks) stays UNDER the
|
||
/// i16 block-relative limit (`MAX_BLOCK_REL` = 32_767 ticks ≈ 3.27 s at the
|
||
/// 0.1 ms scale), so video keyframes drive Cue-aligned cluster boundaries and
|
||
/// the i16-overflow split path stays a rare fallback (long audio-only stretches
|
||
/// or pathological multi-second GOPs) rather than the common case. The classic
|
||
/// 5 s window would, at this scale, force an unaligned i16 split inside every
|
||
/// cluster.
|
||
const CLUSTER_DURATION_TICKS: i64 = 2_000 * 1_000_000 / TIMESTAMP_SCALE_NS;
|
||
|
||
/// Maximum block-relative timestamp expressible in the signed 16-bit
|
||
/// SimpleBlock/Block field (`i16::MAX` ticks). A frame whose offset from the
|
||
/// open cluster's timestamp falls outside `i16::MIN..=i16::MAX` ticks forces a
|
||
/// new cluster (see `write_frame`) so the `as i16` cast can never wrap — in
|
||
/// EITHER direction. PES timestamps come from untrusted disc/file bytes and can
|
||
/// back-jump on discontinuities, so the lower bound matters as much as the
|
||
/// upper one. At a 0.1 ms scale i16::MAX is ~3.27 s, well under the 5 s cluster
|
||
/// window, so a long-GOP / audio-only stretch can hit this bound before the
|
||
/// keyframe boundary — the split path must (and does) push a Cue.
|
||
const MAX_BLOCK_REL: i64 = i16::MAX as i64;
|
||
/// Minimum block-relative timestamp expressible in the signed 16-bit field.
|
||
const MIN_BLOCK_REL: i64 = i16::MIN as i64;
|
||
|
||
// The clip-boundary timeline-continuity corrector (`TimelineContinuity`) lives
|
||
// in `crate::mux::timeline` — shared verbatim with the `demux://` sink. It is
|
||
// imported below where the muxer uses it.
|
||
|
||
/// Force a per-track block timestamp (in TimestampScale ticks) to be strictly
|
||
/// later than the previous one written for that track. `prev` is the last
|
||
/// timestamp for the track (`None` for the first frame). Fixes non-monotonic
|
||
/// DTS: some audio PES PTS truncate to the same tick as the prior frame (or tick
|
||
/// back one from rounding), which ffmpeg/strict players reject. At the 0.1 ms
|
||
/// scale a TrueHD AU (0.833 ms = ~8 ticks) no longer collides with its
|
||
/// neighbour, so this rarely fires for lossless audio — but a +1-tick nudge
|
||
/// (0.1 ms, sub-AU and inaudible) still guards genuine same-tick collisions on
|
||
/// any no-reorder track. Never moves a timestamp 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. The strictly-monotonic nudge is applied to
|
||
/// AUDIO/SUBTITLE tracks only; ALL VIDEO tracks are returned UNCHANGED.
|
||
///
|
||
/// With B-frames, a video frame's presentation PTS is legitimately
|
||
/// non-monotonic in decode/storage order (a B-frame sits between its anchors,
|
||
/// below the frame stored just before it). Forcing it strictly-increasing
|
||
/// clobbers those PTS to prev+1ms — a `copy` remux preserves the (wrong) value,
|
||
/// but a decoder derives DTS from the HEVC POC and finds them colliding
|
||
/// ("non monotonically increasing dts", thousands per title). Matroska
|
||
/// SimpleBlock permits non-monotonic block timestamps (signed block-relative
|
||
/// offsets), so video keeps its true PES PTS; only no-reorder tracks (audio,
|
||
/// subtitles), where a same-millisecond collision IS a real defect, get nudged.
|
||
///
|
||
/// The exemption is keyed on `is_video` (track type), NOT a track index: a
|
||
/// title can carry more than one video track — e.g. a Dolby Vision enhancement
|
||
/// layer at index 1 — and every one must keep its true PTS. Keying on
|
||
/// `track_idx == 0` clamped the EL and reintroduced the exact non-monotonic-DTS
|
||
/// warning this exemption exists to prevent.
|
||
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,
|
||
/// returning the buffer and the used length. Track numbers are small (1-based,
|
||
/// a handful of tracks), so 1 byte covers `< 0x80` and 2 bytes covers the rest;
|
||
/// no heap allocation, called once per block on the mux hot path.
|
||
///
|
||
/// The 2-byte form holds 14 payload bits (max 0x3FFF). The `debug_assert`
|
||
/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and
|
||
/// OR-ing the 0x40 length marker would clobber it, corrupting the track
|
||
/// number. Not reachable today (track numbers are `i+1` over a few streams),
|
||
/// so this documents the bound rather than handling 3-byte VINTs.
|
||
fn track_vint(track_num: usize) -> ([u8; 2], usize) {
|
||
if track_num < 0x80 {
|
||
([(track_num as u8) | 0x80, 0], 1)
|
||
} else {
|
||
debug_assert!(
|
||
track_num < 0x4000,
|
||
"track number {track_num} exceeds the 14-bit 2-byte EBML VINT range"
|
||
);
|
||
([0x40 | ((track_num >> 8) as u8), track_num as u8], 2)
|
||
}
|
||
}
|
||
|
||
impl<W: Write + Seek> MkvMuxer<W> {
|
||
/// Create a new MKV muxer: writes EBML header, Segment start, Info, Tracks, Chapters.
|
||
pub fn new(
|
||
mut writer: W,
|
||
tracks: &[MkvTrack],
|
||
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();
|
||
// Absolute file offset where the CUES Seek entry begins, so that — if no
|
||
// Cues element is ultimately written (zero cue points) — the entry can be
|
||
// overwritten with a Void at finish() instead of leaving a SeekHead
|
||
// pointer that resolves to whatever element (Tags / EOF) happens to land
|
||
// at the Cues offset. See `cues_seek_entry_pos` / `finish`.
|
||
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 {
|
||
// The source declared no duration (e.g. HD-DVD — its `.MAP` timemaps
|
||
// are not parsed). Reserve a DURATION placeholder now and back-patch
|
||
// it at finish() from the muxed timeline, so the file still declares a
|
||
// runtime instead of showing "unknown". The 8-byte float payload sits
|
||
// 3 bytes in (2-byte ID `0x4489` + 1-byte size `0x88`).
|
||
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 (MediaInfo "Writing application"/"library").
|
||
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 ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
|
||
std::collections::HashMap::new();
|
||
for (i, track) in tracks.iter().enumerate() {
|
||
let track_uid = (i + 1) as u64 | 0x100_0000;
|
||
track_uids.push(track_uid);
|
||
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
|
||
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
|
||
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
|
||
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
|
||
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
|
||
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
|
||
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.is_forced {
|
||
ebml::write_uint(&mut writer, ebml::FLAG_FORCED, 1)?;
|
||
}
|
||
|
||
if let Some(ref cp) = track.codec_private {
|
||
ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?;
|
||
}
|
||
// Pre-0.13 a deferred codecPrivate path existed for video tracks
|
||
// (placeholder reserve + later seek-back fill via
|
||
// `fill_codec_private`). The PES pipeline hands codec_private
|
||
// up-front via the DiscTitle, so the deferred path was never
|
||
// exercised — removed in the 0.13 dead-code sweep.
|
||
|
||
// DefaultDuration — frame duration in nanoseconds
|
||
if track.default_duration_ns > 0 {
|
||
ebml::write_uint(
|
||
&mut writer,
|
||
ebml::DEFAULT_DURATION,
|
||
track.default_duration_ns,
|
||
)?;
|
||
}
|
||
|
||
// DefaultDecodedFieldDuration (one FIELD = half a frame), a DIRECT
|
||
// child of TrackEntry. The production video path now ALWAYS passes
|
||
// `field_duration_ns == 0` (see `MkvTrack::video`) so this element is
|
||
// NOT written: emitting it (20 ms for 576i25) is exactly what made
|
||
// Windows Explorer report 12.5 fps and MediaInfo flip to VFR on the
|
||
// captured SOTL rip, while MakeMKV — which omits it — shows the full
|
||
// 25 fps. The guard below is retained so a non-zero value still emits
|
||
// a well-formed element for any future caller / round-trip test, but
|
||
// the muxer's own callers no longer trigger it.
|
||
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)?;
|
||
// Scan type. FlagInterlaced: 1 = interlaced, 2 = progressive.
|
||
// FieldOrder (0x9D) is only written for interlaced content with a
|
||
// determined order: TFF = 1, BFF = 6, 0 = progressive, and the
|
||
// element is omitted entirely when undetermined (RFC 9559).
|
||
ebml::write_uint(
|
||
&mut writer,
|
||
ebml::FLAG_INTERLACED,
|
||
if track.interlaced {
|
||
ebml::INTERLACED_INTERLACED
|
||
} else {
|
||
ebml::INTERLACED_PROGRESSIVE
|
||
},
|
||
)?;
|
||
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 and sets it on the track), so this writes the
|
||
// right value the first time — no later rewrite.
|
||
ebml::write_uint(&mut writer, ebml::FIELD_ORDER, track.field_order as u64)?;
|
||
}
|
||
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 (sibling of
|
||
// Video) carries the mvcC MVCDecoderConfigurationRecord so players /
|
||
// mediainfo recognise the dependent (right-eye) view that rides as a
|
||
// per-frame BlockAdditional under this mapping (BlockAddIDValue = 2).
|
||
if let Some((subset_sps, pps)) = track.mvc_params.as_ref() {
|
||
if let Some(record) = mvc_decoder_config_record(subset_sps, pps) {
|
||
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
|
||
ebml::write_uint(
|
||
&mut writer,
|
||
ebml::BLOCK_ADD_ID_VALUE,
|
||
BLOCK_ADD_ID_VALUE_MVC,
|
||
)?;
|
||
ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?;
|
||
ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, &record)?;
|
||
ebml::end_master(&mut writer, map_pos)?;
|
||
} else {
|
||
tracing::warn!(
|
||
target: "mux",
|
||
"MVC track: could not build MVCDecoderConfigurationRecord from the \
|
||
dependent view's parameter sets (subset_sps={} B, pps={} B); \
|
||
emitting no mvcC mapping — the 3D pairing will not be signalled.",
|
||
subset_sps.len(),
|
||
pps.len(),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Dolby Vision signaling — BlockAdditionMapping is a child of the
|
||
// TrackEntry (sibling of Video). Carries the dvcC so players /
|
||
// mediainfo 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 ACTUAL file offset of the 1-byte Channels value
|
||
// so an AC-3 track can correct it from the bitstream acmod on
|
||
// its first frame (the IFO nibble is unreliable). Rather than
|
||
// assume write_uint's encoding (ID + size widths), write the
|
||
// element's ID and size explicitly, then record the position
|
||
// immediately before the value byte. Channels is 1..=255 so
|
||
// the value is exactly one byte (Size = 1), and the acmod
|
||
// correction is likewise 1..=255 — the width never changes, 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])?;
|
||
if track.codec_id == ebml::CODEC_AC3 {
|
||
ac3_channel_fixups.insert(
|
||
i,
|
||
Ac3ChannelFixup {
|
||
value_offset,
|
||
claimed: track.channels,
|
||
corrected: false,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
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,
|
||
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(),
|
||
primary_video_track: tracks
|
||
.iter()
|
||
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
|
||
track_has_mvc_mapping: tracks.iter().map(|t| t.mvc_params.is_some()).collect(),
|
||
continuity: TimelineContinuity::new(),
|
||
cues: Vec::new(),
|
||
frame_count: 0,
|
||
dropped_pre_cluster: 0,
|
||
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: None,
|
||
ac3_channel_fixups,
|
||
opening_capture: None,
|
||
})
|
||
}
|
||
|
||
/// 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 fn set_opening_capture(&mut self, capture: Option<crate::diag::OpeningCapture>) {
|
||
self.opening_capture = capture;
|
||
}
|
||
|
||
/// Write a single frame.
|
||
///
|
||
/// When `duration_ns` is `Some`, the frame is emitted as a
|
||
/// `BlockGroup` with `BlockDuration` so the player knows exactly
|
||
/// when to remove the on-screen artifact (the practical case is
|
||
/// PGS subtitles — without it, the last bitmap lingers until the
|
||
/// next display set replaces it). Otherwise a plain `SimpleBlock`.
|
||
/// Rewrite a video track's `FieldOrder` value in place from the MEASURED
|
||
/// field order carried on the first coded picture, replacing the scan-time
|
||
/// guess written at construction. This is the fix for the "we parsed
|
||
/// `top_field_first` then ignored it" red flag: the muxer now stamps the
|
||
/// field order the bitstream actually states, not an assumption.
|
||
///
|
||
/// Idempotent — only the first call per track patches (later calls and
|
||
/// non-interlaced / non-video tracks are no-ops). `Progressive` / unknown
|
||
/// (`None`) leaves the written value untouched: an interlaced track keeps
|
||
/// its guess rather than being cleared via a multi-element change. The byte
|
||
/// width is fixed (FieldOrder is 0..=14), so the in-place rewrite is valid.
|
||
///
|
||
/// `block_additional`, when `Some`, is attached to the frame as a Matroska
|
||
/// `BlockAdditional` (BlockAddID=2) — Blu-ray 3D (MVC): the base view is the
|
||
/// Block and the dependent (right-eye) access unit rides as the
|
||
/// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a
|
||
/// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not
|
||
/// a keyframe. `None` for every non-3D frame.
|
||
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<()> {
|
||
// --log-level 3: capture the first ~100 coded frames per track to the
|
||
// side file BEFORE any timeline mangling, with the codec parser's own
|
||
// frame PTS — so an opening-GOP / mid-GOP-open / menu issue is
|
||
// reconstructable from the log + side file alone (no disc). No-op (and
|
||
// no allocation) on normal runs; the capture is `None`.
|
||
if let Some(cap) = self.opening_capture.as_mut() {
|
||
cap.record(track_idx, pts_ns, keyframe, data);
|
||
}
|
||
|
||
// 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);
|
||
|
||
// The clip-boundary epoch decision is driven by the PRIMARY video track
|
||
// ONLY (the first video track, NOT the literal index 0 — the M2TS/PMT
|
||
// path can list an audio ES before the video ES, so streams[0] may be
|
||
// audio). A title can carry a SECOND video track — a Dolby Vision
|
||
// enhancement layer — whose PTS runs on its OWN timeline, interleaved
|
||
// with the base layer's. The two video PTS sequences overlap, so the EL's
|
||
// frames look like multi-second backward jumps against the base layer's
|
||
// frontier and would false-trigger an epoch reset on every GOP (the exact
|
||
// ratchet that inflated Top Gun's 1-clip timeline to ~7 h). Only the base
|
||
// video layer establishes/advances the frontier and opens epochs; the EL
|
||
// — like audio and subtitles — rides the current offset.
|
||
let drives_epoch = Some(track_idx) == self.primary_video_track;
|
||
|
||
// Map the raw PES PTS onto the continuous output timeline FIRST, before
|
||
// any base/cluster math: at a non-seamless clip / layer-break boundary
|
||
// the source PES PTS jumps backward. Rebasing it (a global offset across
|
||
// all tracks, A/V-sync-preserving) keeps the boundary from becoming a
|
||
// band of non-monotonic block timestamps. Only the PRIMARY video track
|
||
// drives the boundary decision; every other track (audio, subtitle, DV
|
||
// EL) rides the current offset. No-op for single-clip titles.
|
||
let pts_ns = self.continuity.adjust(pts_ns, drives_epoch);
|
||
let raw_ticks = pts_ns / TIMESTAMP_SCALE_NS;
|
||
|
||
// Cluster boundaries normally coincide with a video keyframe so every
|
||
// Cues entry resolves to a seekable IDR at the cluster start. Keyed on
|
||
// the primary video track (first video index) when the title HAS video;
|
||
// for an audio-only / subtitle-only title (no video track) fall back to
|
||
// the first track (index 0) so its keyframes still open clusters —
|
||
// otherwise no cluster would ever open and every frame would be dropped.
|
||
let cluster_driver = self.primary_video_track.unwrap_or(0);
|
||
let is_video_key = keyframe && track_idx == cluster_driver;
|
||
|
||
// Derive the timestamp base from the first *kept* keyframe (the frame
|
||
// that opens the first cluster), NOT the first frame merely seen. The
|
||
// first frame seen can have a higher display PTS than the subsequent
|
||
// I-frame (B-frame reordering / a PTS discontinuity), which would make
|
||
// later cluster/cue timestamps negative and wrap to ~u64::MAX on the
|
||
// `as u64` cast in `start_cluster`/`finish`. Anchoring on the first kept
|
||
// keyframe guarantees the open cluster's timestamp is 0 and all later
|
||
// relative offsets are computed from a frame we actually wrote.
|
||
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(());
|
||
}
|
||
self.base_pts_ticks = Some(raw_ticks);
|
||
raw_ticks
|
||
}
|
||
};
|
||
// Floor at 0: base is the first kept keyframe, so any frame with an
|
||
// earlier PTS (audio/subtitle arriving with a pre-keyframe timestamp, or
|
||
// a back-jump on a stream discontinuity) would compute negative here,
|
||
// which would wrap to ~u64::MAX on the `as u64` cluster/cue write and
|
||
// could overflow the i16 block-relative cast. Frames before the first
|
||
// kept keyframe are clamped to t=0 rather than corrupting the timeline.
|
||
let pts_ticks = (raw_ticks - base).max(0);
|
||
|
||
// Strictly-monotonic block timestamps — AUDIO/SUBTITLE ONLY. Some audio
|
||
// PES PTS truncate to the same tick as the previous frame (or tick back
|
||
// one); nudge those to prev+1 tick (sub-frame, inaudible).
|
||
//
|
||
// VIDEO is EXEMPT: with B-frames, presentation PTS is legitimately
|
||
// non-monotonic in decode/storage order (a B-frame's PTS sits between its
|
||
// anchors, below the frame stored before it). Forcing it
|
||
// strictly-increasing clobbers those PTS, which a `copy` remux preserves
|
||
// but a decoder rejects — it derives DTS from the HEVC POC and finds them
|
||
// colliding ("non monotonically increasing dts"). Matroska SimpleBlock
|
||
// permits non-monotonic block timestamps (negative block-relative
|
||
// offsets), so leave the true PES PTS intact for video.
|
||
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) {
|
||
// The block-relative timestamp is a signed 16-bit value, so a
|
||
// frame whose offset from the current cluster's timestamp falls
|
||
// outside i16::MIN..=i16::MAX ticks (~±3.27 s at the 0.1 ms
|
||
// scale) would silently wrap on the `as i16` cast, corrupting A/V
|
||
// sync. The keyframe-driven boundary above only fires on a video
|
||
// keyframe — a long audio-only stretch, a very long GOP with no
|
||
// intervening keyframe (positive direction), or an
|
||
// audio/subtitle PES whose PTS back-jumps below the open cluster
|
||
// (negative direction) can drift past the i16 range. Force a
|
||
// fresh cluster here even without a keyframe to keep the cast in
|
||
// range. pts_ticks is already floored at 0 above, so the new
|
||
// cluster timestamp never wraps on the `as u64` write in
|
||
// start_cluster.
|
||
//
|
||
// This split cluster is NOT keyframe-aligned, but it MUST still
|
||
// carry a Cue entry: at the finer 0.1 ms scale these forced
|
||
// splits are routine (any GOP/audio run over ~3.27 s triggers
|
||
// one), so omitting them would leave multi-second gaps in the
|
||
// seek index where a player's `-ss` lands at the wrong cluster.
|
||
// The Cue points at this cluster's start; a player seeking here
|
||
// resumes decode from the first block (it back-references the
|
||
// prior keyframe via the codec, as players already do for
|
||
// non-IDR cue targets). Cue track is the current frame's track.
|
||
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,
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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 timestamp so a missing source duration can be
|
||
// back-patched from the real muxed runtime at finish().
|
||
// Track the block END (start + its own duration when known), not just
|
||
// the start, so a back-patched Segment Duration covers the final frame's
|
||
// full presentation instead of understating the runtime by one frame.
|
||
let block_end_ticks =
|
||
pts_ticks + duration_ns.map_or(0, |d| (d as i64 / TIMESTAMP_SCALE_NS).max(1));
|
||
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(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64);
|
||
// A BlockAdditional with BlockAddID=2 is only conforming when the track
|
||
// declared the matching mvcC BlockAdditionMapping. If it did not (the
|
||
// dependent view's parameter sets were never captured before the header
|
||
// was written), drop the additional and emit a plain block rather than a
|
||
// non-conforming file with an orphaned BlockAddID.
|
||
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,
|
||
};
|
||
match block_additional {
|
||
// MVC: base view Block + dependent-view BlockAdditional, always a
|
||
// BlockGroup. Non-keyframe base frames get a ReferenceBlock to the
|
||
// last keyframe (a keyframe carries none), so a player never treats a
|
||
// P/B frame as a seek point.
|
||
Some(additional) => {
|
||
let reference = if keyframe {
|
||
None
|
||
} else {
|
||
// Offset (ticks) of the referenced keyframe relative to this
|
||
// block. A non-keyframe MUST carry a ReferenceBlock or a reader
|
||
// treats it as a seek point; fall back to 0 (self-relative) in
|
||
// the pre-first-keyframe corner (unreachable in practice — such
|
||
// frames are dropped before a cluster opens) so the marker is
|
||
// never absent.
|
||
Some(
|
||
self.last_video_keyframe_ticks
|
||
.map(|kf| kf - pts_ticks)
|
||
.unwrap_or(0),
|
||
)
|
||
};
|
||
self.write_block_group_mvc(
|
||
track_idx + 1,
|
||
relative_ts,
|
||
data,
|
||
additional,
|
||
reference,
|
||
duration_ticks,
|
||
)?;
|
||
}
|
||
None => match duration_ticks {
|
||
// BlockDuration present (PGS subtitles) → BlockGroup.
|
||
Some(dt) => {
|
||
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, dt)?;
|
||
}
|
||
None => {
|
||
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
|
||
}
|
||
},
|
||
}
|
||
// Remember the last PRIMARY-video keyframe's tick so a later non-keyframe
|
||
// MVC base frame references a keyframe on its OWN track (see the
|
||
// block_additional path above). Gating to the primary video track avoids a
|
||
// secondary video track's keyframe becoming a cross-track reference target.
|
||
if keyframe && Some(track_idx) == self.primary_video_track {
|
||
self.last_video_keyframe_ticks = 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 the AC-3 track's Channels element from the bitstream acmod on
|
||
// the FIRST frame of the track. The DVD IFO audio nibble is unreliable
|
||
// (it claims 5.1 on a 2.0 stream); the bitstream acmod is authoritative.
|
||
// Only the first frame triggers it; the byte width is unchanged so the
|
||
// patch is a single-byte in-place rewrite (then restore position).
|
||
if let Some(fixup) = self.ac3_channel_fixups.get_mut(&track_idx) {
|
||
if !fixup.corrected {
|
||
match super::codec::ac3::acmod_channels(data) {
|
||
Some(actual) if actual > 0 => {
|
||
if actual != fixup.claimed {
|
||
tracing::warn!(
|
||
target: "mux",
|
||
"AC-3 track {track_idx}: IFO claimed {} channels but bitstream acmod says {}; trusting the bitstream (possible wrong-stream selection)",
|
||
fixup.claimed,
|
||
actual,
|
||
);
|
||
let here = self.writer.stream_position()?;
|
||
self.writer
|
||
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
|
||
self.writer.write_all(&[actual])?;
|
||
self.writer.seek(std::io::SeekFrom::Start(here))?;
|
||
}
|
||
fixup.corrected = true;
|
||
}
|
||
// Frame too short to carry the BSI bits — keep the passed
|
||
// (IFO) value and try again on the next frame.
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Finish the MKV file: write Cues element.
|
||
///
|
||
/// # Track-0 invariant
|
||
///
|
||
/// A cluster only opens on a track-0 video keyframe, so the caller must
|
||
/// supply track 0 as the video track and deliver a keyframe on it before
|
||
/// (or alongside) other-track data. If no track-0 keyframe ever arrives,
|
||
/// every `write_frame` is silently dropped; rather than emit a structurally
|
||
/// valid but empty MKV (zero clusters, zero frames), `finish` returns
|
||
/// `Error::MkvInvalid` when frames were submitted but none were written.
|
||
pub fn finish(mut self) -> io::Result<()> {
|
||
// A title that produced no frames (e.g. fully unreadable, or every
|
||
// frame dropped before the first track-0 keyframe opened a cluster)
|
||
// would otherwise yield a structurally-empty MKV with no clusters or
|
||
// cues. Surface that as an error rather than writing valid-but-empty
|
||
// output.
|
||
if self.frame_count == 0 {
|
||
return Err(crate::error::Error::MkvInvalid.into());
|
||
}
|
||
// 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()?;
|
||
|
||
// 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
|
||
// reads the container `BPS` tag (Windows Explorer's MKV property
|
||
// handler) rather than computing bitrate from stream size shows a
|
||
// bitrate for EVERY track this way, not just CBR audio.
|
||
self.write_bps_tags()?;
|
||
|
||
// Back-patch SeekHead SeekPosition values now that all element offsets
|
||
// are known. When no Cues element was written (zero cue points), the
|
||
// CUES entry's SeekPosition would otherwise be back-patched to
|
||
// `cues_offset`, which now holds Tags / EOF — a dangling pointer to a
|
||
// non-Cues element. Skip that fixup and instead Void the whole CUES Seek
|
||
// entry (below) so the SeekHead carries no false pointer.
|
||
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,
|
||
ebml::CHAPTERS => self
|
||
.chapters_offset
|
||
.expect("CHAPTERS seek fixup present => chapters_offset is Some"),
|
||
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 {
|
||
// The timeline never advanced past tick 0 (a degenerate
|
||
// single-frame recovery at t=0 with no per-frame duration): we
|
||
// can't derive a runtime, so DON'T leave a literal DURATION=0.0
|
||
// (players read that as a zero-length/corrupt file). Void the
|
||
// whole 11-byte DURATION element (ID 2 + size 1 + 8-byte payload)
|
||
// so the Segment simply omits it, as an unknown-duration source
|
||
// did before the back-patch. `pos` is the payload start (+3 from
|
||
// the element start), so 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. The entry is a fixed 21-byte
|
||
// Seek master: SEEK(2 ID + 1 size) + SEEK_ID(2+1) + 4-byte target id +
|
||
// SEEK_POSITION(2+1) + 8-byte value = 21 bytes. A Void (0xEC, 1-byte ID)
|
||
// with a 1-byte size VINT covering the remaining 19 bytes occupies
|
||
// exactly 1 + 1 + 19 = 21 bytes, overwriting the entry in place without
|
||
// shifting any following element.
|
||
if !have_cues {
|
||
if 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_secs). Mirrors mkvmerge's per-track statistics tag so readers
|
||
/// that surface the container tag (Windows Explorer) show a bitrate for
|
||
/// every track. No-op when the duration is unknown (can't compute a rate)
|
||
/// or no track carried any bytes.
|
||
fn write_bps_tags(&mut self) -> io::Result<()> {
|
||
if self.duration_secs <= 0.0 {
|
||
return Ok(());
|
||
}
|
||
if self.track_bytes.iter().all(|&b| b == 0) {
|
||
return Ok(());
|
||
}
|
||
let tags_pos = ebml::start_master(&mut self.writer, ebml::TAGS)?;
|
||
// Snapshot to avoid borrowing self across the writer borrow.
|
||
let entries: Vec<(u64, u64)> = self
|
||
.track_uids
|
||
.iter()
|
||
.zip(self.track_bytes.iter())
|
||
.map(|(&uid, &bytes)| (uid, bytes))
|
||
.collect();
|
||
for (uid, bytes) in entries {
|
||
if bytes == 0 {
|
||
continue;
|
||
}
|
||
// bits per second = bytes * 8 / duration_secs, rounded to nearest.
|
||
let bps = ((bytes as f64) * 8.0 / self.duration_secs).round() as u64;
|
||
let tag_pos = ebml::start_master(&mut self.writer, ebml::TAG)?;
|
||
// Targets → TagTrackUID (this tag applies to one track).
|
||
let targets_pos = ebml::start_master(&mut self.writer, ebml::TARGETS)?;
|
||
ebml::write_uint(&mut self.writer, ebml::TAG_TRACK_UID, uid)?;
|
||
ebml::end_master(&mut self.writer, targets_pos)?;
|
||
// SimpleTag(TagName="BPS", TagString="<bps>").
|
||
let st_pos = ebml::start_master(&mut self.writer, ebml::SIMPLE_TAG)?;
|
||
ebml::write_string(&mut self.writer, ebml::TAG_NAME, "BPS")?;
|
||
ebml::write_string(&mut self.writer, ebml::TAG_STRING, &bps.to_string())?;
|
||
ebml::end_master(&mut self.writer, st_pos)?;
|
||
ebml::end_master(&mut self.writer, tag_pos)?;
|
||
}
|
||
ebml::end_master(&mut self.writer, tags_pos)?;
|
||
Ok(())
|
||
}
|
||
|
||
fn start_cluster(&mut self, ts_ticks: i64) -> io::Result<()> {
|
||
// Close previous cluster if open
|
||
if self.cluster_open {
|
||
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(())
|
||
}
|
||
|
||
fn write_block_group(
|
||
&mut self,
|
||
track_num: usize,
|
||
relative_ts: i16,
|
||
keyframe: bool,
|
||
data: &[u8],
|
||
duration_ticks: u64,
|
||
) -> io::Result<()> {
|
||
let (tv, tv_len) = track_vint(track_num);
|
||
let track_vint = &tv[..tv_len];
|
||
// The 0x80 Keyframe flag is defined only for SimpleBlock; inside a
|
||
// Block within a BlockGroup that high bit is reserved and MUST be 0
|
||
// (keyframe-ness is signalled by the absence of a ReferenceBlock
|
||
// child). `keyframe` is intentionally unused here — every Block this
|
||
// path emits is intra (PGS subtitle frames carrying a duration).
|
||
let _ = keyframe;
|
||
let flags: u8 = 0x00;
|
||
let block_size = track_vint.len() + 2 + 1 + data.len();
|
||
|
||
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
|
||
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
|
||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||
self.writer.write_all(track_vint)?;
|
||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||
self.writer.write_all(&[flags])?;
|
||
self.writer.write_all(data)?;
|
||
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, duration_ticks)?;
|
||
ebml::end_master(&mut self.writer, bg_pos)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Write a BlockGroup carrying the base view Block plus the MVC dependent
|
||
/// (right-eye) access unit as a `BlockAdditional` (BlockAddID=2), per the
|
||
/// track's `mvcC` BlockAdditionMapping. A non-keyframe frame gets a
|
||
/// `ReferenceBlock` (`reference` = referenced keyframe offset in ticks) so it
|
||
/// is not mistaken for a seek point; `BlockDuration` is written when known.
|
||
fn write_block_group_mvc(
|
||
&mut self,
|
||
track_num: usize,
|
||
relative_ts: i16,
|
||
data: &[u8],
|
||
additional: &[u8],
|
||
reference: Option<i64>,
|
||
duration_ticks: Option<u64>,
|
||
) -> io::Result<()> {
|
||
let (tv, tv_len) = track_vint(track_num);
|
||
let track_vint = &tv[..tv_len];
|
||
// The 0x80 Keyframe flag is SimpleBlock-only; inside a BlockGroup Block
|
||
// it is reserved and MUST be 0 — keyframe-ness is signalled by the
|
||
// presence/absence of ReferenceBlock.
|
||
let flags: u8 = 0x00;
|
||
let block_size = track_vint.len() + 2 + 1 + data.len();
|
||
|
||
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
|
||
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
|
||
ebml::write_size(&mut self.writer, block_size as u64)?;
|
||
self.writer.write_all(track_vint)?;
|
||
self.writer.write_all(&relative_ts.to_be_bytes())?;
|
||
self.writer.write_all(&[flags])?;
|
||
self.writer.write_all(data)?;
|
||
if let Some(dt) = duration_ticks {
|
||
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, dt)?;
|
||
}
|
||
if let Some(ref_off) = reference {
|
||
ebml::write_int(&mut self.writer, ebml::REFERENCE_BLOCK, ref_off)?;
|
||
}
|
||
// BlockAdditions → BlockMore { BlockAddID=2, BlockAdditional=dependent AU }.
|
||
let adds_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_ADDITIONS)?;
|
||
let more_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_MORE)?;
|
||
ebml::write_uint(&mut self.writer, ebml::BLOCK_ADD_ID, BLOCK_ADD_ID_VALUE_MVC)?;
|
||
ebml::write_binary(&mut self.writer, ebml::BLOCK_ADDITIONAL, additional)?;
|
||
ebml::end_master(&mut self.writer, more_pos)?;
|
||
ebml::end_master(&mut self.writer, adds_pos)?;
|
||
ebml::end_master(&mut self.writer, bg_pos)?;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Helpers
|
||
// ============================================================
|
||
|
||
// Old parse_resolution/parse_sample_rate/parse_channels removed —
|
||
// Resolution::pixels(), SampleRate::hz(), AudioChannels::count() replace them.
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::io::Cursor;
|
||
|
||
/// Anamorphic DVD: a 720x576 (R576i) PAL stream flagged 16:9 must write a
|
||
/// DisplayWidth/Height carrying the 16:9 DAR (1024x576), NOT the square-pixel
|
||
/// 720x576 (which players show as ~5:4). Square-pixel video
|
||
/// (`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 dts_variants_map_to_registered_a_dts_codec_id() {
|
||
// The Matroska codec-ID registry defines `A_DTS` for the whole DTS
|
||
// family (core, DTS-HD HRA, DTS-HD MA). The `/MA` and `/HR` suffixes
|
||
// are not 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 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"
|
||
);
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
#[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 (zero cue points), the SeekHead must NOT
|
||
/// retain a CUES entry that back-patches to the Cues offset — that offset now
|
||
/// holds Tags / EOF, a dangling pointer to a non-Cues element. finish() Voids
|
||
/// the unused CUES Seek entry instead. (The empty-cues case is defensive —
|
||
/// the normal path pushes a cue with every cluster — so the test clears the
|
||
/// cue list directly before finalizing.)
|
||
#[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: scan all 8-byte SeekPosition values in the
|
||
// SeekHead and ensure none equals the offset where Cues would have been.
|
||
// (Sanity: the file must still parse 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"
|
||
);
|
||
}
|
||
|
||
#[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 presentation PTS sits below the frame stored before it
|
||
// must NOT be nudged to prev+1ms (that clobbering is what produced the
|
||
// "non monotonically increasing dts" flood on decode).
|
||
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 enhancement
|
||
/// layer is video but NOT track 0. The exemption must follow track TYPE, so
|
||
/// the EL's B-frame PTS are preserved exactly like the main video's — not
|
||
/// clamped to prev+1ms (which reintroduced the non-monotonic-DTS flood on
|
||
/// the EL stream). Drives the muxer through both video tracks and asserts
|
||
/// every video block timecode equals its source PTS.
|
||
#[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 output regression (the symptom, at the block-timecode level):
|
||
/// a large clip-boundary reset WITH an interleaved straggler audio frame
|
||
/// from clip 1's tail, driven through the full muxer. Asserts cluster
|
||
/// timestamps are monotonic non-decreasing AND the timeline reaches past the
|
||
/// boundary (clip 2 present) without ratcheting. This is the test that would
|
||
/// have caught BOTH the original `-820000` non-monotonic band and the
|
||
/// straggler ratchet that made everything after the boundary unseekable.
|
||
#[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 far beyond clip1+clip2 (~605s): well under 2× clip1.
|
||
assert!(
|
||
max < tick(1_000_000),
|
||
"no ratchet: max cluster ts {max} ticks must stay near 605s"
|
||
);
|
||
}
|
||
|
||
/// Regression for the hardcoded-`track 0` epoch driver: on an M2TS/PMT
|
||
/// title the PMT may list an AUDIO ES before the VIDEO ES, so `streams[0]`
|
||
/// is audio and the video is at index 1. The epoch driver must follow the
|
||
/// PRIMARY VIDEO track (first video index), not the literal 0. If audio
|
||
/// (index 0) drove epochs, its sparse/lagging PTS would ratchet the frontier
|
||
/// and false-trigger boundary resets, inflating the timeline. This drives
|
||
/// the muxer with audio=track0 / video=track1 and asserts cluster timestamps
|
||
/// stay monotonic and the timeline does NOT ratchet past the real span.
|
||
#[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(1_000_000),
|
||
"no ratchet: max cluster ts {max} ticks must stay near 605s"
|
||
);
|
||
}
|
||
|
||
#[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, after ID + size + track_vint + 2-byte timestamp,
|
||
// the next byte is flags. Keyframe flag = 0x80, non-keyframe = 0x00.
|
||
// Find the flags byte in each region: it's the byte after the 2-byte timestamp.
|
||
// SimpleBlock ID is 0xA3. Find it and walk past ID + size + vint + ts.
|
||
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 ID is 0x88. When is_default is true, FlagDefault is NOT written
|
||
// (MKV default is 1). When is_default is false, FlagDefault=0 IS written.
|
||
// So we should find at least one FlagDefault element (for the non-default track).
|
||
let flag_default_id = ebml::FLAG_DEFAULT.to_be_bytes();
|
||
let _needle = &[flag_default_id[3]]; // 0x88 is a 1-byte ID
|
||
let count = data.windows(1).filter(|w| w[0] == 0x88).count();
|
||
// 0x88 appears as FlagDefault + as TrackType (also 0x83... no, 0x83 != 0x88)
|
||
// FlagDefault (0x88) should appear for the non-default track
|
||
assert!(
|
||
count >= 1,
|
||
"FlagDefault should be written for non-default tracks"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mkv_forced_flag_on_forced_subtitle() {
|
||
use crate::disc::SubtitleStream;
|
||
let video = make_video_track();
|
||
let forced_sub = MkvTrack::subtitle(&SubtitleStream {
|
||
pid: 0x1200,
|
||
codec: Codec::Pgs,
|
||
language: "eng".into(),
|
||
forced: true,
|
||
qualifier: crate::disc::LabelQualifier::Forced,
|
||
codec_data: None,
|
||
});
|
||
assert!(forced_sub.is_forced);
|
||
|
||
let buf = Cursor::new(Vec::new());
|
||
let tracks = [video, forced_sub];
|
||
let muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
|
||
let data = muxer.writer.into_inner();
|
||
|
||
// FlagForced ID: 0x55AA (2-byte ID)
|
||
assert!(
|
||
find_id(&data, ebml::FLAG_FORCED).is_some(),
|
||
"FlagForced element should be present for forced subtitle track"
|
||
);
|
||
}
|
||
|
||
#[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();
|
||
|
||
// FlagForced should NOT be written for non-forced tracks
|
||
assert!(
|
||
find_id(&data, ebml::FLAG_FORCED).is_none(),
|
||
"FlagForced element should not be present for non-forced subtitle"
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 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. Returns
|
||
/// `Vec<(id, body_start_abs, body_size)>` (absolute offsets into `data`).
|
||
/// `master_body` is an absolute-offset slice range `[start, start+size)`.
|
||
/// Unlike `find_id`'s flat byte-scan, this respects EBML nesting: a child
|
||
/// id buried inside a deeper master is NOT reported at this level.
|
||
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)
|
||
}
|
||
|
||
/// 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();
|
||
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() {
|
||
// The COMMON case: a keyframe-driven cluster (opened because a video
|
||
// keyframe crossed the cluster-duration boundary) must begin with the
|
||
// video keyframe. With a 1 s GOP and 3 s clusters, keyframe spacing keeps
|
||
// every keyframe-driven cluster well within the i16 block span, so no
|
||
// forced i16-split clusters appear here and EVERY cluster is
|
||
// keyframe-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 — whether
|
||
// opened by a video keyframe OR forced by the i16 block-relative split —
|
||
// must carry a Cue, so cluster count always 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. Unlike UHD/HEVC — which emits
|
||
// SimpleBlocks (`duration_ns = None`) — DVD video is VFR: every coded
|
||
// picture carries a per-frame `duration_ns = Some(..)`, so it is written
|
||
// as a BlockGroup, NOT a SimpleBlock. The Cues index must still get one
|
||
// cue per cluster on this path exactly like the SimpleBlock path; a DVD
|
||
// MKV with thousands of clusters and ZERO cues lets players chapter-seek
|
||
// but never scrub. The pre-existing cue tests all feed `None` (SimpleBlock
|
||
// only), leaving this BlockGroup path unguarded — this test covers it.
|
||
//
|
||
// Drives the REAL `Mpeg2Parser` end-to-end (decode-order frames,
|
||
// non-monotonic B-frame display PTS, telecine field durations) into the
|
||
// muxer, so it exercises the genuine DVD frame shape, not a hand-built one.
|
||
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 this output is wholly
|
||
// the BlockGroup path — confirmed 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
|
||
);
|
||
}
|
||
}
|
||
|
||
#[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() {
|
||
// SOTL SUB-TASK 2 regression (opening-GOP PTS handling). A DVD title
|
||
// opens on an I-frame the disc stamps at its REAL timeline PTS (here
|
||
// ~10 s, a large non-zero value — NOT 0). The muxer must anchor `base` on
|
||
// that first kept keyframe so the first cluster's timestamp is 0 (the
|
||
// `.max(0)` floor must NOT corrupt it into a huge value or wrap), and the
|
||
// following frame must land exactly one frame interval (40 ms = 400 ticks
|
||
// at the 0.1 ms scale) later — proving the opening pictures keep their
|
||
// relative timeline and aren't garbled.
|
||
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: a source with no declared duration muxes exactly
|
||
// one keyframe at tick 0 with no per-frame duration, so max_block_ticks
|
||
// stays 0. The reserved DURATION placeholder must be VOIDED (element
|
||
// omitted) rather than left as a bogus 0.0 that players read as a
|
||
// zero-length file.
|
||
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()];
|
||
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 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()];
|
||
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_ms, block_relative_ts_i16, absolute_ms) for
|
||
/// all SimpleBlocks across all clusters, so a test can assert that the
|
||
/// reconstructed absolute timestamp (cluster_ts + relative_ts) is correct
|
||
/// and that 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 the `(pts_ms - cluster_ts_ms) as i16` truncation:
|
||
// a single video keyframe at t=0 opens one cluster, then a long
|
||
// audio-only stretch (no further video keyframe) drifts well past
|
||
// i16::MAX ms (~32.767 s). Without the overflow guard the audio
|
||
// blocks past 32.767 s would write a wrapped (negative) relative
|
||
// timestamp into the SimpleBlock. With the guard a fresh cluster is
|
||
// forced so every relative_ts stays in range and reconstructs to the
|
||
// true absolute timestamp.
|
||
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: each i16-forced split cluster is NOT
|
||
// keyframe-aligned (the only video keyframe is at t=0), yet it MUST carry
|
||
// a Cue — otherwise a player seeking into the long audio stretch lands in
|
||
// a multi-second seek-index hole. Every cluster must have a matching cue.
|
||
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}"
|
||
);
|
||
}
|
||
|
||
#[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();
|
||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||
}
|
||
|
||
#[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!(err.kind(), io::ErrorKind::InvalidData);
|
||
}
|
||
|
||
#[test]
|
||
fn backjumped_audio_handled_by_i16_split_no_wrap() {
|
||
// A NON-VIDEO (audio) frame whose PTS back-jumps far below the open
|
||
// cluster does NOT drive an epoch (only video does — that is the rc3
|
||
// fix), so it is not rebased forward. Instead the i16 block-relative
|
||
// guard catches the out-of-range negative offset and forces a fresh,
|
||
// Cue-carrying cluster floored at t=0 — no negative i16 relative, no
|
||
// wrapped `as u64` cluster timestamp. Build: video kf at 0, video kf at
|
||
// 40s, then audio at raw t=0 (a 40s back-jump).
|
||
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 relative timestamp stays within the i16 range (no silent
|
||
// wrap from the 40s back-jump).
|
||
for (cluster_ts, rel, abs) in all_block_timestamps(&data) {
|
||
assert!(
|
||
(MIN_BLOCK_REL..=MAX_BLOCK_REL).contains(&(rel as i64)),
|
||
"block rel {rel} wrapped i16 (cluster_ts={cluster_ts}, abs={abs})"
|
||
);
|
||
}
|
||
// 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() {
|
||
// Stream order: video keyframe at 5s (anchors base=5000ms, opens cluster
|
||
// at ts 0), then an audio frame with raw PTS 4s — earlier than base.
|
||
// raw_ms - base = -1000ms (negative). It must be floored 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_and_two_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]);
|
||
}
|
||
|
||
// ============================================================
|
||
// SimpleBlock byte layout (Matroska §6.2.3): the element's declared
|
||
// size must equal track_vint_len + 2 (rel ts) + 1 (flags) + data, and
|
||
// the rel-ts is a signed 16-bit big-endian field. A wrong size desyncs
|
||
// every following element; a wrong ts 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"
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// BlockGroup (Matroska §6.2.4): a Block inside a BlockGroup carries
|
||
// BlockDuration, and the Block's keyframe flag bit (0x80) MUST be 0
|
||
// (keyframe-ness is signalled by absence of ReferenceBlock). PGS
|
||
// subtitle frames take this path.
|
||
// ============================================================
|
||
|
||
fn first_block_group(data: &[u8]) -> (Vec<u8>, u64, u8) {
|
||
// Returns (inner BLOCK payload bytes after vint+ts+flags, block_duration_ms, flags).
|
||
let clusters = find_clusters(data);
|
||
for (body_start, body_size, _ts) in clusters {
|
||
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 (id, size, _) = ebml::read_element_header(&mut cursor).unwrap();
|
||
if id == ebml::BLOCK_GROUP {
|
||
let bg_start = cursor.position() as usize;
|
||
let bg = &body[bg_start..bg_start + size as usize];
|
||
// Parse the BlockGroup children.
|
||
let mut bc = Cursor::new(bg);
|
||
let mut data_after = Vec::new();
|
||
let mut dur = 0u64;
|
||
let mut flags = 0xFFu8;
|
||
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 };
|
||
flags = blk[vl + 2];
|
||
data_after = blk[vl + 3..].to_vec();
|
||
} else if cid == ebml::BLOCK_DURATION {
|
||
dur = ebml::read_uint_val(&mut bc, cs as usize).unwrap();
|
||
continue;
|
||
}
|
||
bc.seek(io::SeekFrom::Current(cs as i64)).unwrap();
|
||
}
|
||
return (data_after, dur, flags);
|
||
}
|
||
cursor.seek(io::SeekFrom::Current(size as i64)).unwrap();
|
||
}
|
||
}
|
||
panic!("no BlockGroup found");
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 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. A keyframe exactly at the boundary opens a new
|
||
// cluster; one just under stays in the current cluster.
|
||
// ============================================================
|
||
|
||
#[test]
|
||
fn keyframe_at_cluster_boundary_opens_new_cluster() {
|
||
let tracks = [make_video_track()];
|
||
// Keyframe at exactly 3000 ms (>= the 3 s cluster window) → new cluster.
|
||
let data = mux_with_durations(
|
||
&tracks,
|
||
&[
|
||
(0, 0, true, vec![0xAA], None),
|
||
(0, 3_000_000_000, true, vec![0xBB], None),
|
||
],
|
||
);
|
||
assert_eq!(
|
||
find_clusters(&data).len(),
|
||
2,
|
||
"keyframe at the 3s 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 encoding (Matroska §7.1): the muxer writes fixed-width
|
||
// entries — SeekID as a 4-byte binary element (size 0x84) and
|
||
// SeekPosition as an 8-byte uint (size 0x88) so they can be
|
||
// back-patched in place. Verify the declared SeekID matches the target
|
||
// element ID bytes.
|
||
// ============================================================
|
||
|
||
// ============================================================
|
||
// dolby_vision_config (dvcC / DOVIDecoderConfigurationRecord) bit
|
||
// packing. Byte 2: profile(7 bits) << 1 | level high bit. Byte 3:
|
||
// level low 5 bits << 3 | rpu | el | bl. Byte 4: bl_compat_id << 4.
|
||
// ============================================================
|
||
|
||
#[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.
|
||
// This is 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 (scaled floats) + MaxCLL/MaxFALL (uints) — and OMITTED
|
||
/// entirely for an SDR track. 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 with FieldOrder=UNDETERMINED — never a scan-time
|
||
// guess. The mux stream sets the MEASURED value from the first coded
|
||
// picture before the header is written (mkvstream::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() {
|
||
// SOTL SUB-TASK 1 regression. The Windows-fps fix: a 576i25 track must
|
||
// carry the FULL-FRAME DefaultDuration (40 ms → `1/DefaultDuration` = 25
|
||
// fps, the only rate every tool trusts) and must NOT emit
|
||
// DefaultDecodedFieldDuration. rc.5.1 emitted the 20 ms field duration to
|
||
// try to fix Windows; the captured SOTL evidence proved it did the
|
||
// opposite (Explorer 12.5 fps, MediaInfo VFR). MakeMKV's correct rip omits
|
||
// it (Explorer 25 fps, MediaInfo CFR). So: frame duration present = 40 ms,
|
||
// field duration ABSENT, interlace signalling (FlagInterlaced/FieldOrder)
|
||
// retained.
|
||
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
|
||
// MediaInfo (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 the element nesting for the retained (now non-default) writer
|
||
// path: if a caller DOES set field_duration_ns > 0,
|
||
// DefaultDecodedFieldDuration (0x234E7A) must be emitted as a DIRECT child
|
||
// of TrackEntry — NOT nested inside the Video master (which only holds
|
||
// FlagInterlaced / FieldOrder), per the Matroska schema. The production
|
||
// video path passes 0 (so the element is suppressed — see
|
||
// interlaced_576i_omits_default_decoded_field_duration_keeps_full_frame_duration);
|
||
// this test builds a track with a non-zero field duration to exercise the
|
||
// writer guard and pin its (correct) nesting depth.
|
||
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: the dvd.rs tests assert at the stream
|
||
// layer (ColorSpace::Bt470bg); nothing asserted the actual CICP tuple
|
||
// emitted in the MKV. PAL SD must emit matrix/transfer/primaries =
|
||
// (5,5,5) with range=1 (BT.470BG). A swap with NTSC's (6,6,6) goes
|
||
// uncaught by the stream-layer tests alone.
|
||
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.
|
||
// Field order is a bitstream property the IFO/MPLS scan cannot know, so a
|
||
// track built without a measured picture carries FieldOrder=UNDETERMINED
|
||
// and the element is OMITTED — never a hardcoded TFF guess. The MEASURED
|
||
// value is set by the mux stream from the first coded picture (see
|
||
// mkvstream::apply_coding_to_track and its dedicated test).
|
||
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"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn finalize_emits_per_track_bps_tags() {
|
||
// At finalize a Tags master with a per-track BPS SimpleTag is written.
|
||
// BPS = bytes*8/duration_secs. With a 10 s duration and a video frame of
|
||
// 1000 bytes, video BPS = 1000*8/10 = 800.
|
||
let tracks = [make_video_track(), make_audio_track()];
|
||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||
let writer = SharedWriter(shared.clone());
|
||
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
|
||
// Video keyframe 1000 bytes; audio frame 500 bytes.
|
||
muxer
|
||
.write_frame(0, 0, true, &vec![0xABu8; 1000], None, None)
|
||
.unwrap();
|
||
muxer
|
||
.write_frame(1, 0, false, &vec![0xCDu8; 500], 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"
|
||
);
|
||
// The BPS values must appear as TagString text. Video: 800, Audio: 400.
|
||
let text = String::from_utf8_lossy(&data);
|
||
assert!(text.contains("BPS"), "BPS TagName must be present");
|
||
assert!(
|
||
text.contains("800"),
|
||
"video BPS (1000*8/10) must be present"
|
||
);
|
||
assert!(text.contains("400"), "audio BPS (500*8/10) must be present");
|
||
}
|
||
|
||
#[test]
|
||
fn no_bps_tags_when_duration_unknown() {
|
||
// With duration 0 (unknown) the BPS rate can't be computed; no Tags.
|
||
let tracks = [make_video_track()];
|
||
let frames = vec![(0usize, 0i64, true, vec![0xABu8; 1000])];
|
||
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
|
||
let children = segment_children(&data);
|
||
assert!(
|
||
!children.iter().any(|(id, _, _)| *id == ebml::TAGS),
|
||
"no Tags element when duration is unknown"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn ac3_channels_corrected_from_bitstream_acmod() {
|
||
// The audio track header claims 6 channels (IFO 5.1), but the AC-3
|
||
// bitstream's first frame has acmod=2 (2.0 stereo). The Channels element
|
||
// must be rewritten to 2 from the bitstream, not left at the IFO's 6.
|
||
let mut audio = make_audio_track(); // codec A_AC3, channels = 6
|
||
audio.channels = 6;
|
||
let video = make_video_track();
|
||
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
|
||
let writer = SharedWriter(shared.clone());
|
||
let mut muxer = MkvMuxer::new(writer, &[video, audio], None, 0.0, &[]).unwrap();
|
||
// A minimal AC-3 BSI with acmod=2 (2/0 stereo), no LFE → 2 channels.
|
||
// byte5 = bsid 8 (legacy AC-3). byte6: acmod(010) | dsurmod(00) |
|
||
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
|
||
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
|
||
// Open a cluster with a video keyframe first (cluster invariant).
|
||
muxer
|
||
.write_frame(0, 0, true, &[0x01, 0x02], None, 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 base frame carrying a
|
||
// dependent AU emits BlockGroup > BlockAdditions > BlockMore
|
||
// {BlockAddID=2, BlockAdditional=dep}; a following non-keyframe adds a
|
||
// ReferenceBlock so it is 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],
|
||
));
|
||
let mut muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[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();
|
||
let data = muxer.writer.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"
|
||
);
|
||
assert!(
|
||
find_id(&data, ebml::REFERENCE_BLOCK).is_some(),
|
||
"non-keyframe MVC base frame must carry a ReferenceBlock"
|
||
);
|
||
}
|
||
|
||
#[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 / mediainfo recognise the Blu-ray 3D dependent view.
|
||
let mut v = make_video_track();
|
||
v.mvc_params = Some((
|
||
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
|
||
vec![0x68, 0xEE, 0x3C],
|
||
));
|
||
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
|
||
let data = muxer.writer.into_inner();
|
||
assert!(
|
||
find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_some(),
|
||
"MVC track must emit BlockAdditionMapping"
|
||
);
|
||
assert!(
|
||
find_id(&data, ebml::BLOCK_ADD_ID_VALUE).is_some(),
|
||
"MVC mapping must carry BlockAddIDValue"
|
||
);
|
||
assert!(
|
||
data.windows(4).any(|w| w == [0x6D, 0x76, 0x63, 0x43]),
|
||
"mvcC fourcc must be present as the BlockAddIDType value"
|
||
);
|
||
// Without mvc_params, no mapping.
|
||
let muxer = MkvMuxer::new(
|
||
Cursor::new(Vec::new()),
|
||
&[make_video_track()],
|
||
None,
|
||
0.0,
|
||
&[],
|
||
)
|
||
.unwrap();
|
||
let data = muxer.writer.into_inner();
|
||
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
|
||
}
|
||
|
||
// ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) -------------
|
||
//
|
||
// `MkvTrack::video` always builds with `codec_private: None`; the PES mux
|
||
// pipeline fills it in up-front from the DiscTitle (avcC for H.264, hvcC for
|
||
// HEVC, the VFW BITMAPINFOHEADER for VC-1, etc.). Nothing previously asserted
|
||
// that the muxer EMITS the supplied codecPrivate as a well-formed
|
||
// CodecPrivate element. These tests build a real `MkvMuxer` per representative
|
||
// video codec, set the codecPrivate the pipeline would hand over, and read
|
||
// the EMITTED bytes back: the element must be a DIRECT child of TrackEntry
|
||
// (never nested in Video), carry the registered CODEC_ID, and reproduce the
|
||
// exact codecPrivate payload 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. These stand in for avcC / hvcC / VFW-header / MPEG-2 seq
|
||
// header blobs — 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 pin the EMITTED DefaultDuration only for 25 fps (40 ms) and
|
||
// 29.97 fps (33.366 ms). This fills the gap for the remaining film/PAL/NTSC
|
||
// and high-frame-rate rates, asserting the value read back out of the muxer —
|
||
// not just the track field — so a regression in the serializer's uint
|
||
// encoding (or the element being dropped/nested) is caught 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)
|
||
}
|
||
|
||
#[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.
|
||
// 23.976 = 24000/1001 → 41_708_333; 24 → 41_666_666; 25 → 40_000_000;
|
||
// 29.97 = 30000/1001 → 33_366_666; 30 → 33_333_333; 50 → 20_000_000;
|
||
// 59.94 = 60000/1001 → 16_683_333; 60 → 16_666_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"
|
||
);
|
||
}
|
||
}
|
||
}
|