//! 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 crate::disc::{ AudioChannels, AudioStream, Chapter, Codec, ColorSpace, HdrFormat, Resolution, SampleRate, SubtitleStream, VideoStream, }; use std::io::{self, Seek, Write}; /// 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>, 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) // 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>, } /// 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 { 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 } 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 }; let (matrix, transfer, primaries, range) = match v.color_space { ColorSpace::Bt2020 => (9, 16, 9, 1), // bt2020nc, PQ, bt2020, limited ColorSpace::Bt709 => (1, 1, 1, 1), // bt709 ColorSpace::Unknown => (0, 0, 0, 0), }; // Override transfer for non-PQ HDR let transfer = match v.hdr { HdrFormat::Hdr10 | HdrFormat::Hdr10Plus | HdrFormat::DolbyVision => 16, // PQ HdrFormat::Hlg => 18, _ => transfer, }; 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: w, display_height: h, colour_matrix: matrix, colour_transfer: transfer, colour_primaries: primaries, colour_range: range, 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 }, } } /// 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, sample_rate: sr, channels: ch, bit_depth: 0, dv_config: 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, sample_rate: 0.0, channels: 0, bit_depth: 0, dv_config: None, } } } /// Cue point for seeking. struct CuePoint { timestamp_ms: i64, 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 { writer: W, segment_start: u64, cluster_open: bool, cluster_pos: u64, cluster_size_pos: u64, cluster_ts_ms: i64, base_pts_ms: Option, /// Last block timecode (ms, 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 millisecond /// (or tick back 1ms from rounding). last_pts_ms: std::collections::HashMap, /// 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, /// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing). continuity: TimelineContinuity, cues: Vec, 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, info_offset: u64, tracks_offset: u64, chapters_offset: Option, } /// New cluster every 5 seconds. const CLUSTER_DURATION_MS: i64 = 5000; /// Maximum block-relative timestamp expressible in the signed 16-bit /// SimpleBlock/Block field (`i16::MAX` ms). A frame whose offset from the open /// cluster's timestamp falls outside `i16::MIN..=i16::MAX` ms 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. const MAX_BLOCK_REL_MS: i64 = i16::MAX as i64; /// Minimum block-relative timestamp expressible in the signed 16-bit field. const MIN_BLOCK_REL_MS: i64 = i16::MIN as i64; /// A backward PTS step larger than this is treated as a clip-boundary /// discontinuity (a non-seamless BD clip / dual-layer-break where the source /// PES PTS resets), NOT as B-frame reorder. HEVC/H.264 reorder depth tops out /// around 16 frames (<1s at 24 fps); 3s sits comfortably above any legitimate /// reorder window and far below any real clip's duration, so it never /// false-triggers within a clip. const DISCONTINUITY_BACKSTEP_NS: i64 = 3_000_000_000; /// Sub-frame gap inserted after a rebased discontinuity so the first frame of /// the new clip lands strictly after the previous timeline high (1 ms). const DISCONTINUITY_GAP_NS: i64 = 1_000_000; /// Global timeline-continuity corrector. freemkv reads a BD title's clips as /// one concatenated sector stream (clip boundaries / mpls connection_condition /// are not plumbed to the mux), so at a non-seamless boundary the source PES /// PTS jumps backward. Left uncorrected, that produces a sustained band of /// non-monotonic block timestamps (ffmpeg then derives non-monotonic DTS). /// /// A single running `offset_ns` is applied to EVERY track, so the concatenated /// clips form one monotonic timeline AND A/V sync is preserved (all tracks at a /// boundary shift by the same amount). It is global, not per-track: a clip /// boundary resets every stream together by the same delta. /// /// The demuxer interleaves the tracks, so at a boundary the streams do NOT all /// reset on the same frame — a lagging audio/PGS frame from the just-ended /// clip's tail can arrive AFTER the next clip's video has already reset the /// epoch. Such a "straggler" carries an old-epoch raw PTS; adding the new /// offset to it would fling it far past the frontier and ratchet the whole /// timeline away (the regression that broke everything after the first clip /// boundary). It is detected as a forward spike and remapped with the PREVIOUS /// epoch's offset so it lands at its true position near the seam, without /// advancing the frontier or the offset. struct TimelineContinuity { /// Offset (ns) added to raw PTS for the CURRENT epoch. offset_ns: i64, /// Offset (ns) of the immediately previous epoch — used to remap stragglers /// (old-clip frames interleaved across the boundary). prev_offset_ns: i64, /// Highest adjusted PTS (ns) accepted onto the timeline so far — the running /// frontier. `None` until the first frame. Stragglers never advance it. high_ns: Option, } impl TimelineContinuity { fn new() -> Self { Self { offset_ns: 0, prev_offset_ns: 0, high_ns: None, } } /// Map a raw PES PTS (ns) onto the continuous output timeline. /// /// - **Backward jump > `DISCONTINUITY_BACKSTEP_NS`** vs the frontier = /// clip-boundary reset: open a new epoch (save the old offset, bump the /// offset so this frame continues just after the frontier). /// - **Forward spike > `DISCONTINUITY_BACKSTEP_NS` past the frontier** = a /// straggler from the previous clip arriving interleaved after the /// boundary: remap with `prev_offset_ns` so it lands near the seam, and do /// NOT advance the frontier or the offset (this is what prevents the /// ratchet). A legitimate per-track gap (e.g. a subtitle absent for /// minutes) is NOT misread as a straggler: video keeps the frontier /// current, so the resuming frame lands at the frontier, not beyond it. /// - **Everything else** (normal progression + sub-threshold B-frame /// reorder dips) passes through with the current offset, preserving PTS. fn adjust(&mut self, raw_pts_ns: i64) -> i64 { let Some(high) = self.high_ns else { let adj = raw_pts_ns.saturating_add(self.offset_ns); self.high_ns = Some(adj); return adj; }; let adj = raw_pts_ns.saturating_add(self.offset_ns); if adj < high - DISCONTINUITY_BACKSTEP_NS { // Clip-boundary reset: continue just after the frontier; remember the // previous offset so this clip's lagging tail frames remap correctly. self.prev_offset_ns = self.offset_ns; let bump = (high - adj).saturating_add(DISCONTINUITY_GAP_NS); self.offset_ns = self.offset_ns.saturating_add(bump); let adj2 = raw_pts_ns.saturating_add(self.offset_ns); self.high_ns = Some(high.max(adj2)); adj2 } else if adj > high + DISCONTINUITY_BACKSTEP_NS && { // A straggler from the just-ended clip maps, under the PREVIOUS // epoch's offset, into the TOP of that epoch — at most the frontier, // and no more than one backstep below it (it is the clip's tail, // delivered late by the interleaver). Both bounds matter: // - `<= high` rules out a genuine large forward jump (it maps ABOVE // the frontier under either offset). // - `>= high - BACKSTEP` rules out a genuine NEW-clip frame whose // low raw PTS also maps below the frontier (that frame belongs to // the new epoch and must be rebased forward, not remapped back). let prev_mapped = raw_pts_ns.saturating_add(self.prev_offset_ns); prev_mapped <= high && prev_mapped >= high - DISCONTINUITY_BACKSTEP_NS } { // Straggler: remap to its true seam position with the previous // offset; leave the frontier and offset untouched (prevents the // ratchet). A real forward jump / new-clip frame falls through to the // normal branch and is rebased there. raw_pts_ns.saturating_add(self.prev_offset_ns) } else { // Normal progression / sub-threshold B-frame reorder: keep true PTS. self.high_ns = Some(high.max(adj)); adj } } } /// Force a per-track block timestamp 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 millisecond as the prior frame (or tick back 1ms from rounding), /// which ffmpeg/strict players reject. The nudge is at most a few ms — sub-frame /// and inaudible — and never moves a timestamp earlier. fn monotonic_ts(prev: Option, pts_ms: i64) -> i64 { match prev { Some(p) => pts_ms.max(p.saturating_add(1)), None => pts_ms, } } /// 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, pts_ms: i64) -> i64 { if is_video { pts_ms } else { monotonic_ts(prev, pts_ms) } } /// 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 MkvMuxer { /// 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 { // 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 = Vec::new(); let seekhead_pos = ebml::start_master(&mut writer, ebml::SEEK_HEAD)?; let mut targets: Vec = 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(); for target_id in &targets { 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, 1_000_000)?; // 1ms precision if duration_secs > 0.0 { ebml::write_float(&mut writer, ebml::DURATION, duration_secs * 1000.0)?; // in ms } // Stamp the freemkv version so any muxed file is traceable to the build // that produced it (MediaInfo "Writing application"/"library"). const FREEMKV_MUX_APP: &str = concat!("freemkv ", env!("CARGO_PKG_VERSION")); ebml::write_string(&mut writer, ebml::MUXING_APP, FREEMKV_MUX_APP)?; ebml::write_string(&mut writer, ebml::WRITING_APP, FREEMKV_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)?; for (i, track) in tracks.iter().enumerate() { let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?; ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?; ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x100_0000)?; ebml::write_uint(&mut writer, ebml::TRACK_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, )?; } // 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)?; 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) if track.colour_matrix > 0 || track.colour_transfer > 0 { 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)?; ebml::end_master(&mut writer, col_pos)?; } ebml::end_master(&mut writer, vid_pos)?; } // 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, 0x6476_6343)?; 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 { ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?; } 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 = 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_ms: 0, base_pts_ms: None, last_pts_ms: std::collections::HashMap::new(), track_is_video: tracks .iter() .map(|t| t.track_type == ebml::TRACK_TYPE_VIDEO) .collect(), continuity: TimelineContinuity::new(), cues: Vec::new(), frame_count: 0, dropped_pre_cluster: 0, seek_fixups, info_offset, tracks_offset, chapters_offset, }) } /// 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`. pub fn write_frame( &mut self, track_idx: usize, pts_ns: i64, keyframe: bool, data: &[u8], duration_ns: Option, ) -> io::Result<()> { // Map the raw PES PTS onto the continuous output timeline FIRST, before // any base/cluster math: freemkv concatenates a title's BD clips as one // sector stream, so a non-seamless clip / layer-break boundary arrives // here as a large backward PTS jump. Rebasing it (a global offset across // all tracks, A/V-sync-preserving) keeps the boundary from becoming a // band of non-monotonic block timestamps. No-op for single-clip titles. let pts_ns = self.continuity.adjust(pts_ns); let raw_ms = pts_ns / 1_000_000; // Cluster boundaries normally coincide with a video keyframe so every // Cues entry resolves to a seekable IDR at the cluster start. let is_video_key = keyframe && track_idx == 0; // 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_ms { 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_ms = Some(raw_ms); raw_ms } }; // 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_ms = (raw_ms - base).max(0); // Strictly-monotonic block timestamps — AUDIO/SUBTITLE ONLY. Some audio // PES PTS truncate to the same millisecond as the previous frame (or // tick back 1ms); nudge those to prev+1ms (sub-frame, inaudible). // // VIDEO (track 0) 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 to prev+1ms, 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 is_video = self.track_is_video.get(track_idx).copied().unwrap_or(false); let pts_ms = block_ts(is_video, self.last_pts_ms.get(&track_idx).copied(), pts_ms); let needs_new_cluster = !self.cluster_open || (is_video_key && (pts_ms - self.cluster_ts_ms) >= CLUSTER_DURATION_MS); 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_ms)?; self.cues.push(CuePoint { timestamp_ms: pts_ms, track: track_idx + 1, cluster_pos: self.cluster_pos - self.segment_start, }); } else { let rel = pts_ms - self.cluster_ts_ms; if !(MIN_BLOCK_REL_MS..=MAX_BLOCK_REL_MS).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 ms (~±32.767 s) 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, e.g. // a stream discontinuity) can drift past the i16 range. Force a // fresh cluster here even without a keyframe to keep the cast in // range. pts_ms is already floored at 0 above, so the new // cluster timestamp never wraps on the `as u64` write in // start_cluster. This cluster is not keyframe-aligned so it gets // no Cues entry (Cues stay IDR-only for seekability). self.start_cluster(pts_ms)?; } } // Committed to writing this frame — record its (monotonic) timestamp so // the next block on this track is forced strictly later. self.last_pts_ms.insert(track_idx, pts_ms); let relative_ts = (pts_ms - self.cluster_ts_ms) as i16; match duration_ns { Some(dur_ns) => { let duration_ms = (dur_ns / 1_000_000).max(1); self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ms)?; } None => { self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?; } } self.frame_count += 1; 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()); } // Close final cluster self.end_cluster()?; // Write Cues let cues_start = self.writer.stream_position()?; let cues_offset = cues_start - self.segment_start; 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_ms 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)?; } // Back-patch SeekHead SeekPosition values now that all element offsets are known. for fixup in &self.seek_fixups { 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())?; } self.writer.seek(std::io::SeekFrom::End(0))?; self.writer.flush()?; Ok(()) } fn start_cluster(&mut self, ts_ms: 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_ms as u64)?; self.cluster_ts_ms = ts_ms; 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_ms: 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_ms)?; 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; /// Helper: search for a 4-byte big-endian EBML ID in a byte slice. fn find_id(data: &[u8], id: u32) -> Option { 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, sample_rate: 0.0, channels: 0, bit_depth: 0, dv_config: 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, sample_rate: 48000.0, channels: 6, bit_depth: 0, dv_config: 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) .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) .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()" ); } #[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 = 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 = 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); } // ── Clip-boundary timeline-continuity (PTS discontinuity rebasing) ── const S: i64 = 1_000_000_000; // 1 second in ns /// Characterization of the BUG: a BD title's two clips concatenated with a /// PTS reset at the boundary. WITHOUT correction the raw timeline goes /// hard backward at clip 2 (what produced the non-monotonic-DTS band on /// Dune / Top Gun). WITH `TimelineContinuity` the output is monotonic and /// continuous across the boundary. #[test] fn continuity_rebases_clip_boundary_reset() { // Two interleaved tracks (video t0 + audio t1), clip1 rising to 10s, // then clip2 RESETS near 0 and rises again — the non-seamless case. let clip1: Vec = (0..=10).map(|i| i * S).collect(); // 0..10s let clip2: Vec = (0..=10).map(|i| i * S).collect(); // resets to 0..10s let raw: Vec = clip1.iter().chain(clip2.iter()).copied().collect(); // Uncorrected (the bug): the sequence is NOT monotonic — clip2's first // frame (0) is 10s below clip1's last (10s). assert!( raw.windows(2).any(|w| w[1] < w[0]), "precondition: raw clip-reset sequence is non-monotonic" ); // Corrected: strictly non-decreasing, and clip2 continues AFTER clip1. let mut tc = TimelineContinuity::new(); let out: Vec = raw.iter().map(|&p| tc.adjust(p)).collect(); assert!( out.windows(2).all(|w| w[1] >= w[0]), "corrected timeline must be monotonic non-decreasing, got {out:?}" ); // Clip2's first frame lands just after clip1's last (10s) + the gap. assert_eq!(out[11], 10 * S + DISCONTINUITY_GAP_NS); // Clip2's last frame is offset by the whole of clip1, not back near 0. assert!(out[21] > 19 * S); } /// Regression guard: NORMAL B-frame reorder (a small backward dip, well /// under the discontinuity threshold) must pass through UNCHANGED — the /// corrector must not rebase legitimate reorder (that would re-break the /// video-PTS exemption). #[test] fn continuity_preserves_bframe_reorder() { let mut tc = TimelineContinuity::new(); // I, P(+3 frames), B, B, B — presentation PTS dips backward by ~2 // frames (~83ms), far under the 3s threshold. let raw = [0i64, 125_000_000, 42_000_000, 83_000_000, 250_000_000]; let out: Vec = raw.iter().map(|&p| tc.adjust(p)).collect(); assert_eq!(out, raw, "B-frame reorder must pass through unchanged"); assert_eq!(tc.offset_ns, 0, "no rebase for sub-threshold reorder"); } /// A legitimate FORWARD gap (a real timing gap within a clip, under the /// backstep window) must be PRESERVED, not clamped — only backward /// clip-boundary jumps are rebased and only an old-epoch straggler (a /// forward spike FAR past the frontier, right after a boundary) is remapped. #[test] fn continuity_preserves_forward_gap() { let mut tc = TimelineContinuity::new(); let raw = [0i64, S, 2 * S + 500_000_000, 4 * S]; // a 1.5s gap mid-stream let out: Vec = raw.iter().map(|&p| tc.adjust(p)).collect(); assert_eq!(out, raw, "forward gap preserved verbatim"); assert_eq!(tc.offset_ns, 0, "no rebase on forward progression"); } /// Regression for the ratchet bug (the one the first fix introduced, which /// broke everything after the first clip boundary): the demuxer interleaves /// tracks, so a lagging audio frame from clip 1's TAIL arrives AFTER clip 2's /// video has reset the epoch. The old global-high logic added the new offset /// to that straggler, flung it into the future, inflated the frontier, and /// re-triggered the rebase on every real clip-2 frame → offset ran away. /// /// Correct behaviour: the straggler is remapped to its true seam position /// (it is NOT thrown forward), the frontier and offset do NOT ratchet, and /// clip 2 continues monotonically just after clip 1. #[test] fn continuity_straggler_does_not_ratchet_the_timeline() { let mut tc = TimelineContinuity::new(); // clip1 rises to 10s (frontier 10s, offset 0). for i in 0..=10 { tc.adjust(i * S); } let offset_before = tc.offset_ns; let frontier_before = tc.high_ns.unwrap(); assert_eq!(offset_before, 0); assert_eq!(frontier_before, 10 * S); // clip2's first VIDEO frame resets to 0 → clip-boundary rebase. let c2_first = tc.adjust(0); assert_eq!( c2_first, 10 * S + DISCONTINUITY_GAP_NS, "clip2 continues after clip1" ); let offset_after_boundary = tc.offset_ns; // Now a STRAGGLER: clip1's tail audio (raw ~9.5s) arrives interleaved. let straggler = tc.adjust(9 * S + 500_000_000); // It must land near the seam (clip1 tail), NOT ~19.5s in the future. assert!( straggler <= 10 * S, "straggler remapped to its true seam position, got {straggler}" ); // And it must NOT have moved the offset or the frontier. assert_eq!( tc.offset_ns, offset_after_boundary, "straggler must not ratchet the offset" ); assert_eq!( tc.high_ns.unwrap(), c2_first, "straggler must not inflate the frontier" ); // clip2 keeps rising from ~0; every frame stays just past the seam — no // runaway. After 10 more seconds of clip2 the timeline is ~20s, not 30s+. let mut last = c2_first; for i in 1..=10 { let a = tc.adjust(i * S); assert!( a >= last, "clip2 monotonic after straggler, got {a} < {last}" ); last = a; } assert!( last < 21 * S, "no ratchet: clip2 end near 20s (clip1+clip2), got {last}" ); } /// Regression for the original Top Gun band (`-58864 >= -820000`-scale): a /// LARGE, real-magnitude clip-boundary back-jump (clip 1 ≈ 13 min, clip 2 /// resets to 0) must be rebased to one continuous monotonic timeline — not /// left to produce the sustained non-monotonic-DTS band the auditor flagged. #[test] fn continuity_large_clip_boundary_backjump_rebased() { let mut tc = TimelineContinuity::new(); // Clip 1: 0 .. 780s (13 min) at 1s steps. let clip1: Vec = (0..=780).map(|i| i * S).collect(); // Clip 2: resets to 0 .. 120s — the ~ -780s discontinuity. let clip2: Vec = (0..=120).map(|i| i * S).collect(); let mut last = i64::MIN; let mut max = i64::MIN; for &p in clip1.iter().chain(clip2.iter()) { let a = tc.adjust(p); assert!( a >= last, "rebased timeline must be monotonic, got {a} < {last}" ); last = a; max = max.max(a); } // Offset ≈ the whole of clip 1 (one boundary, no ratchet). assert_eq!(tc.offset_ns, 780 * S + DISCONTINUITY_GAP_NS); // Timeline spans clip1+clip2 (~900s), proving clip 2 is reachable past // the boundary — not capped at it, and not ratcheted far beyond. assert!( (900 * S..901 * S).contains(&max), "timeline must span ~900s (clip1+clip2), got {max}" ); } /// 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)> = 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)"); let clusters = find_clusters(&data); let ts: Vec = clusters.iter().map(|&(_, _, t)| t).collect(); assert!(!ts.is_empty(), "expected clusters"); // Cluster timestamps must be monotonic non-decreasing (no back-dated // cluster from the straggler, 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(); // Timeline reaches past the boundary (clip 2 present): ≥ ~600s. assert!( max >= 600_000, "timeline must span past the boundary, got {max}ms" ); // And does NOT ratchet far beyond clip1+clip2 (~605s): well under 2× clip1. assert!( max < 1_000_000, "no ratchet: max cluster ts {max}ms 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) .unwrap(); muxer .write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None) .unwrap(); muxer .write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None) .unwrap(); muxer .write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], 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).unwrap(); let pos_after_kf = muxer.writer.position(); muxer .write_frame(0, 1_000_000, false, &[0xBB], 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>>>); impl Write for SharedWriter { fn write(&mut self, buf: &[u8]) -> io::Result { 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 { 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)> { 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)> = 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)], ) -> (Vec, 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).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 } /// 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 cluster_starts_only_on_video_keyframe() { 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() { 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(), "cluster count {} != cue count {}", clusters.len(), cues.len() ); // For 30s @ 5s min cluster duration with 1s GOP, expect 6 clusters / 6 cues. assert_eq!( clusters.len(), 6, "expected 6 clusters for 30s @ 5s cluster duration" ); } #[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 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 ); } #[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)> = 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_MS, "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; its reconstructed absolute // timestamp must equal that, proving no truncation occurred. let max_abs = blocks.iter().map(|(_, _, abs)| *abs).max().unwrap(); assert_eq!(max_abs, 60_000, "last block must reconstruct to 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() ); } #[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).unwrap(); muxer .write_frame(0, 10_000_000, false, &[0xBB; 8], None) .unwrap(); muxer .write_frame(1, 20_000_000, true, &[0xCC; 8], 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_rebased_by_continuity_no_i16_wrap() { // An audio frame whose PTS back-jumps far below the open cluster (a // clip-boundary discontinuity) is now REBASED by TimelineContinuity // before the cluster math, so it never produces a negative i16 block // relative. Build: video kf at 0, video kf at 40s, then audio at t=0 // (a 40s back-jump > the 3s discontinuity threshold). Continuity shifts // the audio to ~40s, keeping the timeline monotonic — it lands in the // 40s cluster rather than forcing a third, back-dated cluster. 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); // Two clusters: t=0 (video kf) and t=40000 (video kf). The back-jumped // audio is rebased onto the timeline (~40s) and joins the 40s cluster — // no negative i16 relative, no forced back-dated third cluster. assert_eq!( clusters.len(), 2, "continuity rebases the back-jump (no forced 3rd cluster), got {} clusters", clusters.len() ); // Cluster timestamps stay non-negative (the `as u64` write is safe) and // monotonic non-decreasing — continuity guaranteed a forward timeline. let ts: Vec = clusters.iter().map(|(_, _, t)| *t).collect(); assert!( ts.windows(2).all(|w| w[1] >= w[0]), "cluster ts monotonic: {ts:?}" ); for t in &ts { assert!(*t <= i64::MAX as u64, "cluster ts must not have wrapped"); } } #[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).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) { 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, Option); /// 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 { 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).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 5s // cluster window) must encode rel ts 1000 = 0x03E8 big-endian. 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 < 5000ms boundary). let clusters = find_clusters(&data); assert_eq!(clusters.len(), 1, "1s < 5s cluster window → one cluster"); let blocks = all_block_timestamps(&data); // Two blocks: rel 0 and rel 1000. let rels: Vec = blocks.iter().map(|(_, r, _)| *r).collect(); assert!(rels.contains(&1000), "second block rel ts must be 1000ms"); } // ============================================================ // 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, 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_ms, flags) = first_block_group(&data); assert_eq!(block_data, vec![0xCC, 0xDD]); assert_eq!(dur_ms, 40, "BlockDuration must be 40 ms (40_000_000 ns)"); 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_ms() { // A sub-millisecond duration (e.g. 500_000 ns = 0.5 ms) must floor to 1 // ms, 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(500_000)), ], ); let (_, dur_ms, _) = first_block_group(&data); assert_eq!(dur_ms, 1, "sub-ms duration must floor to 1 ms, not 0"); } // ============================================================ // Cluster boundary (CLUSTER_DURATION_MS = 5000): a new cluster opens // on a video keyframe once >= 5000 ms have 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_5s_boundary_opens_new_cluster() { let tracks = [make_video_track()]; // Keyframe at exactly 5000 ms (>= CLUSTER_DURATION_MS) → new cluster. let data = mux_with_durations( &tracks, &[ (0, 0, true, vec![0xAA], None), (0, 5_000_000_000, true, vec![0xBB], None), ], ); assert_eq!( find_clusters(&data).len(), 2, "keyframe at the 5s boundary must open a second cluster" ); } #[test] fn keyframe_just_under_5s_stays_in_cluster() { let tracks = [make_video_track()]; // Keyframe at 4999 ms (< 5000) → same cluster. let data = mux_with_durations( &tracks, &[ (0, 0, true, vec![0xAA], None), (0, 4_999_000_000, true, vec![0xBB], None), ], ); assert_eq!( find_clusters(&data).len(), 1, "keyframe under the 5s 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" ); } #[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()); } }