diff --git a/Cargo.lock b/Cargo.lock index c0995e716..963660883 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1493,6 +1493,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-matroska" +version = "0.1.0" +dependencies = [ + "serde_json", +] + [[package]] name = "filmcraft-media" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9d7d9d453..a809ee9e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ filmcraft-geom = { path = "crates/geom" } filmcraft-color = { path = "crates/color" } filmcraft-bitstream = { path = "crates/bitstream" } filmcraft-isobmff = { path = "crates/isobmff" } +filmcraft-matroska = { path = "crates/matroska" } filmcraft-h264 = { path = "crates/h264" } filmcraft-hevc = { path = "crates/hevc" } filmcraft-prores = { path = "crates/prores" } diff --git a/crates/matroska/Cargo.toml b/crates/matroska/Cargo.toml new file mode 100644 index 000000000..c95f34c49 --- /dev/null +++ b/crates/matroska/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "filmcraft-matroska" +description = "Clean-room Matroska (MKV) and WebM demuxer" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/matroska/README.md b/crates/matroska/README.md new file mode 100644 index 000000000..a4805b6f5 --- /dev/null +++ b/crates/matroska/README.md @@ -0,0 +1,115 @@ +# filmcraft-matroska + +A clean-room Matroska (MKV) and WebM demuxer, plus a minimal muxer, written from the public +specifications: RFC 8794 (EBML), RFC 9559 (Matroska), the Matroska codec mappings and the WebM +container guidelines. Layer L0: it depends only on `std`, has no `unsafe`, and builds for +`wasm32-unknown-unknown`. No GPL/LGPL code (FFmpeg, libmatroska, mkvtoolnix) was consulted; +FFmpeg is used only as an external test oracle. + +## Demuxing + +```rust +use filmcraft_matroska::{Demuxer, TrackKind, open}; + +// Sample tables, same shape as filmcraft-isobmff (any ByteSource: &[u8], Vec, Arc<[u8]>, File…) +let file = std::fs::File::open("clip.mkv")?; +let mkv = open(&file)?; +let v = mkv.track_of_kind(TrackKind::Video).unwrap(); +let k = mkv.keyframe_before(v, 2_000_000_000).unwrap(); // sample index +let frame = mkv.read_sample(&file, v, k)?; + +// Streaming packets + seeking (from a ByteSource, any Read + Seek, or a byte slice) +let mut d = Demuxer::from_reader(std::io::BufReader::new(std::fs::File::open("clip.webm")?))?; +let sp = d.seek(v, 2_000_000_000)?; // lands on the keyframe with pts ≤ 2 s +while let Some(p) = d.next_packet()? { // file order, all tracks + // p.track, p.pts (ticks), p.pts_ns, p.duration, p.keyframe, p.data +} +``` + +- **Open** (`open`, `open_with(src, &OpenOptions { index, verify_crc })`): EBML header (DocType + `matroska`/`webm`), the first Segment, and its level-1 elements: SeekHead (followed for elements + after the clusters), Info, Tracks, Cues, Chapters, Tags, Attachments (listed; payload located by + offset/size, never read). With `index: true` (default) every Cluster is scanned — element and block + headers only, never frame payloads — to build per-track `Sample` tables (`offset`, `size`, `pts`, + `duration`, `keyframe`, cluster, block offset, lace index) and keyframe lists. With `index: false` + open stops at the first Cluster. +- **Timestamps.** Matroska ticks are `TimestampScale` ns (usually 1 ms); `Track::timebase` is that + as a reduced fraction of a second. `pts` is in ticks, `pts_ns` in exact nanoseconds. `CodecDelay` + is subtracted (RFC 9559), as FFmpeg does (Opus/Vorbis priming gives negative pts). Laced frames get + `block ts + i × (BlockDuration or DefaultDuration)/n`; if neither is known, later laces repeat the + block timestamp and have `duration == 0` (`Packet::lace` tells which). +- **Keyframes:** SimpleBlock keyframe flag; BlockGroup = keyframe iff it has no ReferenceBlock. +- **Lacing:** Xiph, fixed-size, EBML. **Unknown sizes:** Segment (live/streamed output) and + Clusters end at the next top-level element. **CRC-32:** optional verification of level-1 + elements and clusters; mismatches go to `MkvFile::crc_errors` (not fatal). **Void** skipped. +- **Damage:** undecodable/oversized elements trigger a byte scan for the next plausible level-1 + element (Cluster whose first child is Timestamp, or a sized metadata element); a truncated final + block is dropped. Problems are listed in `MkvFile::warnings`. +- **Seeking** (`Demuxer::seek(track, time_ns)`): uses the sample index when present; otherwise Cues + (refined by scanning the cued cluster for the exact keyframe); otherwise builds the index by + scanning all clusters. `Demuxer::keyframe_index(track)` returns the keyframe list. +- **Tracks:** number/UID/type/flags, `DefaultDuration`, name, language (+BCP 47), CodecID, + CodecPrivate, CodecName, `CodecDelay`, `SeekPreRoll`; `VideoInfo` (pixel/display size, crop, + interlacing, stereo, alpha mode, `pixel_aspect()`), `Colour` (matrix, range, transfer, primaries, + subsampling/siting, MaxCLL/MaxFALL, SMPTE 2086 mastering metadata); `AudioInfo` (rate, output + rate, channels, bit depth); `ContentEncoding`s. +- **Content encodings:** header stripping is undone in every packet / `read_sample`. zlib/bzlib/ + lzo compression and encryption are reported (`Track::frames_readable() == false`); packets are + then returned as stored and `read_sample` fails with `Unsupported`. + +### Codec mapping (`Codec`) + +| CodecID | `Codec` | payload | +|---|---|---| +| `V_MPEG4/ISO/AVC` | `Avc` | avcC (CodecPrivate) | +| `V_MPEGH/ISO/HEVC` | `Hevc` | hvcC | +| `V_VP8`, `V_VP9` | `Vp8`, `Vp9` | VP9 CodecPrivate features | +| `V_AV1` | `Av1` | av1C | +| `V_PRORES` | `ProRes` | FourCC; frames get the 8-byte `size`+`icpf` header re-added | +| `V_MJPEG`, `V_MS/VFW/FOURCC` (MJPG) | `Mjpeg` | | +| `V_MS/VFW/FOURCC` (other) | `VfwFourcc` | BITMAPINFOHEADER | +| `A_AAC`, legacy `A_AAC/MPEG{2,4}/…` | `Aac` | AudioSpecificConfig (synthesised for legacy IDs, incl. SBR) | +| `A_OPUS` | `Opus` | OpusHead | +| `A_VORBIS` | `Vorbis` | three headers split from Xiph-laced CodecPrivate | +| `A_FLAC` | `Flac` | `fLaC` + metadata blocks | +| `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE` | `Pcm` | bit depth from Audio | +| `A_AC3`, `A_EAC3`, `A_MPEG/L3`, `A_MPEG/L2` | `Ac3`, `Eac3`, `Mp3`, `Mp2` | | +| `S_TEXT/UTF8` | `SubRip` | | +| `S_TEXT/WEBVTT`, `D_WEBVTT/*` (WebM) | `WebVtt` | WebM blocks keep `id\nsettings\npayload` | +| `S_TEXT/ASS`, `S_TEXT/SSA` | `Ass` | script header | +| anything else | `Other(id)` | `Track::codec_private` | + +`Codec::name()` matches FFmpeg's `codec_name` for these. + +## Muxing (minimal) + +`MkvWriter::new(w: Write + Seek, Vec, MuxOptions)` → `write_frame(track, pts_ns, +keyframe, data, duration_ns)` → `finish()`. Writes the EBML header, Segment, SeekHead, Info (with +Duration), Tracks, Clusters of SimpleBlocks (new cluster at a video keyframe after +`cluster_duration_ns`), BlockGroups for explicit durations, optional audio lacing (Xiph, EBML, +fixed-size), and Cues (video keyframes, or the first frame of each track per cluster). No +CRC-32, Chapters, Tags, Attachments, or content encodings. + +## Tests + +- Unit: EBML VINTs (known/unknown sizes, IDs, signed lace deltas), typed values, CRC-32, all lacing + modes, codec mapping, a hand-built file (unknown-size Segment/Clusters, header stripping, + BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32 pass/fail, Cue-less + seek), and mux → demux round trips for every lacing mode. +- Oracle (`tests/oracle.rs`, skipped without ffmpeg/ffprobe; fixtures in `target/fixtures/matroska`): + H.264+AAC, VP9+Opus WebM, FLAC, Vorbis, SRT+ASS subtitles, WebVTT WebM, Cues at front, a + streamed (pipe) file without Cues, HEVC with HDR colour, ProRes+PCM, MJPEG+AC-3, AV1. Every packet + is compared with `ffprobe -show_packets` (stream, size, keyframe flag, pts, duration), plus codec + names, time bases, dimensions, audio params and duration; the sample index must equal the packet + stream. Seeks (indexed, Cues-only, and index-on-demand) must land on the latest keyframe ≤ target. + Our muxer's laced output (Xiph/EBML/fixed) is read back by ffprobe and compared the same way. + A damaged file must resync and deliver the packets after the damage; truncated files must not fail. + +## Limitations + +- Only the first Segment is read (no linked/chained segments, no ordered-chapter editions playback). +- zlib/bzlib/lzo compressed and encrypted tracks are detected but their frames are not decoded. +- BlockAdditions (e.g. VP9 alpha, WebVTT settings in Matroska) are skipped; `EncryptedBlock` ignored. +- `TrackTimestampScale` (deprecated) is ignored. +- Lace timestamps without any duration information repeat the block timestamp (FFmpeg derives them + from codec parsers). diff --git a/crates/matroska/src/codec.rs b/crates/matroska/src/codec.rs new file mode 100644 index 000000000..e44f47ea8 --- /dev/null +++ b/crates/matroska/src/codec.rs @@ -0,0 +1,225 @@ +//! Matroska CodecID → codec mapping (Matroska codec specifications, RFC 9559 §5.1.4.1.21 and the +//! codec mappings registry). + +/// Codec of a track, derived from `CodecID` and `CodecPrivate`. +/// +/// Configuration records are passed through as the raw bytes found in `CodecPrivate`, which for +/// the ISO codecs are exactly the ISOBMFF configuration boxes' payloads (avcC, hvcC, av1C, ASC…). +#[derive(Clone, Debug, PartialEq)] +pub enum Codec { + /// `V_MPEG4/ISO/AVC`; payload is an `AVCDecoderConfigurationRecord` (avcC). + Avc { avcc: Vec }, + /// `V_MPEGH/ISO/HEVC`; payload is an `HEVCDecoderConfigurationRecord` (hvcC). + Hevc { hvcc: Vec }, + /// `V_VP8`. + Vp8, + /// `V_VP9`; `CodecPrivate` (optional) holds VP9 codec feature metadata. + Vp9 { private: Vec }, + /// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C). + Av1 { av1c: Vec }, + /// `V_PRORES`; `fourcc` from `CodecPrivate` (e.g. `apcn`), if present. + ProRes { fourcc: Option<[u8; 4]> }, + /// `V_MJPEG`, or `V_MS/VFW/FOURCC` with an `MJPG` compression FourCC. + Mjpeg, + /// `V_MS/VFW/FOURCC` with any other FourCC; `bitmap_info_header` is the whole `CodecPrivate`. + VfwFourcc { fourcc: [u8; 4], bitmap_info_header: Vec }, + /// `A_AAC` (and the legacy `A_AAC/MPEG{2,4}/` IDs); `asc` is the + /// `AudioSpecificConfig` (synthesised for legacy IDs without `CodecPrivate`). + Aac { asc: Vec }, + /// `A_OPUS`; `head` is the `OpusHead` identification header. + Opus { head: Vec }, + /// `A_VORBIS`; the three Vorbis headers (identification, comment, setup), split from the + /// Xiph-laced `CodecPrivate`. + Vorbis { headers: Vec> }, + /// `A_FLAC`; `private` is `fLaC` + metadata blocks (STREAMINFO first). + Flac { private: Vec }, + /// `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE`. Bit depth comes from the Audio element. + Pcm { float: bool, big_endian: bool, bits: u16 }, + /// `A_AC3`. + Ac3, + /// `A_EAC3`. + Eac3, + /// `A_MPEG/L3`. + Mp3, + /// `A_MPEG/L2`. + Mp2, + /// `S_TEXT/UTF8` (SubRip-style plain text). + SubRip, + /// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`. + WebVtt, + /// `S_TEXT/ASS` / `S_TEXT/SSA`; `header` is the script header from `CodecPrivate`. + Ass { header: Vec, ssa: bool }, + /// Any other CodecID (kept verbatim along with `Track::codec_private`). + Other(String), +} + +impl Codec { + /// Short codec name, matching FFmpeg's `codec_name` where one exists (useful for logs/UI). + pub fn name(&self) -> String { + match self { + Codec::Avc { .. } => "h264".into(), + Codec::Hevc { .. } => "hevc".into(), + Codec::Vp8 => "vp8".into(), + Codec::Vp9 { .. } => "vp9".into(), + Codec::Av1 { .. } => "av1".into(), + Codec::ProRes { .. } => "prores".into(), + Codec::Mjpeg => "mjpeg".into(), + Codec::VfwFourcc { fourcc, .. } => String::from_utf8_lossy(fourcc).trim().to_ascii_lowercase(), + Codec::Aac { .. } => "aac".into(), + Codec::Opus { .. } => "opus".into(), + Codec::Vorbis { .. } => "vorbis".into(), + Codec::Flac { .. } => "flac".into(), + Codec::Pcm { float, big_endian, bits } => { + let k = if *float { + 'f' + } else if *bits == 8 { + 'u' + } else { + 's' + }; + if *bits == 8 { format!("pcm_{k}8") } else { format!("pcm_{k}{bits}{}", if *big_endian { "be" } else { "le" }) } + } + Codec::Ac3 => "ac3".into(), + Codec::Eac3 => "eac3".into(), + Codec::Mp3 => "mp3".into(), + Codec::Mp2 => "mp2".into(), + Codec::SubRip => "subrip".into(), + Codec::WebVtt => "webvtt".into(), + Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(), + Codec::Other(id) => id.clone(), + } + } +} + +const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350]; + +/// Build a 2-byte (or 5-byte with SBR extension) AudioSpecificConfig for legacy `A_AAC/...` IDs. +fn synth_asc(object_type: u8, rate: f64, out_rate: Option, channels: u64, sbr: bool) -> Vec { + let rate_index = |r: f64| AAC_RATES.iter().position(|&x| x as f64 == r).unwrap_or(4) as u8; + let ri = rate_index(rate); + let ch = channels.min(7) as u8; + let mut v = vec![(object_type << 3) | (ri >> 1), ((ri & 1) << 7) | (ch << 3)]; + if sbr { + // backward-compatible explicit signalling: sync 0x2b7, SBR object type 5, flag, ext rate index + let ext = rate_index(out_rate.unwrap_or(rate * 2.0)); + v.push(0x56); + v.push(0xE5); + v.push(0x80 | (ext << 3)); + } + v +} + +/// Split a Xiph-laced `CodecPrivate` (Vorbis/Theora headers). +fn split_xiph(p: &[u8]) -> Vec> { + let Some(&n) = p.first() else { return Vec::new() }; + let mut pos = 1; + let mut sizes = Vec::new(); + for _ in 0..n { + let mut s = 0usize; + loop { + let Some(&b) = p.get(pos) else { return Vec::new() }; + pos += 1; + s += b as usize; + if b != 255 { + break; + } + } + sizes.push(s); + } + let mut out = Vec::new(); + for s in sizes { + let Some(h) = p.get(pos..pos + s) else { return Vec::new() }; + out.push(h.to_vec()); + pos += s; + } + out.push(p[pos.min(p.len())..].to_vec()); + out +} + +/// Map a CodecID + CodecPrivate (+ audio params for legacy AAC / PCM) to a [`Codec`]. +pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInfo>) -> Codec { + let p = private.to_vec(); + match id { + "V_MPEG4/ISO/AVC" => Codec::Avc { avcc: p }, + "V_MPEGH/ISO/HEVC" => Codec::Hevc { hvcc: p }, + "V_VP8" => Codec::Vp8, + "V_VP9" => Codec::Vp9 { private: p }, + "V_AV1" => Codec::Av1 { av1c: p }, + "V_PRORES" => Codec::ProRes { fourcc: private.get(..4).and_then(|f| f.try_into().ok()) }, + "V_MJPEG" => Codec::Mjpeg, + "V_MS/VFW/FOURCC" => { + let fourcc: [u8; 4] = private.get(16..20).and_then(|f| f.try_into().ok()).unwrap_or(*b" "); + match &fourcc { + b"MJPG" | b"mjpg" | b"AVRn" | b"AVDJ" => Codec::Mjpeg, + b"H264" | b"h264" | b"avc1" | b"X264" | b"x264" => Codec::Avc { avcc: Vec::new() }, + b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => Codec::ProRes { fourcc: Some(fourcc) }, + _ => Codec::VfwFourcc { fourcc, bitmap_info_header: p }, + } + } + "A_AAC" => Codec::Aac { asc: p }, + "A_OPUS" => Codec::Opus { head: p }, + "A_VORBIS" => Codec::Vorbis { headers: split_xiph(private) }, + "A_FLAC" => Codec::Flac { private: p }, + "A_PCM/INT/LIT" | "A_PCM/INT/BIG" | "A_PCM/FLOAT/IEEE" => { + let bits = audio.and_then(|a| a.bit_depth).unwrap_or(if id == "A_PCM/FLOAT/IEEE" { 32 } else { 16 }) as u16; + Codec::Pcm { float: id == "A_PCM/FLOAT/IEEE", big_endian: id == "A_PCM/INT/BIG", bits } + } + "A_AC3" | "A_AC3/BSID9" | "A_AC3/BSID10" => Codec::Ac3, + "A_EAC3" => Codec::Eac3, + "A_MPEG/L3" => Codec::Mp3, + "A_MPEG/L2" => Codec::Mp2, + "S_TEXT/UTF8" => Codec::SubRip, + "S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt, + "S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false }, + "S_TEXT/SSA" | "S_SSA" => Codec::Ass { header: p, ssa: true }, + _ if id.starts_with("A_AAC/") => { + if !private.is_empty() { + return Codec::Aac { asc: p }; + } + let (rate, out_rate, ch) = audio.map_or((48000.0, None, 2), |a| (a.sampling_frequency, a.output_sampling_frequency, a.channels)); + let object_type = if id.ends_with("/MAIN") { + 1 + } else if id.ends_with("/SSR") { + 3 + } else if id.ends_with("/LTP") { + 4 + } else { + 2 + }; + let sbr = id.ends_with("/SBR"); + Codec::Aac { asc: synth_asc(object_type, rate, out_rate, ch, sbr) } + } + _ => Codec::Other(id.to_string()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mapping() { + assert_eq!(map_codec("V_MPEG4/ISO/AVC", &[1, 2], None), Codec::Avc { avcc: vec![1, 2] }); + assert_eq!(map_codec("V_PRORES", b"apcn", None), Codec::ProRes { fourcc: Some(*b"apcn") }); + let mut bih = vec![0u8; 40]; + bih[16..20].copy_from_slice(b"MJPG"); + assert_eq!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::Mjpeg); + bih[16..20].copy_from_slice(b"FFV1"); + assert!(matches!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::VfwFourcc { fourcc, .. } if &fourcc == b"FFV1")); + let a = crate::AudioInfo { sampling_frequency: 44100.0, channels: 2, bit_depth: Some(24), output_sampling_frequency: None }; + assert_eq!(map_codec("A_PCM/INT/LIT", &[], Some(&a)).name(), "pcm_s24le"); + assert_eq!(map_codec("A_PCM/FLOAT/IEEE", &[], Some(&a)), Codec::Pcm { float: true, big_endian: false, bits: 24 }); + // legacy AAC LC 44.1k stereo → 0x12 0x10 + assert_eq!(map_codec("A_AAC/MPEG4/LC", &[], Some(&a)), Codec::Aac { asc: vec![0x12, 0x10] }); + assert_eq!(map_codec("S_TEXT/UTF8", &[], None).name(), "subrip"); + assert_eq!(map_codec("X_WHATEVER", &[], None), Codec::Other("X_WHATEVER".into())); + } + + #[test] + fn xiph_split() { + let mut p = vec![2u8, 3, 2]; + p.extend_from_slice(&[1, 1, 1, 2, 2, 3, 3, 3, 3]); + let h = split_xiph(&p); + assert_eq!(h, vec![vec![1, 1, 1], vec![2, 2], vec![3, 3, 3, 3]]); + } +} diff --git a/crates/matroska/src/demux.rs b/crates/matroska/src/demux.rs new file mode 100644 index 000000000..808b45b76 --- /dev/null +++ b/crates/matroska/src/demux.rs @@ -0,0 +1,1166 @@ +//! Matroska / WebM demuxer: EBML header, Segment walk, cluster scanning, packet reading, seeking. + +use std::collections::{HashSet, VecDeque}; + +use crate::ebml::{self, Children, Lacing, Size, parse_lacing, read_id, read_size}; +use crate::error::{Error, Result}; +use crate::ids::*; +use crate::meta::*; +use crate::source::{ByteSource, ReadSeekSource}; +use crate::track::{Sample, Track, TrackKind, gcd}; + +const WIN: usize = 64 * 1024; +/// Upper bound for level-1 metadata elements read into memory (Info, Tracks, Cues, Tags…). +const MAX_META: u64 = 256 << 20; + +/// Options for [`open_with`] / [`Demuxer::with_options`]. +#[derive(Clone, Debug)] +pub struct OpenOptions { + /// Scan every cluster at open to build per-track sample tables and keyframe indices + /// (reads only element and block headers, never frame payloads). Default `true`. + /// + /// With `false`, open stops at the first Cluster and uses the SeekHead to find Cues, Tags, + /// Chapters… placed after the clusters; [`Demuxer::seek`] then uses Cues (or builds the index + /// on first use if there are none). + pub index: bool, + /// Verify CRC-32 elements of level-1 elements (and clusters while indexing). Mismatches are + /// recorded in [`MkvFile::crc_errors`]; parsing continues. Default `false`. + pub verify_crc: bool, +} + +impl Default for OpenOptions { + fn default() -> Self { + Self { index: true, verify_crc: false } + } +} + +/// A parsed Matroska / WebM file (first Segment). +#[derive(Clone, Debug)] +pub struct MkvFile { + /// EBML `DocType`: `matroska` or `webm`. + pub doc_type: String, + pub doc_type_version: u64, + pub doc_type_read_version: u64, + /// Absolute offset of the Segment's data (base for SeekHead and Cues positions). + pub segment_data_start: u64, + /// Absolute end of the Segment (file end for unknown-size / live segments). + pub segment_end: u64, + /// Segment size field was "unknown" (live / streamed output). + pub live: bool, + pub info: SegmentInfo, + pub tracks: Vec, + pub seek_head: Vec, + /// Cue points, sorted by time. + pub cues: Vec, + /// Clusters in file order (complete only when [`MkvFile::indexed`]). + pub clusters: Vec, + pub chapters: Vec, + pub tags: Vec, + pub attachments: Vec, + /// Absolute offset of the first Cluster. + pub first_cluster: Option, + /// Sample tables have been built by scanning all clusters. + pub indexed: bool, + /// Number of CRC-32 elements verified (with `verify_crc`). + pub crc_checked: u32, + /// Absolute offsets of elements whose CRC-32 did not match. + pub crc_errors: Vec, + /// Non-fatal problems met while parsing (damage, resyncs, truncation). + pub warnings: Vec, +} + +/// One demuxed frame. +#[derive(Clone, Debug, PartialEq)] +pub struct Packet { + /// Index into [`MkvFile::tracks`]. + pub track: usize, + /// Matroska `TrackNumber`. + pub track_number: u64, + /// Presentation timestamp in timestamp ticks (the track timebase, [`Track::timebase`]). + pub pts: i64, + /// Presentation timestamp in nanoseconds. + pub pts_ns: i64, + /// Duration in ticks (0 if unknown). + pub duration: u64, + /// Duration in nanoseconds (0 if unknown). + pub duration_ns: u64, + pub keyframe: bool, + pub discardable: bool, + pub invisible: bool, + /// `DiscardPadding` (ns) from the enclosing BlockGroup. + pub discard_padding_ns: Option, + /// Absolute offset of the stored frame data. + pub offset: u64, + /// Index of the frame within a laced block (0 when not laced). Laced frames after the first + /// only have exact timestamps when the block or track carries a duration; otherwise they + /// repeat the block timestamp and `duration` is 0. + pub lace: u16, + /// Frame bytes with header stripping undone. Frames of tracks with other content encodings + /// (zlib, encryption) are returned as stored; see [`Track::frames_readable`]. + pub data: Vec, +} + +/// Result of [`Demuxer::seek`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SeekPoint { + pub track: usize, + /// Keyframe timestamp in ticks and ns. + pub pts: i64, + pub pts_ns: i64, + /// Sample index of the keyframe when the file is indexed. + pub sample: Option, + pub cluster_offset: u64, + pub block_offset: u64, +} + +/// A keyframe index entry ([`Demuxer::keyframe_index`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Keyframe { + pub sample: usize, + pub pts: i64, + pub pts_ns: i64, + pub block_offset: u64, +} + +// --------------------------------------------------------------------------------------------- +// I/O with a small read window +// --------------------------------------------------------------------------------------------- + +#[derive(Default, Clone, Debug)] +struct Cache { + buf: Vec, + start: u64, +} + +struct Io<'a, S: ByteSource + ?Sized> { + src: &'a S, + len: u64, + cache: &'a mut Cache, +} + +#[derive(Clone, Copy, Debug)] +struct Hdr { + id: u32, + pos: u64, + size: Size, + hlen: usize, +} + +impl Hdr { + fn data_start(&self) -> u64 { + self.pos + self.hlen as u64 + } + /// End offset, clamped to `limit`; unknown sizes run to `limit`. + fn end(&self, limit: u64) -> u64 { + match self.size { + Size::Known(n) => self.data_start().saturating_add(n).min(limit), + Size::Unknown => limit, + } + } + fn overruns(&self, limit: u64) -> bool { + matches!(self.size, Size::Known(n) if self.data_start().saturating_add(n) > limit) + } +} + +impl<'a, S: ByteSource + ?Sized> Io<'a, S> { + fn new(src: &'a S, cache: &'a mut Cache) -> Self { + let len = src.len(); + Self { src, len, cache } + } + + /// Up to `n` (≤ WIN) bytes at `pos` (fewer at EOF). + fn peek(&mut self, pos: u64, n: usize) -> Result<&[u8]> { + let n = n.min(WIN); + if pos >= self.len { + return Ok(&[]); + } + let want_end = (pos + n as u64).min(self.len); + let c = &*self.cache; + if !(pos >= c.start && want_end <= c.start + c.buf.len() as u64) { + let fill = ((self.len - pos) as usize).min(WIN); + let mut buf = std::mem::take(&mut self.cache.buf); + buf.resize(fill, 0); + self.src.read_at(pos, &mut buf)?; + self.cache.buf = buf; + self.cache.start = pos; + } + let o = (pos - self.cache.start) as usize; + Ok(&self.cache.buf[o..o + (want_end - pos) as usize]) + } + + /// Exactly `n` bytes at `pos`. + fn read_vec(&mut self, pos: u64, n: u64) -> Result> { + if pos.checked_add(n).is_none_or(|e| e > self.len) { + return Err(Error::Io(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "read past end of file"))); + } + if n as usize <= WIN / 2 { + return Ok(self.peek(pos, n as usize)?.to_vec()); + } + let mut v = vec![0u8; n as usize]; + self.src.read_at(pos, &mut v)?; + Ok(v) + } + + /// Element header at `pos` (`None` if undecodable or past `limit`). + fn header(&mut self, pos: u64, limit: u64) -> Result> { + if pos >= limit { + return Ok(None); + } + let b = self.peek(pos, 12)?; + let Some((id, il)) = read_id(b) else { return Ok(None) }; + let Some((size, sl)) = read_size(&b[il..]) else { return Ok(None) }; + let hlen = il + sl; + if pos + hlen as u64 > limit { + return Ok(None); + } + Ok(Some(Hdr { id, pos, size, hlen })) + } + + /// Scan forward from `from` for a plausible level-1 element (Cluster preferred), up to `limit`. + fn resync(&mut self, from: u64, limit: u64) -> Result> { + let mut pos = from; + while pos + 4 <= limit { + let chunk = self.peek(pos, WIN)?.to_vec(); + if chunk.len() < 4 { + return Ok(None); + } + for i in 0..chunk.len() - 3 { + if chunk[i] & 0xF0 != 0x10 { + continue; + } + let id = u32::from_be_bytes([chunk[i], chunk[i + 1], chunk[i + 2], chunk[i + 3]]); + if !is_top_level(id) || id == EBML || id == SEGMENT { + continue; + } + let p = pos + i as u64; + if self.plausible(p, id, limit)? { + return Ok(Some(p)); + } + } + pos += (chunk.len() - 3) as u64; + } + Ok(None) + } + + fn plausible(&mut self, p: u64, id: u32, limit: u64) -> Result { + let Some(h) = self.header(p, limit)? else { return Ok(false) }; + if h.id != id || h.overruns(limit) { + return Ok(false); + } + if h.size == Size::Unknown && id != CLUSTER { + return Ok(false); + } + // first child must decode and fit; clusters must start with Timestamp (after CRC-32/Void) + let mut cp = h.data_start(); + let end = h.end(limit); + for _ in 0..3 { + let Some(c) = self.header(cp, end)? else { return Ok(false) }; + if c.overruns(end) { + return Ok(false); + } + if id != CLUSTER { + return Ok(true); + } + match c.id { + TIMESTAMP => return Ok(true), + CRC32 | VOID => cp = c.end(end), + _ => return Ok(false), + } + } + Ok(false) + } + + /// End of an unknown-size master element: the first child that is a top-level ID or undecodable. + fn unknown_end(&mut self, data_start: u64, limit: u64) -> Result { + let mut pos = data_start; + while pos < limit { + let Some(h) = self.header(pos, limit)? else { return Ok(pos) }; + if is_top_level(h.id) || h.size == Size::Unknown { + return Ok(pos); + } + pos = h.end(limit); + } + Ok(limit) + } +} + +// --------------------------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +struct BlockRef { + track: usize, + block_offset: u64, + /// Timestamp in ticks (cluster + relative). + ts: i64, + /// Absolute (offset, size) of each frame. + frames: Vec<(u64, u32)>, + block_duration: Option, + keyframe: bool, + discardable: bool, + invisible: bool, + discard_padding: Option, +} + +#[derive(Clone, Copy, Debug)] +struct FrameRef { + track: usize, + offset: u64, + size: u32, + pts: i64, + pts_ns: i64, + duration: u64, + duration_ns: u64, + keyframe: bool, + discardable: bool, + invisible: bool, + discard_padding: Option, + block_offset: u64, + lace: u16, +} + +fn track_lookup(tracks: &[Track]) -> impl Fn(u64) -> Option + '_ { + move |n| tracks.iter().position(|t| t.number == n) +} + +/// Parse the Block/SimpleBlock header at `data_start` (`len` bytes). +/// Returns (track index, relative ts, flags, frames) or `None` for unknown tracks / damage. +fn parse_block( + io: &mut Io<'_, S>, + data_start: u64, + len: u64, + lookup: &dyn Fn(u64) -> Option, +) -> Result)>> { + let head = io.peek(data_start, 16.min(len as usize))?; + let Some((tn, tl, _)) = ebml::read_vint(head) else { return Ok(None) }; + if head.len() < tl + 3 { + return Ok(None); + } + let rel = i16::from_be_bytes([head[tl], head[tl + 1]]); + let flags = head[tl + 2]; + let Some(track) = lookup(tn) else { return Ok(None) }; + let hdr = (tl + 3) as u64; + let lacing = Lacing::from_flags(flags); + let frames = if lacing == Lacing::None { + vec![(data_start + hdr, (len - hdr) as u32)] + } else { + let body = io.read_vec(data_start + hdr, len - hdr)?; + match parse_lacing(lacing, &body) { + Ok(f) => f.into_iter().map(|(o, l)| (data_start + hdr + o as u64, l as u32)).collect(), + Err(_) => return Ok(None), + } + }; + Ok(Some((track, rel, flags, frames))) +} + +/// Parse one SimpleBlock or BlockGroup element. +fn parse_block_elem( + io: &mut Io<'_, S>, + h: &Hdr, + end: u64, + cluster_ts: i64, + lookup: &dyn Fn(u64) -> Option, +) -> Result> { + let ds = h.data_start(); + match h.id { + SIMPLE_BLOCK => { + let Some((track, rel, flags, frames)) = parse_block(io, ds, end - ds, lookup)? else { return Ok(None) }; + Ok(Some(BlockRef { + track, + block_offset: h.pos, + ts: cluster_ts + rel as i64, + frames, + block_duration: None, + keyframe: flags & 0x80 != 0, + discardable: flags & 0x01 != 0, + invisible: flags & 0x08 != 0, + discard_padding: None, + })) + } + BLOCK_GROUP => { + let mut pos = ds; + let mut block = None; + let (mut dur, mut has_ref, mut pad) = (None, false, None); + while pos < end { + let Some(c) = io.header(pos, end)? else { break }; + let ce = c.end(end); + match c.id { + BLOCK => block = parse_block(io, c.data_start(), ce - c.data_start(), lookup)?, + BLOCK_DURATION => dur = Some(ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)), + REFERENCE_BLOCK => has_ref = true, + DISCARD_PADDING => pad = Some(ebml::int(io.peek(c.data_start(), (ce - c.data_start()) as usize)?)), + _ => {} + } + pos = ce; + } + let Some((track, rel, flags, frames)) = block else { return Ok(None) }; + Ok(Some(BlockRef { + track, + block_offset: h.pos, + ts: cluster_ts + rel as i64, + frames, + block_duration: dur, + keyframe: !has_ref, + discardable: false, + invisible: flags & 0x08 != 0, + discard_padding: pad, + })) + } + _ => Ok(None), + } +} + +/// Expand a block into frames with per-lace timestamps. +fn expand(b: &BlockRef, t: &Track, scale: u64, skip: u16, out: &mut impl Extend) { + let n = b.frames.len() as u64; + // total block duration in ns + let block_ns: Option = match (b.block_duration, t.default_duration_ns) { + (Some(d), _) => Some(d as u128 * scale as u128), + (None, Some(dd)) => Some(dd as u128 * n as u128), + _ => None, + }; + let scale_i = scale as i128; + // round half away from zero + let to_ticks = |ns: i128| -> i64 { (if ns >= 0 { (ns + scale_i / 2) / scale_i } else { (ns - scale_i / 2) / scale_i }) as i64 }; + // CodecDelay is subtracted from every timestamp (RFC 9559 §5.1.4.1.25); in ticks it is rounded + // once, so tick timestamps stay on the block grid. + let delay_ns = t.codec_delay_ns as i128; + let delay_ticks = to_ticks(delay_ns); + let base_ns = b.ts as i128 * scale_i; + out.extend(b.frames.iter().enumerate().skip(skip as usize).map(|(i, &(offset, size))| { + let (start, end) = match block_ns { + Some(total) => ((total * i as u128 / n as u128) as i128, Some((total * (i as u128 + 1) / n as u128) as i128)), + None => (0, None), + }; + let pts_ns = base_ns + start - delay_ns; + let pts = to_ticks(base_ns + start) - delay_ticks; + let duration = end.map_or(0, |e| (to_ticks(e) - to_ticks(start)).max(0) as u64); + FrameRef { + track: b.track, + offset, + size, + pts, + pts_ns: pts_ns as i64, + duration, + duration_ns: end.map_or(0, |e| (e - start).max(0) as u64), + keyframe: b.keyframe, + discardable: b.discardable, + invisible: b.invisible, + discard_padding: b.discard_padding, + block_offset: b.block_offset, + lace: i as u16, + } + })); +} + +/// Scan a whole cluster (headers only), returning its info and blocks. +fn scan_cluster( + io: &mut Io<'_, S>, + h: &Hdr, + limit: u64, + tracks: &[Track], + warnings: &mut Vec, +) -> Result<(ClusterInfo, Vec)> { + let lookup = track_lookup(tracks); + let ds = h.data_start(); + let known_end = match h.size { + Size::Known(_) => Some(h.end(limit)), + Size::Unknown => None, + }; + let lim = known_end.unwrap_or(limit); + let mut ts: i64 = 0; + let mut blocks = Vec::new(); + let mut pos = ds; + let end = loop { + if pos >= lim { + break lim; + } + let Some(c) = io.header(pos, lim)? else { + warnings.push(format!("damaged cluster at {}: undecodable element at {pos}", h.pos)); + break io.resync(pos + 1, limit)?.unwrap_or(limit); + }; + if known_end.is_none() && is_top_level(c.id) { + break pos; + } + if c.size == Size::Unknown || c.overruns(lim) { + warnings.push(format!("damaged cluster at {}: element 0x{:X} at {pos} overruns", h.pos, c.id)); + if c.overruns(lim) && matches!(c.id, SIMPLE_BLOCK | BLOCK_GROUP) && io.len <= c.data_start() + c.size.known().unwrap_or(0) { + break lim; // truncated file: drop the partial block + } + break io.resync(pos + 1, limit)?.unwrap_or(limit); + } + let ce = c.end(lim); + match c.id { + TIMESTAMP => ts = ebml::uint(io.peek(c.data_start(), (ce - c.data_start()) as usize)?) as i64, + SIMPLE_BLOCK | BLOCK_GROUP => { + if let Some(b) = parse_block_elem(io, &c, ce, ts, &lookup)? { + blocks.push(b); + } + } + _ => {} + } + pos = ce; + }; + Ok((ClusterInfo { offset: h.pos, data_start: ds, end, timestamp: ts.max(0) as u64 }, blocks)) +} + +// --------------------------------------------------------------------------------------------- +// Open +// --------------------------------------------------------------------------------------------- + +/// Open a Matroska/WebM file with default options (full sample index). +pub fn open(src: S) -> Result { + open_with(src, &OpenOptions::default()) +} + +/// Open with explicit options. +pub fn open_with(src: S, opts: &OpenOptions) -> Result { + let mut cache = Cache::default(); + open_impl(&src, opts, &mut cache) +} + +fn open_impl(src: &S, opts: &OpenOptions, cache: &mut Cache) -> Result { + let mut io = Io::new(src, cache); + let len = io.len; + // EBML header (tolerate leading junk in the first 64 KiB) + let start = { + let b = io.peek(0, WIN)?; + let magic = [0x1A, 0x45, 0xDF, 0xA3]; + b.windows(4).position(|w| w == magic).ok_or_else(|| Error::NotMatroska("no EBML header".into()))? as u64 + }; + let eh = io.header(start, len)?.ok_or_else(|| Error::NotMatroska("bad EBML header".into()))?; + let hdr_end = eh.end(len); + let hdr = io.read_vec(eh.data_start(), hdr_end - eh.data_start())?; + let (mut doc_type, mut dtv, mut dtrv) = ("matroska".to_string(), 1, 1); + for e in Children::new(&hdr) { + match e.id { + DOC_TYPE => doc_type = ebml::string(e.data), + DOC_TYPE_VERSION => dtv = ebml::uint(e.data), + DOC_TYPE_READ_VERSION => dtrv = ebml::uint(e.data), + EBML_MAX_ID_LENGTH if ebml::uint(e.data) > 4 => return Err(Error::Unsupported("EBMLMaxIDLength > 4".into())), + EBML_MAX_SIZE_LENGTH if ebml::uint(e.data) > 8 => return Err(Error::Unsupported("EBMLMaxSizeLength > 8".into())), + _ => {} + } + } + if doc_type != "matroska" && doc_type != "webm" { + return Err(Error::NotMatroska(format!("DocType {doc_type:?}"))); + } + // Segment + let mut pos = hdr_end; + let seg = loop { + let h = io.header(pos, len)?.ok_or_else(|| Error::Invalid("no Segment".into()))?; + if h.id == SEGMENT { + break h; + } + if h.size == Size::Unknown { + return Err(Error::Invalid("unknown-size element before Segment".into())); + } + pos = h.end(len); + }; + let mut file = MkvFile { + doc_type, + doc_type_version: dtv, + doc_type_read_version: dtrv, + segment_data_start: seg.data_start(), + segment_end: seg.end(len), + live: seg.size == Size::Unknown, + info: SegmentInfo::default(), + tracks: Vec::new(), + seek_head: Vec::new(), + cues: Vec::new(), + clusters: Vec::new(), + chapters: Vec::new(), + tags: Vec::new(), + attachments: Vec::new(), + first_cluster: None, + indexed: false, + crc_checked: 0, + crc_errors: Vec::new(), + warnings: Vec::new(), + }; + if seg.overruns(len) { + file.warnings.push("Segment extends past end of file (truncated)".into()); + } + let mut w = Walker { opts, parsed: HashSet::new(), blocks_seen: false }; + w.walk(&mut io, &mut file)?; + finish_tracks(&mut file); + Ok(file) +} + +struct Walker<'o> { + opts: &'o OpenOptions, + parsed: HashSet, + blocks_seen: bool, +} + +impl Walker<'_> { + fn walk(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile) -> Result<()> { + let seg_end = file.segment_end; + let mut pos = file.segment_data_start; + while pos < seg_end { + let Some(h) = io.header(pos, seg_end)? else { + match io.resync(pos + 1, seg_end)? { + Some(p) => { + file.warnings.push(format!("undecodable element at {pos}; resynced at {p}")); + pos = p; + continue; + } + None => break, + } + }; + match h.id { + CLUSTER => { + if file.first_cluster.is_none() { + file.first_cluster = Some(h.pos); + // make sure Info/Tracks are known before interpreting blocks + self.follow_seek_head(io, file, &[INFO, TRACKS])?; + if !self.opts.index { + break; + } + } + let (ci, blocks) = scan_cluster(io, &h, seg_end, &file.tracks, &mut file.warnings)?; + if self.opts.verify_crc && h.size != Size::Unknown { + let data = io.read_vec(h.data_start(), ci.end - h.data_start())?; + self.check_crc(file, h.pos, &data); + } + add_blocks(file, file.clusters.len() as u32, &blocks); + self.blocks_seen = true; + file.clusters.push(ci); + pos = ci.end.max(h.data_start()); + } + SEEK_HEAD | INFO | TRACKS | CUES | CHAPTERS | TAGS | ATTACHMENTS => { + pos = self.level1(io, file, &h, seg_end)?; + } + EBML | SEGMENT => break, // chained segment: only the first one is read + _ => { + if h.size == Size::Unknown { + pos = io.unknown_end(h.data_start(), seg_end)?; + } else if h.overruns(seg_end) && h.id != VOID { + match io.resync(pos + 1, seg_end)? { + Some(p) => { + file.warnings.push(format!("oversized element 0x{:X} at {pos}; resynced at {p}", h.id)); + pos = p; + } + None => break, + } + } else { + pos = h.end(seg_end); + } + } + } + } + self.follow_seek_head(io, file, &[INFO, TRACKS, CUES, CHAPTERS, TAGS, ATTACHMENTS, SEEK_HEAD])?; + // follow a second-level SeekHead reached through the first one + self.follow_seek_head(io, file, &[CUES, CHAPTERS, TAGS, ATTACHMENTS])?; + file.cues.sort_by_key(|c| c.time); + file.indexed = self.opts.index; + Ok(()) + } + + fn follow_seek_head(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, ids: &[u32]) -> Result<()> { + let entries = file.seek_head.clone(); + for e in entries { + if !ids.contains(&e.id) { + continue; + } + let Some(abs) = file.segment_data_start.checked_add(e.position) else { continue }; + if self.parsed.contains(&abs) || abs >= file.segment_end { + continue; + } + match io.header(abs, file.segment_end)? { + Some(h) if h.id == e.id => { + self.level1(io, file, &h, file.segment_end)?; + } + _ => file.warnings.push(format!("SeekHead entry 0x{:X} -> {abs} does not point at that element", e.id)), + } + } + Ok(()) + } + + fn check_crc(&self, file: &mut MkvFile, pos: u64, data: &[u8]) { + if let Some(ok) = ebml::verify_crc(data) { + file.crc_checked += 1; + if !ok { + file.crc_errors.push(pos); + file.warnings.push(format!("CRC-32 mismatch in element at {pos}")); + } + } + } + + /// Parse a level-1 metadata element; returns its end. + fn level1(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, h: &Hdr, limit: u64) -> Result { + let end = if h.size == Size::Unknown { io.unknown_end(h.data_start(), limit)? } else { h.end(limit) }; + if h.overruns(limit) { + file.warnings.push(format!("element 0x{:X} at {} truncated", h.id, h.pos)); + } + if !self.parsed.insert(h.pos) { + return Ok(end); + } + if h.id == ATTACHMENTS { + self.attachments(io, file, h.data_start(), end)?; + return Ok(end); + } + let n = end - h.data_start(); + if n > MAX_META { + file.warnings.push(format!("element 0x{:X} at {} too large ({n} bytes); skipped", h.id, h.pos)); + return Ok(end); + } + let data = io.read_vec(h.data_start(), n)?; + if self.opts.verify_crc { + self.check_crc(file, h.pos, &data); + } + match h.id { + SEEK_HEAD => file.seek_head.extend(parse_seekhead(&data)), + INFO => file.info = parse_info(&data), + TRACKS => { + if file.tracks.is_empty() { + file.tracks = parse_tracks(&data, file.info.timestamp_scale); + } + } + CUES => parse_cues(&data, &mut file.cues), + CHAPTERS => parse_chapters(&data, &mut file.chapters), + TAGS => parse_tags(&data, &mut file.tags), + _ => {} + } + Ok(end) + } + + fn attachments(&mut self, io: &mut Io<'_, S>, file: &mut MkvFile, start: u64, end: u64) -> Result<()> { + let mut pos = start; + while pos < end { + let Some(h) = io.header(pos, end)? else { break }; + let he = h.end(end); + if h.id == ATTACHED_FILE { + let mut a = Attachment::default(); + let mut p = h.data_start(); + while p < he { + let Some(c) = io.header(p, he)? else { break }; + let ce = c.end(he); + let n = ce - c.data_start(); + if c.id == FILE_DATA { + a.data_offset = c.data_start(); + a.data_size = n; + } else if n <= 4096 { + let d = io.peek(c.data_start(), n as usize)?; + match c.id { + FILE_NAME => a.name = ebml::string(d), + FILE_MEDIA_TYPE => a.media_type = ebml::string(d), + FILE_DESCRIPTION => a.description = Some(ebml::string(d)), + FILE_UID => a.uid = ebml::uint(d), + _ => {} + } + } + p = ce; + } + file.attachments.push(a); + } + pos = he; + } + Ok(()) + } +} + +fn add_blocks(file: &mut MkvFile, cluster: u32, blocks: &[BlockRef]) { + let scale = file.info.timestamp_scale; + let mut frames: Vec = Vec::new(); + for b in blocks { + frames.clear(); + let t = &file.tracks[b.track]; + expand(b, t, scale, 0, &mut frames); + let t = &mut file.tracks[b.track]; + for f in &frames { + if f.keyframe { + t.sync.push(t.samples.len() as u32); + } + t.samples.push(Sample { + offset: f.offset, + size: f.size, + pts: f.pts, + duration: f.duration, + keyframe: f.keyframe, + cluster, + block_offset: f.block_offset, + lace: f.lace, + }); + } + } +} + +fn finish_tracks(file: &mut MkvFile) { + let scale = file.info.timestamp_scale; + let g = gcd(scale, 1_000_000_000); + for t in &mut file.tracks { + t.timebase = (scale / g, 1_000_000_000 / g); + } +} + +// --------------------------------------------------------------------------------------------- +// MkvFile helpers +// --------------------------------------------------------------------------------------------- + +impl MkvFile { + /// True for WebM (`DocType` = `webm`). + pub fn is_webm(&self) -> bool { + self.doc_type == "webm" + } + + /// Segment duration in ns: `Info/Duration` if present, else the end of the last indexed sample. + pub fn duration_ns(&self) -> Option { + if let Some(d) = self.info.duration { + return Some((d * self.info.timestamp_scale as f64).round() as u64); + } + let end = self.tracks.iter().flat_map(|t| t.samples.iter().map(|s| s.pts + s.duration as i64)).max()?; + Some((end.max(0) as u64).saturating_mul(self.info.timestamp_scale)) + } + + /// Index of the track with this `TrackNumber`. + pub fn track_by_number(&self, number: u64) -> Option { + self.tracks.iter().position(|t| t.number == number) + } + + /// Index of the first track of the given kind (preferring the default flag). + pub fn track_of_kind(&self, kind: TrackKind) -> Option { + let mut it = self.tracks.iter().enumerate().filter(|(_, t)| t.kind == kind); + let first = it.clone().next().map(|x| x.0); + it.find(|(_, t)| t.default).map(|x| x.0).or(first) + } + + /// Read sample `index` of `track` (header stripping undone). + pub fn read_sample(&self, src: &S, track: usize, index: usize) -> Result> { + let t = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; + let s = t.samples.get(index).ok_or(Error::NoSuchSample(index))?; + if !t.frames_readable() { + return Err(Error::Unsupported(format!("content encoding of track {} (compression/encryption)", t.number))); + } + let mut data = t.frame_prefix(s.size as u64); + let at = data.len(); + data.resize(at + s.size as usize, 0); + src.read_at(s.offset, &mut data[at..])?; + Ok(data) + } + + /// Keyframe sample of `track` with the greatest pts ≤ `time_ns` (indexed files only). + pub fn keyframe_before(&self, track: usize, time_ns: i64) -> Option { + let t = self.tracks.get(track)?; + t.keyframe_before_pts(t.ns_to_ticks(time_ns)) + } +} + +// --------------------------------------------------------------------------------------------- +// Demuxer (streaming packets + seeking) +// --------------------------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug)] +struct InCluster { + ts: i64, + end: Option, +} + +/// Packet reader over a [`ByteSource`]: iterates frames in file order and seeks to keyframes. +pub struct Demuxer { + src: S, + file: MkvFile, + opts: OpenOptions, + cache: Cache, + pos: u64, + cluster: Option, + pending: VecDeque, + skip_laces: Option<(u64, u16)>, + done: bool, +} + +impl Demuxer> { + /// Open from any `Read + Seek` stream. + pub fn from_reader(r: R) -> Result { + Self::new(ReadSeekSource::new(r)?) + } +} + +impl<'a> Demuxer<&'a [u8]> { + /// Open from an in-memory byte slice. + pub fn from_slice(data: &'a [u8]) -> Result { + Self::new(data) + } +} + +impl Demuxer { + /// Open with default options (full index). + pub fn new(src: S) -> Result { + Self::with_options(src, OpenOptions::default()) + } + + pub fn with_options(src: S, opts: OpenOptions) -> Result { + let mut cache = Cache::default(); + let file = open_impl(&src, &opts, &mut cache)?; + let pos = file.first_cluster.unwrap_or(file.segment_end); + Ok(Self { src, file, opts, cache, pos, cluster: None, pending: VecDeque::new(), skip_laces: None, done: false }) + } + + pub fn file(&self) -> &MkvFile { + &self.file + } + pub fn tracks(&self) -> &[Track] { + &self.file.tracks + } + pub fn source(&self) -> &S { + &self.src + } + pub fn into_parts(self) -> (S, MkvFile) { + (self.src, self.file) + } + /// Segment duration in ns. + pub fn duration_ns(&self) -> Option { + self.file.duration_ns() + } + + /// Scan all clusters and build sample tables (no-op if already indexed). + pub fn build_index(&mut self) -> Result<()> { + if self.file.indexed { + return Ok(()); + } + let opts = OpenOptions { index: true, verify_crc: self.opts.verify_crc }; + self.file = open_impl(&self.src, &opts, &mut self.cache)?; + Ok(()) + } + + /// Keyframes of `track` (builds the index if needed). + pub fn keyframe_index(&mut self, track: usize) -> Result> { + self.build_index()?; + let t = self.file.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; + Ok(t.keyframes() + .iter() + .map(|&k| { + let s = &t.samples[k as usize]; + Keyframe { sample: k as usize, pts: s.pts, pts_ns: t.ticks_to_ns(s.pts), block_offset: s.block_offset } + }) + .collect()) + } + + /// Rewind to the first cluster. + pub fn rewind(&mut self) { + self.pos = self.file.first_cluster.unwrap_or(self.file.segment_end); + self.cluster = None; + self.pending.clear(); + self.skip_laces = None; + self.done = false; + } + + /// Read sample `index` of `track` (requires an index). + pub fn read_sample(&self, track: usize, index: usize) -> Result> { + self.file.read_sample(&self.src, track, index) + } + + /// Position the packet stream at the keyframe of `track` with the greatest pts ≤ `time_ns` + /// (or the track's first keyframe if none precedes it). The next [`Demuxer::next_packet`] + /// call returns packets starting at that keyframe's block (all tracks). + /// + /// Uses the sample index when present, else Cues (refined by scanning the cued cluster), else + /// builds the index first. + pub fn seek(&mut self, track: usize, time_ns: i64) -> Result { + let t = self.file.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; + let ticks = t.ns_to_ticks(time_ns); + if !self.file.indexed { + let tn = t.number; + let cue = self + .file + .cues + .iter() + .filter(|c| c.time as i64 <= ticks) + .filter_map(|c| c.positions.iter().find(|p| p.track == tn).map(|p| (c.time, *p))) + .next_back() + .or_else(|| self.file.cues.iter().find_map(|c| c.positions.iter().find(|p| p.track == tn).map(|p| (c.time, *p)))); + if let Some((_, cp)) = cue + && let Some(sp) = self.seek_via_cue(track, ticks, cp.cluster_position)? + { + return Ok(sp); + } + self.build_index()?; + } + let t = &self.file.tracks[track]; + let k = t.keyframe_before_pts(ticks).ok_or_else(|| Error::Invalid(format!("track {} has no keyframes", t.number)))?; + let s = t.samples[k]; + let c = self.file.clusters[s.cluster as usize]; + let sp = SeekPoint { track, pts: s.pts, pts_ns: t.ticks_to_ns(s.pts), sample: Some(k), cluster_offset: c.offset, block_offset: s.block_offset }; + self.position_at(c, s.block_offset, s.lace); + Ok(sp) + } + + fn position_at(&mut self, c: ClusterInfo, block_offset: u64, lace: u16) { + self.pending.clear(); + self.done = false; + self.pos = block_offset; + let known = c.end; + self.cluster = Some(InCluster { ts: c.timestamp as i64, end: Some(known) }); + self.skip_laces = (lace > 0).then_some((block_offset, lace)); + } + + fn seek_via_cue(&mut self, track: usize, ticks: i64, cluster_position: u64) -> Result> { + let abs = self.file.segment_data_start + cluster_position; + let mut io = Io::new(&self.src, &mut self.cache); + let Some(h) = io.header(abs, self.file.segment_end)? else { return Ok(None) }; + if h.id != CLUSTER { + self.file.warnings.push(format!("cue points at {abs}, which is not a Cluster")); + return Ok(None); + } + let mut warnings = Vec::new(); + let (ci, blocks) = scan_cluster(&mut io, &h, self.file.segment_end, &self.file.tracks, &mut warnings)?; + let t = &self.file.tracks[track]; + let mut frames = Vec::new(); + for b in blocks.iter().filter(|b| b.track == track && b.keyframe) { + expand(b, t, self.file.info.timestamp_scale, 0, &mut frames); + } + let best = frames.iter().filter(|f| f.pts <= ticks).max_by_key(|f| f.pts).or_else(|| frames.iter().min_by_key(|f| f.pts)).copied(); + let Some(f) = best else { return Ok(None) }; + let sp = SeekPoint { track, pts: f.pts, pts_ns: f.pts_ns, sample: None, cluster_offset: ci.offset, block_offset: f.block_offset }; + self.position_at(ci, f.block_offset, f.lace); + Ok(Some(sp)) + } + + /// Next frame in file order, or `None` at the end of the Segment. + pub fn next_packet(&mut self) -> Result> { + loop { + if let Some(f) = self.pending.pop_front() { + return self.materialize(f).map(Some); + } + if self.done || !self.fill()? { + self.done = true; + return Ok(None); + } + } + } + + fn materialize(&mut self, f: FrameRef) -> Result { + let t = &self.file.tracks[f.track]; + let mut data = if t.frames_readable() { t.frame_prefix(f.size as u64) } else { Vec::new() }; + let at = data.len(); + data.resize(at + f.size as usize, 0); + let mut io = Io::new(&self.src, &mut self.cache); + if (f.size as usize) <= WIN / 2 { + let b = io.peek(f.offset, f.size as usize)?; + if b.len() != f.size as usize { + return Err(Error::Invalid("frame data past end of file".into())); + } + data[at..].copy_from_slice(b); + } else { + self.src.read_at(f.offset, &mut data[at..])?; + } + Ok(Packet { + track: f.track, + track_number: t.number, + pts: f.pts, + pts_ns: f.pts_ns, + duration: f.duration, + duration_ns: f.duration_ns, + keyframe: f.keyframe, + discardable: f.discardable, + invisible: f.invisible, + discard_padding_ns: f.discard_padding, + offset: f.offset, + lace: f.lace, + data, + }) + } + + /// Advance over elements until at least one frame is pending. Returns false at the end. + fn fill(&mut self) -> Result { + let seg_end = self.file.segment_end; + let scale = self.file.info.timestamp_scale; + let mut io = Io::new(&self.src, &mut self.cache); + let lookup = track_lookup(&self.file.tracks); + loop { + if let Some(c) = self.cluster { + let lim = c.end.unwrap_or(seg_end); + if self.pos >= lim { + self.cluster = None; + continue; + } + let Some(h) = io.header(self.pos, lim)? else { + self.cluster = None; + match io.resync(self.pos + 1, seg_end)? { + Some(p) => { + self.pos = p; + continue; + } + None => return Ok(false), + } + }; + if c.end.is_none() && is_top_level(h.id) { + self.cluster = None; + continue; + } + if h.size == Size::Unknown || h.overruns(lim) { + self.cluster = None; + match io.resync(self.pos + 1, seg_end)? { + Some(p) => { + self.pos = p; + continue; + } + None => return Ok(false), + } + } + let he = h.end(lim); + self.pos = he; + match h.id { + TIMESTAMP => { + let v = ebml::uint(io.peek(h.data_start(), (he - h.data_start()) as usize)?) as i64; + self.cluster = Some(InCluster { ts: v, end: c.end }); + } + SIMPLE_BLOCK | BLOCK_GROUP => { + if let Some(b) = parse_block_elem(&mut io, &h, he, c.ts, &lookup)? { + let skip = match self.skip_laces.take() { + Some((off, n)) if off == h.pos => n, + _ => 0, + }; + expand(&b, &self.file.tracks[b.track], scale, skip, &mut self.pending); + if !self.pending.is_empty() { + return Ok(true); + } + } + } + _ => {} + } + continue; + } + if self.pos >= seg_end { + return Ok(false); + } + let Some(h) = io.header(self.pos, seg_end)? else { + match io.resync(self.pos + 1, seg_end)? { + Some(p) => { + self.pos = p; + continue; + } + None => return Ok(false), + } + }; + match h.id { + CLUSTER => { + self.cluster = Some(InCluster { ts: 0, end: h.size.known().map(|_| h.end(seg_end)) }); + self.pos = h.data_start(); + } + EBML | SEGMENT => return Ok(false), + _ => { + self.pos = if h.size == Size::Unknown { io.unknown_end(h.data_start(), seg_end)? } else { h.end(seg_end) }; + if self.pos <= h.pos { + return Ok(false); + } + } + } + } + } +} + +impl Iterator for Demuxer { + type Item = Result; + fn next(&mut self) -> Option> { + match self.next_packet() { + Ok(Some(p)) => Some(Ok(p)), + Ok(None) => None, + Err(e) => { + self.done = true; + Some(Err(e)) + } + } + } +} diff --git a/crates/matroska/src/ebml.rs b/crates/matroska/src/ebml.rs new file mode 100644 index 000000000..cccfdcf3f --- /dev/null +++ b/crates/matroska/src/ebml.rs @@ -0,0 +1,476 @@ +//! EBML primitives (RFC 8794): variable-length integers, element headers, typed values, CRC-32, +//! and Matroska block lacing (RFC 9559 §10.3). + +use crate::error::{Result, invalid}; + +/// Length in bytes of a VINT whose first byte is `b` (1..=8), or `None` for a zero first byte. +#[inline] +pub fn vint_len(b: u8) -> Option { + if b == 0 { None } else { Some(b.leading_zeros() as usize + 1) } +} + +/// Element data size: known, or "unknown" (all value bits set). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Size { + Known(u64), + Unknown, +} + +impl Size { + pub fn known(self) -> Option { + match self { + Size::Known(n) => Some(n), + Size::Unknown => None, + } + } +} + +/// Decode an unsigned VINT (marker bit stripped). Returns `(value, length, all_ones)`. +pub fn read_vint(data: &[u8]) -> Option<(u64, usize, bool)> { + let first = *data.first()?; + let len = vint_len(first)?; + if data.len() < len { + return None; + } + let mask = if len == 8 { 0 } else { 0xFFu8 >> len }; + let mut v = (first & mask) as u64; + for &b in &data[1..len] { + v = (v << 8) | b as u64; + } + let all_ones = v == (1u64 << (7 * len)) - 1; + Some((v, len, all_ones)) +} + +/// Decode an element data size. All-ones is the reserved "unknown size". +pub fn read_size(data: &[u8]) -> Option<(Size, usize)> { + let (v, len, ones) = read_vint(data)?; + Some((if ones { Size::Unknown } else { Size::Known(v) }, len)) +} + +/// Decode an element ID (marker bits kept, 1..=4 bytes). Rejects all-ones value bits (reserved). +/// All-zero value bits are accepted, because Matroska's `ChapterDisplay` uses ID `0x80`. +pub fn read_id(data: &[u8]) -> Option<(u32, usize)> { + let first = *data.first()?; + let len = vint_len(first)?; + if len > 4 || data.len() < len { + return None; + } + let (v, _, ones) = read_vint(&data[..len])?; + if ones || (v == 0 && len > 1) { + return None; + } + let mut id = 0u32; + for &b in &data[..len] { + id = (id << 8) | b as u32; + } + Some((id, len)) +} + +/// Decode a signed VINT as used by EBML lacing (value − (2^(7·len−1) − 1)). +pub fn read_svint(data: &[u8]) -> Option<(i64, usize)> { + let (v, len, _) = read_vint(data)?; + let bias = (1i64 << (7 * len - 1)) - 1; + Some((v as i64 - bias, len)) +} + +/// Encode an element ID (already carrying its marker bits). +pub fn write_id(out: &mut Vec, id: u32) { + let n = if id >= 0x100_0000 { + 4 + } else if id >= 0x1_0000 { + 3 + } else if id >= 0x100 { + 2 + } else { + 1 + }; + out.extend_from_slice(&id.to_be_bytes()[4 - n..]); +} + +/// Encode a data size as a VINT using the shortest length (or `min_len` if larger). +pub fn write_size(out: &mut Vec, size: u64, min_len: usize) { + let mut len = 1; + while len < 8 && size >= (1u64 << (7 * len)) - 1 { + len += 1; + } + let len = len.max(min_len).min(8); + let v = size | (1u64 << (7 * len)); + out.extend_from_slice(&v.to_be_bytes()[8 - len..]); +} + +/// Encode the "unknown size" marker of the given length. +pub fn write_unknown_size(out: &mut Vec, len: usize) { + out.push(0xFFu8 >> (len - 1)); + out.extend(std::iter::repeat_n(0xFF, len - 1)); +} + +/// Big-endian unsigned integer of 0..=8 bytes. +pub fn uint(data: &[u8]) -> u64 { + data.iter().take(8).fold(0u64, |a, &b| (a << 8) | b as u64) +} + +/// Big-endian two's-complement signed integer of 0..=8 bytes. +pub fn int(data: &[u8]) -> i64 { + let n = data.len().min(8); + if n == 0 { + return 0; + } + let v = uint(&data[..n]); + let shift = 64 - 8 * n as u32; + ((v << shift) as i64) >> shift +} + +/// IEEE float of 0, 4 or 8 bytes. +pub fn float(data: &[u8]) -> f64 { + match data.len() { + 4 => f32::from_be_bytes(data.try_into().unwrap_or([0; 4])) as f64, + 8 => f64::from_be_bytes(data.try_into().unwrap_or([0; 8])), + _ => 0.0, + } +} + +/// String / UTF-8 value: bytes up to the first NUL, lossily decoded. +pub fn string(data: &[u8]) -> String { + let end = data.iter().position(|&b| b == 0).unwrap_or(data.len()); + String::from_utf8_lossy(&data[..end]).into_owned() +} + +/// One element inside an in-memory master element. +#[derive(Clone, Copy, Debug)] +pub struct Elem<'a> { + pub id: u32, + /// Offset of the element header, relative to the start of the parent's data. + pub offset: usize, + pub header_len: usize, + pub data: &'a [u8], + /// The size field was "unknown" (data runs to the end of the parent). + pub unknown_size: bool, + /// The declared size overran the parent and was truncated. + pub truncated: bool, +} + +/// Iterator over the children of an in-memory master element. +/// +/// Damaged data ends the iteration (see [`Children::damaged`]); oversize elements are truncated to +/// the parent's end. +pub struct Children<'a> { + data: &'a [u8], + pos: usize, + damaged: bool, +} + +impl<'a> Children<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0, damaged: false } + } + /// True if iteration stopped on an undecodable header. + pub fn damaged(&self) -> bool { + self.damaged + } +} + +impl<'a> Iterator for Children<'a> { + type Item = Elem<'a>; + fn next(&mut self) -> Option> { + let rest = self.data.get(self.pos..)?; + if rest.is_empty() { + return None; + } + let Some((id, il)) = read_id(rest) else { + self.damaged = true; + self.pos = self.data.len(); + return None; + }; + let Some((size, sl)) = read_size(&rest[il..]) else { + self.damaged = true; + self.pos = self.data.len(); + return None; + }; + let hl = il + sl; + let avail = (rest.len() - hl) as u64; + let (len, unknown, truncated) = match size { + Size::Known(n) if n <= avail => (n as usize, false, false), + Size::Known(_) => (avail as usize, false, true), + Size::Unknown => (avail as usize, true, false), + }; + let e = Elem { id, offset: self.pos, header_len: hl, data: &rest[hl..hl + len], unknown_size: unknown, truncated }; + self.pos += hl + len; + Some(e) + } +} + +/// CRC-32 (ISO 3309 / IEEE 802.3, reflected polynomial 0xEDB88320), as used by EBML CRC-32 elements. +pub fn crc32(data: &[u8]) -> u32 { + crc32_update(0xFFFF_FFFF, data) ^ 0xFFFF_FFFF +} + +/// Incremental form: start with `0xFFFF_FFFF`, finish with `^ 0xFFFF_FFFF`. +pub fn crc32_update(mut crc: u32, data: &[u8]) -> u32 { + static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new(); + let t = TABLE.get_or_init(|| { + let mut t = [0u32; 256]; + for (i, e) in t.iter_mut().enumerate() { + let mut c = i as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; + } + *e = c; + } + t + }); + for &b in data { + crc = t[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8); + } + crc +} + +/// Check a master element's leading CRC-32 child, if present. `Some(true/false)` if there is one. +pub fn verify_crc(master_data: &[u8]) -> Option { + let mut it = Children::new(master_data); + let first = it.next()?; + if first.id != crate::ids::CRC32 || first.data.len() != 4 { + return None; + } + let stored = u32::from_le_bytes(first.data.try_into().ok()?); + let rest = &master_data[first.offset + first.header_len + 4..]; + Some(crc32(rest) == stored) +} + +/// Block lacing mode (flags bits 1–2). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Lacing { + None, + Xiph, + FixedSize, + Ebml, +} + +impl Lacing { + pub fn from_flags(flags: u8) -> Lacing { + match (flags >> 1) & 3 { + 0 => Lacing::None, + 1 => Lacing::Xiph, + 2 => Lacing::FixedSize, + _ => Lacing::Ebml, + } + } +} + +/// Split laced block payload. `data` starts right after the block's flags byte and runs to the end +/// of the block. Returns `(offset, len)` of each frame relative to `data`. +pub fn parse_lacing(lacing: Lacing, data: &[u8]) -> Result> { + if lacing == Lacing::None { + return Ok(vec![(0, data.len())]); + } + let Some(&n1) = data.first() else { return invalid("laced block without frame count") }; + let count = n1 as usize + 1; + let mut pos = 1usize; + let mut sizes = Vec::with_capacity(count); + match lacing { + Lacing::None => unreachable!(), + Lacing::Xiph => { + for _ in 0..count - 1 { + let mut s = 0usize; + loop { + let Some(&b) = data.get(pos) else { return invalid("truncated Xiph lace header") }; + pos += 1; + s += b as usize; + if b != 255 { + break; + } + } + sizes.push(s); + } + } + Lacing::Ebml => { + if count > 1 { + let Some((first, l, _)) = read_vint(&data[pos..]) else { return invalid("bad EBML lace size") }; + pos += l; + let mut prev = first as i64; + sizes.push(first as usize); + for _ in 1..count - 1 { + let Some((d, l)) = read_svint(&data[pos..]) else { return invalid("bad EBML lace delta") }; + pos += l; + prev += d; + if prev < 0 { + return invalid("negative EBML lace size"); + } + sizes.push(prev as usize); + } + } + } + Lacing::FixedSize => { + let rest = data.len() - pos; + if !rest.is_multiple_of(count) { + return invalid("fixed-size lacing does not divide block"); + } + sizes.extend(std::iter::repeat_n(rest / count, count - 1)); + } + } + let used: usize = sizes.iter().try_fold(0usize, |a, &s| a.checked_add(s)).unwrap_or(usize::MAX); + if pos > data.len() || used > data.len() - pos { + return invalid("lace sizes exceed block"); + } + sizes.push(data.len() - pos - used); + let mut out = Vec::with_capacity(count); + for s in sizes { + out.push((pos, s)); + pos += s; + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vint_roundtrip() { + for &v in &[0u64, 1, 126, 127, 128, 16382, 16383, 16384, 1 << 20, (1 << 56) - 2] { + let mut b = Vec::new(); + write_size(&mut b, v, 1); + let (s, l) = read_size(&b).unwrap(); + assert_eq!(s, Size::Known(v), "value {v}"); + assert_eq!(l, b.len()); + } + // 127 needs 2 bytes because 0xFF is "unknown" + let mut b = Vec::new(); + write_size(&mut b, 127, 1); + assert_eq!(b, [0x40, 0x7F]); + // padded length + let mut b = Vec::new(); + write_size(&mut b, 5, 8); + assert_eq!(b, [1, 0, 0, 0, 0, 0, 0, 5]); + assert_eq!(read_size(&b).unwrap(), (Size::Known(5), 8)); + } + + #[test] + fn vint_unknown_and_invalid() { + assert_eq!(read_size(&[0xFF]).unwrap(), (Size::Unknown, 1)); + assert_eq!(read_size(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]).unwrap(), (Size::Unknown, 8)); + for l in 1..=8 { + let mut b = Vec::new(); + write_unknown_size(&mut b, l); + assert_eq!(read_size(&b).unwrap(), (Size::Unknown, l)); + } + assert!(read_size(&[0x00, 1, 2]).is_none()); + assert!(read_size(&[0x40]).is_none()); // truncated + assert_eq!(read_vint(&[0x81]).unwrap(), (1, 1, false)); + assert_eq!(read_vint(&[0x10, 0x00, 0x00, 0x01]).unwrap(), (1, 4, false)); + } + + #[test] + fn ids() { + assert_eq!(read_id(&[0x1A, 0x45, 0xDF, 0xA3, 0x99]).unwrap(), (crate::ids::EBML, 4)); + assert_eq!(read_id(&[0xA3]).unwrap(), (0xA3, 1)); + assert_eq!(read_id(&[0x42, 0x86]).unwrap(), (0x4286, 2)); + assert!(read_id(&[0xFF]).is_none()); // reserved + assert_eq!(read_id(&[0x80]).unwrap(), (0x80, 1)); // ChapterDisplay + assert!(read_id(&[0x40, 0x00]).is_none()); // zero value + assert!(read_id(&[0x08, 0, 0, 0, 1]).is_none()); // 5-byte ID + for id in [0xA3u32, 0x4286, 0x2AD7B1, 0x1F43B675] { + let mut b = Vec::new(); + write_id(&mut b, id); + assert_eq!(read_id(&b).unwrap(), (id, b.len())); + } + } + + #[test] + fn signed_vint() { + assert_eq!(read_svint(&[0xBF]).unwrap(), (0, 1)); // 63 - 63 + assert_eq!(read_svint(&[0x80]).unwrap(), (-63, 1)); + assert_eq!(read_svint(&[0xC0]).unwrap(), (1, 1)); + assert_eq!(read_svint(&[0x5F, 0xFF]).unwrap(), (0, 2)); + assert_eq!(read_svint(&[0x60, 0x00]).unwrap(), (1, 2)); + } + + #[test] + fn values() { + assert_eq!(uint(&[]), 0); + assert_eq!(uint(&[1, 0]), 256); + assert_eq!(int(&[0xFF]), -1); + assert_eq!(int(&[0xFF, 0xFE]), -2); + assert_eq!(int(&[0x7F]), 127); + assert_eq!(float(&1.5f32.to_be_bytes()), 1.5); + assert_eq!(float(&2.25f64.to_be_bytes()), 2.25); + assert_eq!(string(b"abc\0\0"), "abc"); + } + + #[test] + fn crc() { + assert_eq!(crc32(b"123456789"), 0xCBF4_3926); + let payload = [0x42u8, 0x86, 0x81, 0x01]; + let mut m = vec![0xBF, 0x84]; + m.extend_from_slice(&crc32(&payload).to_le_bytes()); + m.extend_from_slice(&payload); + assert_eq!(verify_crc(&m), Some(true)); + m[7] ^= 1; + assert_eq!(verify_crc(&m), Some(false)); + assert_eq!(verify_crc(&payload), None); + } + + #[test] + fn children_iter() { + // two elements, then one whose size overruns + let d = [0x42, 0x86, 0x81, 0x01, 0xEC, 0x82, 0, 0, 0xA3, 0x85, 1, 2]; + let v: Vec<_> = Children::new(&d).collect(); + assert_eq!(v.len(), 3); + assert_eq!(v[0].id, 0x4286); + assert_eq!(v[0].data, &[1]); + assert_eq!(v[1].id, 0xEC); + assert_eq!(v[2].data, &[1, 2]); + assert!(v[2].truncated); + let mut it = Children::new(&[0x00, 0x01]); + assert!(it.next().is_none()); + assert!(it.damaged()); + } + + fn frames<'a>(d: &'a [u8], f: &[(usize, usize)]) -> Vec<&'a [u8]> { + f.iter().map(|&(o, l)| &d[o..o + l]).collect() + } + + #[test] + fn lacing_xiph() { + // 3 frames: 300, 2, rest(3) + let mut d = vec![2u8, 255, 45, 2]; + d.extend(std::iter::repeat_n(7u8, 300)); + d.extend_from_slice(&[1, 2]); + d.extend_from_slice(&[3, 4, 5]); + let f = parse_lacing(Lacing::Xiph, &d).unwrap(); + assert_eq!(f.iter().map(|x| x.1).collect::>(), [300, 2, 3]); + assert_eq!(frames(&d, &f)[2], &[3, 4, 5]); + // exact multiple of 255 needs a terminating 0 + let mut d = vec![1u8, 255, 0]; + d.extend(std::iter::repeat_n(1u8, 255)); + d.push(9); + let f = parse_lacing(Lacing::Xiph, &d).unwrap(); + assert_eq!(f.iter().map(|x| x.1).collect::>(), [255, 1]); + assert!(parse_lacing(Lacing::Xiph, &[1, 200, 1]).is_err()); + } + + #[test] + fn lacing_ebml() { + // 4 frames: 5, 3 (delta -2), 4 (delta +1), rest 2 + let mut d = vec![3u8, 0x85, 0xBF - 2, 0xBF + 1]; + d.extend_from_slice(&[1; 5]); + d.extend_from_slice(&[2; 3]); + d.extend_from_slice(&[3; 4]); + d.extend_from_slice(&[4; 2]); + let f = parse_lacing(Lacing::Ebml, &d).unwrap(); + assert_eq!(f.iter().map(|x| x.1).collect::>(), [5, 3, 4, 2]); + assert_eq!(frames(&d, &f), [&[1u8; 5][..], &[2; 3], &[3; 4], &[4; 2]]); + assert!(parse_lacing(Lacing::Ebml, &[1, 0x90]).is_err()); + } + + #[test] + fn lacing_fixed_and_none() { + let d = [2u8, 1, 1, 2, 2, 3, 3]; + let f = parse_lacing(Lacing::FixedSize, &d).unwrap(); + assert_eq!(frames(&d, &f), [&[1u8, 1][..], &[2, 2], &[3, 3]]); + assert!(parse_lacing(Lacing::FixedSize, &[2, 1, 1]).is_err()); + assert_eq!(parse_lacing(Lacing::None, &[9, 9]).unwrap(), [(0, 2)]); + assert_eq!(Lacing::from_flags(0x80 | 0x06), Lacing::Ebml); + assert_eq!(Lacing::from_flags(0x02), Lacing::Xiph); + assert_eq!(Lacing::from_flags(0x04), Lacing::FixedSize); + } +} diff --git a/crates/matroska/src/error.rs b/crates/matroska/src/error.rs new file mode 100644 index 000000000..718c5c505 --- /dev/null +++ b/crates/matroska/src/error.rs @@ -0,0 +1,53 @@ +use std::fmt; +use std::io; + +/// Errors produced by the demuxer. +#[derive(Debug)] +pub enum Error { + /// Underlying I/O failure. + Io(io::Error), + /// Structurally invalid data that could not be recovered from. + Invalid(String), + /// Valid but unsupported feature (e.g. zlib-compressed or encrypted tracks when reading data). + Unsupported(String), + /// Not an EBML file, or an EBML file whose DocType is not `matroska`/`webm`. + NotMatroska(String), + /// A track index was out of range. + NoSuchTrack(usize), + /// A sample index was out of range. + NoSuchSample(usize), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::Invalid(s) => write!(f, "invalid data: {s}"), + Error::Unsupported(s) => write!(f, "unsupported: {s}"), + Error::NotMatroska(s) => write!(f, "not a Matroska/WebM file: {s}"), + Error::NoSuchTrack(i) => write!(f, "track index {i} out of range"), + Error::NoSuchSample(i) => write!(f, "sample index {i} out of range"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::Io(e) + } +} + +pub type Result = std::result::Result; + +pub(crate) fn invalid(msg: impl Into) -> Result { + Err(Error::Invalid(msg.into())) +} diff --git a/crates/matroska/src/ids.rs b/crates/matroska/src/ids.rs new file mode 100644 index 000000000..be1ef47e6 --- /dev/null +++ b/crates/matroska/src/ids.rs @@ -0,0 +1,206 @@ +//! EBML / Matroska element IDs (RFC 8794, RFC 9559). IDs keep their VINT marker bits. +#![allow(dead_code)] + +// EBML header + global elements +pub const EBML: u32 = 0x1A45_DFA3; +pub const EBML_VERSION: u32 = 0x4286; +pub const EBML_READ_VERSION: u32 = 0x42F7; +pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2; +pub const EBML_MAX_SIZE_LENGTH: u32 = 0x42F3; +pub const DOC_TYPE: u32 = 0x4282; +pub const DOC_TYPE_VERSION: u32 = 0x4287; +pub const DOC_TYPE_READ_VERSION: u32 = 0x4285; +pub const CRC32: u32 = 0xBF; +pub const VOID: u32 = 0xEC; + +// Segment and level-1 elements +pub const SEGMENT: u32 = 0x1853_8067; +pub const SEEK_HEAD: u32 = 0x114D_9B74; +pub const INFO: u32 = 0x1549_A966; +pub const TRACKS: u32 = 0x1654_AE6B; +pub const CLUSTER: u32 = 0x1F43_B675; +pub const CUES: u32 = 0x1C53_BB6B; +pub const ATTACHMENTS: u32 = 0x1941_A469; +pub const CHAPTERS: u32 = 0x1043_A770; +pub const TAGS: u32 = 0x1254_C367; + +/// True for IDs that can only appear directly inside a Segment (or are the EBML/Segment headers): +/// used to find the end of unknown-size elements. +pub fn is_top_level(id: u32) -> bool { + matches!(id, EBML | SEGMENT | SEEK_HEAD | INFO | TRACKS | CLUSTER | CUES | ATTACHMENTS | CHAPTERS | TAGS) +} + +// SeekHead +pub const SEEK: u32 = 0x4DBB; +pub const SEEK_ID: u32 = 0x53AB; +pub const SEEK_POSITION: u32 = 0x53AC; + +// Info +pub const SEGMENT_UUID: u32 = 0x73A4; +pub const SEGMENT_FILENAME: u32 = 0x7384; +pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1; +pub const DURATION: u32 = 0x4489; +pub const DATE_UTC: u32 = 0x4461; +pub const TITLE: u32 = 0x7BA9; +pub const MUXING_APP: u32 = 0x4D80; +pub const WRITING_APP: u32 = 0x5741; + +// Cluster +pub const TIMESTAMP: u32 = 0xE7; +pub const POSITION: u32 = 0xA7; +pub const PREV_SIZE: u32 = 0xAB; +pub const SIMPLE_BLOCK: u32 = 0xA3; +pub const BLOCK_GROUP: u32 = 0xA0; +pub const ENCRYPTED_BLOCK: u32 = 0xAF; +pub const SILENT_TRACKS: u32 = 0x5854; +pub const BLOCK: u32 = 0xA1; +pub const BLOCK_ADDITIONS: u32 = 0x75A1; +pub const BLOCK_DURATION: u32 = 0x9B; +pub const REFERENCE_PRIORITY: u32 = 0xFA; +pub const REFERENCE_BLOCK: u32 = 0xFB; +pub const CODEC_STATE: u32 = 0xA4; +pub const DISCARD_PADDING: u32 = 0x75A2; + +pub fn is_cluster_child(id: u32) -> bool { + matches!(id, TIMESTAMP | POSITION | PREV_SIZE | SIMPLE_BLOCK | BLOCK_GROUP | ENCRYPTED_BLOCK | SILENT_TRACKS | CRC32 | VOID) +} + +// Tracks +pub const TRACK_ENTRY: u32 = 0xAE; +pub const TRACK_NUMBER: u32 = 0xD7; +pub const TRACK_UID: u32 = 0x73C5; +pub const TRACK_TYPE: u32 = 0x83; +pub const FLAG_ENABLED: u32 = 0xB9; +pub const FLAG_DEFAULT: u32 = 0x88; +pub const FLAG_FORCED: u32 = 0x55AA; +pub const FLAG_HEARING_IMPAIRED: u32 = 0x55AB; +pub const FLAG_VISUAL_IMPAIRED: u32 = 0x55AC; +pub const FLAG_ORIGINAL: u32 = 0x55AE; +pub const FLAG_COMMENTARY: u32 = 0x55AF; +pub const FLAG_LACING: u32 = 0x9C; +pub const DEFAULT_DURATION: u32 = 0x23_E383; +pub const TRACK_TIMESTAMP_SCALE: u32 = 0x23_314F; +pub const NAME: u32 = 0x536E; +pub const LANGUAGE: u32 = 0x22_B59C; +pub const LANGUAGE_BCP47: u32 = 0x22_B59D; +pub const CODEC_ID: u32 = 0x86; +pub const CODEC_PRIVATE: u32 = 0x63A2; +pub const CODEC_NAME: u32 = 0x25_8688; +pub const CODEC_DELAY: u32 = 0x56AA; +pub const SEEK_PRE_ROLL: u32 = 0x56BB; + +// Video +pub const VIDEO: u32 = 0xE0; +pub const FLAG_INTERLACED: u32 = 0x9A; +pub const FIELD_ORDER: u32 = 0x9D; +pub const STEREO_MODE: u32 = 0x53B8; +pub const ALPHA_MODE: u32 = 0x53C0; +pub const PIXEL_WIDTH: u32 = 0xB0; +pub const PIXEL_HEIGHT: u32 = 0xBA; +pub const PIXEL_CROP_BOTTOM: u32 = 0x54AA; +pub const PIXEL_CROP_TOP: u32 = 0x54BB; +pub const PIXEL_CROP_LEFT: u32 = 0x54CC; +pub const PIXEL_CROP_RIGHT: u32 = 0x54DD; +pub const DISPLAY_WIDTH: u32 = 0x54B0; +pub const DISPLAY_HEIGHT: u32 = 0x54BA; +pub const DISPLAY_UNIT: u32 = 0x54B2; +pub const COLOUR_SPACE: u32 = 0x2E_B524; +pub const COLOUR: u32 = 0x55B0; + +// Colour +pub const MATRIX_COEFFICIENTS: u32 = 0x55B1; +pub const BITS_PER_CHANNEL: u32 = 0x55B2; +pub const CHROMA_SUBSAMPLING_HORZ: u32 = 0x55B3; +pub const CHROMA_SUBSAMPLING_VERT: u32 = 0x55B4; +pub const CB_SUBSAMPLING_HORZ: u32 = 0x55B5; +pub const CB_SUBSAMPLING_VERT: u32 = 0x55B6; +pub const CHROMA_SITING_HORZ: u32 = 0x55B7; +pub const CHROMA_SITING_VERT: u32 = 0x55B8; +pub const RANGE: u32 = 0x55B9; +pub const TRANSFER_CHARACTERISTICS: u32 = 0x55BA; +pub const PRIMARIES: u32 = 0x55BB; +pub const MAX_CLL: u32 = 0x55BC; +pub const MAX_FALL: u32 = 0x55BD; +pub const MASTERING_METADATA: u32 = 0x55D0; +pub const PRIMARY_R_X: u32 = 0x55D1; +pub const PRIMARY_R_Y: u32 = 0x55D2; +pub const PRIMARY_G_X: u32 = 0x55D3; +pub const PRIMARY_G_Y: u32 = 0x55D4; +pub const PRIMARY_B_X: u32 = 0x55D5; +pub const PRIMARY_B_Y: u32 = 0x55D6; +pub const WHITE_POINT_X: u32 = 0x55D7; +pub const WHITE_POINT_Y: u32 = 0x55D8; +pub const LUMINANCE_MAX: u32 = 0x55D9; +pub const LUMINANCE_MIN: u32 = 0x55DA; + +// Audio +pub const AUDIO: u32 = 0xE1; +pub const SAMPLING_FREQUENCY: u32 = 0xB5; +pub const OUTPUT_SAMPLING_FREQUENCY: u32 = 0x78B5; +pub const CHANNELS: u32 = 0x9F; +pub const BIT_DEPTH: u32 = 0x6264; + +// ContentEncodings +pub const CONTENT_ENCODINGS: u32 = 0x6D80; +pub const CONTENT_ENCODING: u32 = 0x6240; +pub const CONTENT_ENCODING_ORDER: u32 = 0x5031; +pub const CONTENT_ENCODING_SCOPE: u32 = 0x5032; +pub const CONTENT_ENCODING_TYPE: u32 = 0x5033; +pub const CONTENT_COMPRESSION: u32 = 0x5034; +pub const CONTENT_COMP_ALGO: u32 = 0x4254; +pub const CONTENT_COMP_SETTINGS: u32 = 0x4255; +pub const CONTENT_ENCRYPTION: u32 = 0x5035; + +// Cues +pub const CUE_POINT: u32 = 0xBB; +pub const CUE_TIME: u32 = 0xB3; +pub const CUE_TRACK_POSITIONS: u32 = 0xB7; +pub const CUE_TRACK: u32 = 0xF7; +pub const CUE_CLUSTER_POSITION: u32 = 0xF1; +pub const CUE_RELATIVE_POSITION: u32 = 0xF0; +pub const CUE_DURATION: u32 = 0xB2; +pub const CUE_BLOCK_NUMBER: u32 = 0x5378; + +// Attachments +pub const ATTACHED_FILE: u32 = 0x61A7; +pub const FILE_DESCRIPTION: u32 = 0x467E; +pub const FILE_NAME: u32 = 0x466E; +pub const FILE_MEDIA_TYPE: u32 = 0x4660; +pub const FILE_DATA: u32 = 0x465C; +pub const FILE_UID: u32 = 0x46AE; + +// Chapters +pub const EDITION_ENTRY: u32 = 0x45B9; +pub const EDITION_UID: u32 = 0x45BC; +pub const EDITION_FLAG_HIDDEN: u32 = 0x45BD; +pub const EDITION_FLAG_DEFAULT: u32 = 0x45DB; +pub const EDITION_FLAG_ORDERED: u32 = 0x45DD; +pub const CHAPTER_ATOM: u32 = 0xB6; +pub const CHAPTER_UID: u32 = 0x73C4; +pub const CHAPTER_STRING_UID: u32 = 0x5654; +pub const CHAPTER_TIME_START: u32 = 0x91; +pub const CHAPTER_TIME_END: u32 = 0x92; +pub const CHAPTER_FLAG_HIDDEN: u32 = 0x98; +pub const CHAPTER_FLAG_ENABLED: u32 = 0x4598; +pub const CHAPTER_DISPLAY: u32 = 0x80; +pub const CHAP_STRING: u32 = 0x85; +pub const CHAP_LANGUAGE: u32 = 0x437C; +pub const CHAP_LANGUAGE_BCP47: u32 = 0x437D; +pub const CHAP_COUNTRY: u32 = 0x437E; + +// Tags +pub const TAG: u32 = 0x7373; +pub const TARGETS: u32 = 0x63C0; +pub const TARGET_TYPE_VALUE: u32 = 0x68CA; +pub const TARGET_TYPE: u32 = 0x63CA; +pub const TAG_TRACK_UID: u32 = 0x63C5; +pub const TAG_EDITION_UID: u32 = 0x63C9; +pub const TAG_CHAPTER_UID: u32 = 0x63C4; +pub const TAG_ATTACHMENT_UID: u32 = 0x63C6; +pub const SIMPLE_TAG: u32 = 0x67C8; +pub const TAG_NAME: u32 = 0x45A3; +pub const TAG_LANGUAGE: u32 = 0x447A; +pub const TAG_LANGUAGE_BCP47: u32 = 0x447B; +pub const TAG_DEFAULT: u32 = 0x4484; +pub const TAG_STRING: u32 = 0x4487; +pub const TAG_BINARY: u32 = 0x4485; diff --git a/crates/matroska/src/lib.rs b/crates/matroska/src/lib.rs new file mode 100644 index 000000000..2e280200e --- /dev/null +++ b/crates/matroska/src/lib.rs @@ -0,0 +1,36 @@ +//! Clean-room Matroska (MKV) and WebM demuxer, written from RFC 8794 (EBML), RFC 9559 (Matroska) +//! and the WebM container guidelines. +//! +//! - [`open`] / [`open_with`] parse the EBML header and the first Segment (Info, Tracks, SeekHead, +//! Cues, Chapters, Tags, Attachments) and, by default, scan every Cluster's block headers to build +//! per-track [`Sample`] tables — the same shape as `filmcraft-isobmff`, so callers can treat both +//! containers alike. Frame data is read on demand with [`MkvFile::read_sample`]. +//! - [`Demuxer`] iterates [`Packet`]s in file order (from any [`ByteSource`], `Read + Seek`, or a +//! byte slice) and seeks to the keyframe preceding a time, via the index, Cues, or a cluster scan. +//! +//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. + +mod codec; +mod demux; +pub mod ebml; +mod error; +mod ids; +mod meta; +mod mux; +mod source; +#[cfg(test)] +mod synthetic_tests; +mod track; + +pub use codec::Codec; +pub use demux::{Demuxer, Keyframe, MkvFile, OpenOptions, Packet, SeekPoint, open, open_with}; +pub use error::{Error, Result}; +pub use meta::{Attachment, Chapter, ChapterDisplay, ClusterInfo, CuePoint, CuePosition, Edition, SeekEntry, SegmentInfo, SimpleTag, Tag, TagTargets}; +pub use mux::{LacingMode, MkvWriter, MuxOptions, TrackSpec}; +pub use source::{ByteSource, ReadSeekSource}; +pub use track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Sample, Track, TrackKind, VideoInfo}; + +/// Element IDs used by the demuxer (marker bits included), for callers inspecting raw elements. +pub mod element_ids { + pub use crate::ids::*; +} diff --git a/crates/matroska/src/meta.rs b/crates/matroska/src/meta.rs new file mode 100644 index 000000000..adac699f8 --- /dev/null +++ b/crates/matroska/src/meta.rs @@ -0,0 +1,526 @@ +//! Segment-level metadata: Info, SeekHead, Cues, Chapters, Tags, Attachments — types and parsers +//! for their in-memory element data. + +use crate::codec::map_codec; +use crate::ebml::{Children, float, int, string, uint}; +use crate::ids::*; +use crate::track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Track, TrackKind, VideoInfo, gcd}; + +/// `Info` element. +#[derive(Clone, Debug, PartialEq)] +pub struct SegmentInfo { + /// Nanoseconds per timestamp tick (default 1 000 000 = 1 ms). + pub timestamp_scale: u64, + /// `Duration` in timestamp ticks (float), if present. + pub duration: Option, + pub title: Option, + pub muxing_app: String, + pub writing_app: String, + /// `DateUTC`: nanoseconds since 2001-01-01T00:00:00 UTC. + pub date_utc: Option, + pub segment_uuid: Option<[u8; 16]>, +} + +impl Default for SegmentInfo { + fn default() -> Self { + Self { + timestamp_scale: 1_000_000, + duration: None, + title: None, + muxing_app: String::new(), + writing_app: String::new(), + date_utc: None, + segment_uuid: None, + } + } +} + +/// One `SeekHead` entry: level-1 element ID and its position relative to the Segment data start. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SeekEntry { + pub id: u32, + pub position: u64, +} + +/// One `CueTrackPositions` of a `CuePoint`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CuePosition { + pub track: u64, + /// Cluster position relative to the Segment data start. + pub cluster_position: u64, + /// Block position relative to the Cluster data start. + pub relative_position: Option, + pub block_number: Option, + pub duration: Option, +} + +/// One `CuePoint`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CuePoint { + /// In timestamp ticks. + pub time: u64, + pub positions: Vec, +} + +/// A cluster found while scanning. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ClusterInfo { + /// Absolute offset of the Cluster element header. + pub offset: u64, + /// Absolute offset of the first child. + pub data_start: u64, + /// Absolute end offset (for unknown-size clusters, where the next top-level element starts). + pub end: u64, + /// Cluster `Timestamp` in ticks. + pub timestamp: u64, +} + +/// One `ChapterDisplay`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ChapterDisplay { + pub string: String, + pub languages: Vec, + pub languages_bcp47: Vec, + pub countries: Vec, +} + +/// `ChapterAtom` (possibly nested). +#[derive(Clone, Debug, PartialEq)] +pub struct Chapter { + pub uid: u64, + pub string_uid: Option, + pub start_ns: u64, + pub end_ns: Option, + pub hidden: bool, + pub enabled: bool, + pub displays: Vec, + pub children: Vec, +} + +impl Chapter { + /// First display string, if any. + pub fn title(&self) -> Option<&str> { + self.displays.first().map(|d| d.string.as_str()) + } +} + +/// `EditionEntry`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Edition { + pub uid: Option, + pub hidden: bool, + pub default: bool, + pub ordered: bool, + pub chapters: Vec, +} + +/// `Targets` of a `Tag`. Empty UID lists mean "the whole segment". +#[derive(Clone, Debug, PartialEq)] +pub struct TagTargets { + pub type_value: u64, + pub type_name: Option, + pub track_uids: Vec, + pub edition_uids: Vec, + pub chapter_uids: Vec, + pub attachment_uids: Vec, +} + +impl Default for TagTargets { + fn default() -> Self { + Self { type_value: 50, type_name: None, track_uids: Vec::new(), edition_uids: Vec::new(), chapter_uids: Vec::new(), attachment_uids: Vec::new() } + } +} + +/// `SimpleTag` (possibly nested). +#[derive(Clone, Debug, PartialEq)] +pub struct SimpleTag { + pub name: String, + pub language: String, + pub default: bool, + pub string: Option, + pub binary: Option>, + pub children: Vec, +} + +/// `Tag`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Tag { + pub targets: TagTargets, + pub simple: Vec, +} + +/// `AttachedFile` (listed only; the payload is located by `data_offset`/`data_size`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Attachment { + pub uid: u64, + pub name: String, + pub media_type: String, + pub description: Option, + pub data_offset: u64, + pub data_size: u64, +} + +pub(crate) fn parse_info(d: &[u8]) -> SegmentInfo { + let mut info = SegmentInfo::default(); + for e in Children::new(d) { + match e.id { + TIMESTAMP_SCALE => { + let v = uint(e.data); + if v > 0 { + info.timestamp_scale = v; + } + } + DURATION => info.duration = Some(float(e.data)).filter(|v| v.is_finite() && *v >= 0.0), + TITLE => info.title = Some(string(e.data)), + MUXING_APP => info.muxing_app = string(e.data), + WRITING_APP => info.writing_app = string(e.data), + DATE_UTC => info.date_utc = Some(int(e.data)), + SEGMENT_UUID => info.segment_uuid = e.data.try_into().ok(), + _ => {} + } + } + info +} + +pub(crate) fn parse_seekhead(d: &[u8]) -> Vec { + let mut out = Vec::new(); + for s in Children::new(d).filter(|e| e.id == SEEK) { + let (mut id, mut pos) = (None, None); + for e in Children::new(s.data) { + match e.id { + SEEK_ID => id = Some(uint(e.data) as u32), + SEEK_POSITION => pos = Some(uint(e.data)), + _ => {} + } + } + if let (Some(id), Some(position)) = (id, pos) { + out.push(SeekEntry { id, position }); + } + } + out +} + +pub(crate) fn parse_cues(d: &[u8], out: &mut Vec) { + for cp in Children::new(d).filter(|e| e.id == CUE_POINT) { + let mut time = None; + let mut positions = Vec::new(); + for e in Children::new(cp.data) { + match e.id { + CUE_TIME => time = Some(uint(e.data)), + CUE_TRACK_POSITIONS => { + let mut p = CuePosition { track: 0, cluster_position: u64::MAX, relative_position: None, block_number: None, duration: None }; + for c in Children::new(e.data) { + match c.id { + CUE_TRACK => p.track = uint(c.data), + CUE_CLUSTER_POSITION => p.cluster_position = uint(c.data), + CUE_RELATIVE_POSITION => p.relative_position = Some(uint(c.data)), + CUE_BLOCK_NUMBER => p.block_number = Some(uint(c.data)), + CUE_DURATION => p.duration = Some(uint(c.data)), + _ => {} + } + } + if p.track != 0 && p.cluster_position != u64::MAX { + positions.push(p); + } + } + _ => {} + } + } + if let Some(time) = time + && !positions.is_empty() + { + out.push(CuePoint { time, positions }); + } + } +} + +fn parse_chapter(d: &[u8]) -> Chapter { + let mut c = Chapter { uid: 0, string_uid: None, start_ns: 0, end_ns: None, hidden: false, enabled: true, displays: Vec::new(), children: Vec::new() }; + for e in Children::new(d) { + match e.id { + CHAPTER_UID => c.uid = uint(e.data), + CHAPTER_STRING_UID => c.string_uid = Some(string(e.data)), + CHAPTER_TIME_START => c.start_ns = uint(e.data), + CHAPTER_TIME_END => c.end_ns = Some(uint(e.data)), + CHAPTER_FLAG_HIDDEN => c.hidden = uint(e.data) != 0, + CHAPTER_FLAG_ENABLED => c.enabled = uint(e.data) != 0, + CHAPTER_DISPLAY => { + let mut disp = ChapterDisplay::default(); + for x in Children::new(e.data) { + match x.id { + CHAP_STRING => disp.string = string(x.data), + CHAP_LANGUAGE => disp.languages.push(string(x.data)), + CHAP_LANGUAGE_BCP47 => disp.languages_bcp47.push(string(x.data)), + CHAP_COUNTRY => disp.countries.push(string(x.data)), + _ => {} + } + } + if disp.languages.is_empty() { + disp.languages.push("eng".into()); + } + c.displays.push(disp); + } + CHAPTER_ATOM => c.children.push(parse_chapter(e.data)), + _ => {} + } + } + c +} + +pub(crate) fn parse_chapters(d: &[u8], out: &mut Vec) { + for ed in Children::new(d).filter(|e| e.id == EDITION_ENTRY) { + let mut edition = Edition::default(); + for e in Children::new(ed.data) { + match e.id { + EDITION_UID => edition.uid = Some(uint(e.data)), + EDITION_FLAG_HIDDEN => edition.hidden = uint(e.data) != 0, + EDITION_FLAG_DEFAULT => edition.default = uint(e.data) != 0, + EDITION_FLAG_ORDERED => edition.ordered = uint(e.data) != 0, + CHAPTER_ATOM => edition.chapters.push(parse_chapter(e.data)), + _ => {} + } + } + out.push(edition); + } +} + +fn parse_simple_tag(d: &[u8]) -> SimpleTag { + let mut t = SimpleTag { name: String::new(), language: "und".into(), default: true, string: None, binary: None, children: Vec::new() }; + for e in Children::new(d) { + match e.id { + TAG_NAME => t.name = string(e.data), + TAG_LANGUAGE => t.language = string(e.data), + TAG_DEFAULT => t.default = uint(e.data) != 0, + TAG_STRING => t.string = Some(string(e.data)), + TAG_BINARY => t.binary = Some(e.data.to_vec()), + SIMPLE_TAG => t.children.push(parse_simple_tag(e.data)), + _ => {} + } + } + t +} + +pub(crate) fn parse_tags(d: &[u8], out: &mut Vec) { + for tg in Children::new(d).filter(|e| e.id == TAG) { + let mut tag = Tag::default(); + for e in Children::new(tg.data) { + match e.id { + TARGETS => { + for x in Children::new(e.data) { + match x.id { + TARGET_TYPE_VALUE => tag.targets.type_value = uint(x.data), + TARGET_TYPE => tag.targets.type_name = Some(string(x.data)), + TAG_TRACK_UID => tag.targets.track_uids.push(uint(x.data)), + TAG_EDITION_UID => tag.targets.edition_uids.push(uint(x.data)), + TAG_CHAPTER_UID => tag.targets.chapter_uids.push(uint(x.data)), + TAG_ATTACHMENT_UID => tag.targets.attachment_uids.push(uint(x.data)), + _ => {} + } + } + } + SIMPLE_TAG => tag.simple.push(parse_simple_tag(e.data)), + _ => {} + } + } + out.push(tag); + } +} + +fn parse_colour(d: &[u8]) -> Colour { + let mut c = Colour::default(); + for e in Children::new(d) { + let v = || Some(uint(e.data)); + match e.id { + MATRIX_COEFFICIENTS => c.matrix_coefficients = v(), + BITS_PER_CHANNEL => c.bits_per_channel = v(), + CHROMA_SUBSAMPLING_HORZ => c.chroma_subsampling_horz = v(), + CHROMA_SUBSAMPLING_VERT => c.chroma_subsampling_vert = v(), + CB_SUBSAMPLING_HORZ => c.cb_subsampling_horz = v(), + CB_SUBSAMPLING_VERT => c.cb_subsampling_vert = v(), + CHROMA_SITING_HORZ => c.chroma_siting_horz = v(), + CHROMA_SITING_VERT => c.chroma_siting_vert = v(), + RANGE => c.range = v(), + TRANSFER_CHARACTERISTICS => c.transfer_characteristics = v(), + PRIMARIES => c.primaries = v(), + MAX_CLL => c.max_cll = v(), + MAX_FALL => c.max_fall = v(), + MASTERING_METADATA => { + let mut m = MasteringMetadata::default(); + for x in Children::new(e.data) { + let f = float(x.data); + match x.id { + PRIMARY_R_X => m.primaries[0].0 = f, + PRIMARY_R_Y => m.primaries[0].1 = f, + PRIMARY_G_X => m.primaries[1].0 = f, + PRIMARY_G_Y => m.primaries[1].1 = f, + PRIMARY_B_X => m.primaries[2].0 = f, + PRIMARY_B_Y => m.primaries[2].1 = f, + WHITE_POINT_X => m.white_point.0 = f, + WHITE_POINT_Y => m.white_point.1 = f, + LUMINANCE_MAX => m.luminance_max = f, + LUMINANCE_MIN => m.luminance_min = f, + _ => {} + } + } + c.mastering = Some(m); + } + _ => {} + } + } + c +} + +fn parse_video(d: &[u8]) -> VideoInfo { + let mut v = VideoInfo::default(); + for e in Children::new(d) { + let u = uint(e.data); + match e.id { + PIXEL_WIDTH => v.pixel_width = u as u32, + PIXEL_HEIGHT => v.pixel_height = u as u32, + DISPLAY_WIDTH => v.display_width = Some(u as u32), + DISPLAY_HEIGHT => v.display_height = Some(u as u32), + DISPLAY_UNIT => v.display_unit = u, + PIXEL_CROP_TOP => v.crop.0 = u as u32, + PIXEL_CROP_BOTTOM => v.crop.1 = u as u32, + PIXEL_CROP_LEFT => v.crop.2 = u as u32, + PIXEL_CROP_RIGHT => v.crop.3 = u as u32, + FLAG_INTERLACED => v.flag_interlaced = u, + FIELD_ORDER => v.field_order = Some(u), + STEREO_MODE => v.stereo_mode = Some(u), + ALPHA_MODE => v.alpha_mode = u, + COLOUR_SPACE => v.colour_space = e.data.try_into().ok(), + COLOUR => v.colour = Some(parse_colour(e.data)), + _ => {} + } + } + v +} + +fn parse_audio(d: &[u8]) -> AudioInfo { + let mut a = AudioInfo::default(); + for e in Children::new(d) { + match e.id { + SAMPLING_FREQUENCY => a.sampling_frequency = float(e.data), + OUTPUT_SAMPLING_FREQUENCY => a.output_sampling_frequency = Some(float(e.data)), + CHANNELS => a.channels = uint(e.data), + BIT_DEPTH => a.bit_depth = Some(uint(e.data)), + _ => {} + } + } + a +} + +fn parse_encodings(d: &[u8]) -> Vec { + let mut list: Vec<(u64, ContentEncoding)> = Vec::new(); + for ce in Children::new(d).filter(|e| e.id == CONTENT_ENCODING) { + let (mut order, mut scope, mut ty) = (0u64, 1u64, 0u64); + let mut comp: Option<(u64, Vec)> = None; + for e in Children::new(ce.data) { + match e.id { + CONTENT_ENCODING_ORDER => order = uint(e.data), + CONTENT_ENCODING_SCOPE => scope = uint(e.data), + CONTENT_ENCODING_TYPE => ty = uint(e.data), + CONTENT_COMPRESSION => { + let (mut algo, mut settings) = (0u64, Vec::new()); + for x in Children::new(e.data) { + match x.id { + CONTENT_COMP_ALGO => algo = uint(x.data), + CONTENT_COMP_SETTINGS => settings = x.data.to_vec(), + _ => {} + } + } + comp = Some((algo, settings)); + } + _ => {} + } + } + let enc = if ty == 1 { + ContentEncoding::Encryption { scope } + } else { + let (algo, settings) = comp.unwrap_or((0, Vec::new())); + if algo == 3 { ContentEncoding::HeaderStripping { bytes: settings, scope } } else { ContentEncoding::Compression { algo, settings, scope } } + }; + list.push((order, enc)); + } + // highest order is applied last on muxing → first to undo; keep decode order + list.sort_by_key(|e| std::cmp::Reverse(e.0)); + list.into_iter().map(|e| e.1).collect() +} + +pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec { + let g = gcd(timestamp_scale, 1_000_000_000); + let timebase = (timestamp_scale / g, 1_000_000_000 / g); + let mut out = Vec::new(); + for te in Children::new(d).filter(|e| e.id == TRACK_ENTRY) { + let mut t = Track { + number: 0, + uid: 0, + kind: TrackKind::Other(0), + enabled: true, + default: true, + forced: false, + hearing_impaired: false, + lacing: true, + default_duration_ns: None, + name: String::new(), + language: "eng".into(), + language_bcp47: None, + codec_id: String::new(), + codec_private: Vec::new(), + codec_name: String::new(), + codec: crate::Codec::Other(String::new()), + codec_delay_ns: 0, + seek_pre_roll_ns: 0, + video: None, + audio: None, + encodings: Vec::new(), + timebase, + samples: Vec::new(), + sync: Vec::new(), + }; + for e in Children::new(te.data) { + match e.id { + TRACK_NUMBER => t.number = uint(e.data), + TRACK_UID => t.uid = uint(e.data), + TRACK_TYPE => t.kind = TrackKind::from_type(uint(e.data)), + FLAG_ENABLED => t.enabled = uint(e.data) != 0, + FLAG_DEFAULT => t.default = uint(e.data) != 0, + FLAG_FORCED => t.forced = uint(e.data) != 0, + FLAG_HEARING_IMPAIRED => t.hearing_impaired = uint(e.data) != 0, + FLAG_LACING => t.lacing = uint(e.data) != 0, + DEFAULT_DURATION => t.default_duration_ns = Some(uint(e.data)).filter(|&v| v > 0), + NAME => t.name = string(e.data), + LANGUAGE => t.language = string(e.data), + LANGUAGE_BCP47 => t.language_bcp47 = Some(string(e.data)), + CODEC_ID => t.codec_id = string(e.data), + CODEC_PRIVATE => t.codec_private = e.data.to_vec(), + CODEC_NAME => t.codec_name = string(e.data), + CODEC_DELAY => t.codec_delay_ns = uint(e.data), + SEEK_PRE_ROLL => t.seek_pre_roll_ns = uint(e.data), + VIDEO => t.video = Some(parse_video(e.data)), + AUDIO => t.audio = Some(parse_audio(e.data)), + CONTENT_ENCODINGS => t.encodings = parse_encodings(e.data), + _ => {} + } + } + if t.number == 0 { + continue; + } + if t.kind == TrackKind::Audio && t.audio.is_none() { + t.audio = Some(AudioInfo::default()); + } + // CodecPrivate may itself be header-stripped (scope bit 2 = private data) + for enc in &t.encodings { + if let ContentEncoding::HeaderStripping { bytes, scope } = enc + && scope & 2 != 0 + { + let mut p = bytes.clone(); + p.extend_from_slice(&t.codec_private); + t.codec_private = p; + } + } + t.codec = map_codec(&t.codec_id, &t.codec_private, t.audio.as_ref()); + out.push(t); + } + out +} diff --git a/crates/matroska/src/mux.rs b/crates/matroska/src/mux.rs new file mode 100644 index 000000000..f809b1beb --- /dev/null +++ b/crates/matroska/src/mux.rs @@ -0,0 +1,539 @@ +//! Minimal Matroska / WebM muxer: SimpleBlocks (optionally laced), BlockGroups for explicit +//! durations, Cues, SeekHead, Info with Duration. Output must be `Write + Seek` (sizes and the +//! SeekHead are back-patched on [`MkvWriter::finish`]). + +use std::io::{Seek, SeekFrom, Write}; + +use crate::ebml::{write_id, write_size, write_unknown_size}; +use crate::error::{Error, Result}; +use crate::ids::*; +use crate::track::TrackKind; + +/// Lacing used for audio tracks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LacingMode { + None, + Xiph, + Ebml, + /// Fixed-size lacing when consecutive frames have equal sizes, otherwise EBML lacing. + FixedOrEbml, +} + +/// Track description for [`MkvWriter`]. +#[derive(Clone, Debug)] +pub struct TrackSpec { + pub kind: TrackKind, + pub codec_id: String, + pub codec_private: Vec, + pub default_duration_ns: Option, + pub language: Option, + pub name: Option, + /// Pixel width/height for video tracks. + pub video_size: Option<(u32, u32)>, + /// (sampling frequency, channels, bit depth) for audio tracks. + pub audio: Option<(f64, u64, Option)>, + pub codec_delay_ns: u64, + pub seek_pre_roll_ns: u64, +} + +impl TrackSpec { + pub fn new(kind: TrackKind, codec_id: impl Into) -> Self { + Self { + kind, + codec_id: codec_id.into(), + codec_private: Vec::new(), + default_duration_ns: None, + language: None, + name: None, + video_size: None, + audio: None, + codec_delay_ns: 0, + seek_pre_roll_ns: 0, + } + } +} + +/// Muxer options. +#[derive(Clone, Debug)] +pub struct MuxOptions { + /// `webm` or `matroska`. + pub doc_type: String, + /// Nanoseconds per tick (default 1 000 000). + pub timestamp_scale: u64, + /// Start a new cluster at the next video keyframe (or any frame without video) after this long. + pub cluster_duration_ns: u64, + pub lacing: LacingMode, + /// Maximum frames per laced block. + pub max_lace_frames: usize, + pub title: Option, + pub writing_app: String, +} + +impl Default for MuxOptions { + fn default() -> Self { + Self { + doc_type: "matroska".into(), + timestamp_scale: 1_000_000, + cluster_duration_ns: 1_000_000_000, + lacing: LacingMode::None, + max_lace_frames: 8, + title: None, + writing_app: "filmcraft".into(), + } + } +} + +pub(crate) fn el(out: &mut Vec, id: u32, data: &[u8]) { + write_id(out, id); + write_size(out, data.len() as u64, 1); + out.extend_from_slice(data); +} + +pub(crate) fn el_uint(out: &mut Vec, id: u32, v: u64) { + let n = (8 - v.leading_zeros() as usize / 8).max(1); + el(out, id, &v.to_be_bytes()[8 - n..]); +} + +pub(crate) fn el_float(out: &mut Vec, id: u32, v: f64) { + el(out, id, &v.to_be_bytes()); +} + +pub(crate) fn el_str(out: &mut Vec, id: u32, s: &str) { + el(out, id, s.as_bytes()); +} + +fn void(out: &mut Vec, total: usize) { + debug_assert!(total >= 2); + let sl = if total - 2 <= 126 { 1 } else { 8 }; + write_id(out, VOID); + write_size(out, (total - 1 - sl) as u64, sl); + out.resize(out.len() + total - 1 - sl, 0); +} + +#[derive(Clone, Debug)] +struct Pending { + track: usize, + ts: i64, + frames: Vec>, +} + +const SEEK_HEAD_SPACE: usize = 128; + +/// Streaming Matroska/WebM writer. +pub struct MkvWriter { + w: W, + opts: MuxOptions, + tracks: Vec, + segment_size_pos: u64, + segment_data_start: u64, + seek_head_pos: u64, + duration_pos: u64, + info_pos: u64, + tracks_pos: u64, + cluster: Vec, + cluster_ts: Option, + cluster_start_ns: i64, + pending: Option, + cues: Vec<(u64, u64, u64)>, // (time ticks, track number, cluster position) + cued_tracks_in_cluster: Vec, + end_ns: i64, + has_video: bool, +} + +impl MkvWriter { + pub fn new(mut w: W, tracks: Vec, opts: MuxOptions) -> Result { + if tracks.is_empty() || opts.timestamp_scale == 0 { + return Err(Error::Invalid("muxer needs at least one track and a non-zero timestamp scale".into())); + } + let mut out = Vec::new(); + let mut h = Vec::new(); + el_uint(&mut h, EBML_VERSION, 1); + el_uint(&mut h, EBML_READ_VERSION, 1); + el_uint(&mut h, EBML_MAX_ID_LENGTH, 4); + el_uint(&mut h, EBML_MAX_SIZE_LENGTH, 8); + el_str(&mut h, DOC_TYPE, &opts.doc_type); + el_uint(&mut h, DOC_TYPE_VERSION, 4); + el_uint(&mut h, DOC_TYPE_READ_VERSION, 2); + el(&mut out, EBML, &h); + write_id(&mut out, SEGMENT); + let base = w.stream_position()?; + let segment_size_pos = base + out.len() as u64; + write_unknown_size(&mut out, 8); + let segment_data_start = base + out.len() as u64; + let seek_head_pos = segment_data_start; + void(&mut out, SEEK_HEAD_SPACE); + // Info + let info_pos = base + out.len() as u64; + let mut info = Vec::new(); + el_uint(&mut info, TIMESTAMP_SCALE, opts.timestamp_scale); + el_str(&mut info, MUXING_APP, "filmcraft-matroska"); + el_str(&mut info, WRITING_APP, &opts.writing_app); + if let Some(t) = &opts.title { + el_str(&mut info, TITLE, t); + } + let dur_off_in_info = info.len() + 2 + 1; // ID (2) + size (1) + el_float(&mut info, DURATION, 0.0); + write_id(&mut out, INFO); + write_size(&mut out, info.len() as u64, 1); + let duration_pos = base + out.len() as u64 + dur_off_in_info as u64; + out.extend_from_slice(&info); + // Tracks + let tracks_pos = base + out.len() as u64; + let mut tr = Vec::new(); + for (i, t) in tracks.iter().enumerate() { + let mut e = Vec::new(); + el_uint(&mut e, TRACK_NUMBER, i as u64 + 1); + el_uint(&mut e, TRACK_UID, 0x1000 + i as u64 + 1); + let ty = match t.kind { + TrackKind::Video => 1, + TrackKind::Audio => 2, + TrackKind::Subtitle => 0x11, + TrackKind::Complex => 3, + TrackKind::Logo => 0x10, + TrackKind::Buttons => 0x12, + TrackKind::Control => 0x20, + TrackKind::Metadata => 0x21, + TrackKind::Other(n) => n as u64, + }; + el_uint(&mut e, TRACK_TYPE, ty); + el_uint(&mut e, FLAG_LACING, (t.kind == TrackKind::Audio && opts.lacing != LacingMode::None) as u64); + if let Some(l) = &t.language { + el_str(&mut e, LANGUAGE, l); + } + if let Some(n) = &t.name { + el_str(&mut e, NAME, n); + } + el_str(&mut e, CODEC_ID, &t.codec_id); + if !t.codec_private.is_empty() { + el(&mut e, CODEC_PRIVATE, &t.codec_private); + } + if let Some(d) = t.default_duration_ns { + el_uint(&mut e, DEFAULT_DURATION, d); + } + if t.codec_delay_ns > 0 { + el_uint(&mut e, CODEC_DELAY, t.codec_delay_ns); + } + if t.seek_pre_roll_ns > 0 { + el_uint(&mut e, SEEK_PRE_ROLL, t.seek_pre_roll_ns); + } + if let Some((pw, ph)) = t.video_size { + let mut v = Vec::new(); + el_uint(&mut v, PIXEL_WIDTH, pw as u64); + el_uint(&mut v, PIXEL_HEIGHT, ph as u64); + el(&mut e, VIDEO, &v); + } + if let Some((rate, ch, bits)) = t.audio { + let mut a = Vec::new(); + el_float(&mut a, SAMPLING_FREQUENCY, rate); + el_uint(&mut a, CHANNELS, ch); + if let Some(b) = bits { + el_uint(&mut a, BIT_DEPTH, b); + } + el(&mut e, AUDIO, &a); + } + el(&mut tr, TRACK_ENTRY, &e); + } + el(&mut out, TRACKS, &tr); + w.write_all(&out)?; + let has_video = tracks.iter().any(|t| t.kind == TrackKind::Video); + let n = tracks.len(); + Ok(Self { + w, + opts, + tracks, + segment_size_pos, + segment_data_start, + seek_head_pos, + duration_pos, + info_pos, + tracks_pos, + cluster: Vec::new(), + cluster_ts: None, + cluster_start_ns: 0, + pending: None, + cues: Vec::new(), + cued_tracks_in_cluster: vec![false; n], + end_ns: 0, + has_video, + }) + } + + /// Add one frame. `pts_ns` is the presentation time (codec delay is added back for storage); + /// frames must be supplied in the desired file order. `duration_ns` forces a BlockGroup with a + /// BlockDuration when it differs from the track's DefaultDuration. + pub fn write_frame(&mut self, track: usize, pts_ns: i64, keyframe: bool, data: &[u8], duration_ns: Option) -> Result<()> { + let spec = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?; + // the Matroska ProRes mapping stores frames without the 8-byte `size`+`icpf` header + let data = if spec.codec_id == "V_PRORES" && data.get(4..8) == Some(b"icpf") { &data[8..] } else { data }; + let scale = self.opts.timestamp_scale as i64; + let stored_ns = pts_ns + spec.codec_delay_ns as i64; + if stored_ns < 0 { + return Err(Error::Invalid("negative timestamp".into())); + } + let ts = (stored_ns + scale / 2) / scale; + let dur_end = pts_ns + duration_ns.or(spec.default_duration_ns).unwrap_or(0) as i64; + self.end_ns = self.end_ns.max(dur_end).max(pts_ns); + let is_video = spec.kind == TrackKind::Video; + let lacing = spec.kind == TrackKind::Audio && self.opts.lacing != LacingMode::None; + let explicit_dur = duration_ns.filter(|&d| Some(d) != spec.default_duration_ns); + // new cluster? + let start_new = match self.cluster_ts { + None => true, + Some(cts) => { + let long = stored_ns - self.cluster_start_ns >= self.opts.cluster_duration_ns as i64; + (long && (!self.has_video || (is_video && keyframe))) || ts - cts > 30_000 || ts < cts + } + }; + if start_new { + self.flush_cluster()?; + self.cluster_ts = Some(ts); + self.cluster_start_ns = stored_ns; + el_uint(&mut self.cluster, TIMESTAMP, ts as u64); + } + let cts = self.cluster_ts.unwrap_or(0); + // cue: video keyframes, or the first frame of each track per cluster when there is no video + if (is_video && keyframe) || (!self.has_video && !self.cued_tracks_in_cluster[track]) { + self.cued_tracks_in_cluster[track] = true; + let pos = self.w.stream_position()? - self.segment_data_start; + if self.cues.last().is_none_or(|c| c.2 != pos || c.1 != track as u64 + 1) { + self.cues.push((ts as u64, track as u64 + 1, pos)); + } + } + if lacing && explicit_dur.is_none() { + let same = self.pending.as_ref().is_some_and(|p| p.track == track && p.frames.len() < self.opts.max_lace_frames); + if !same { + self.flush_pending(); + } + match &mut self.pending { + Some(p) => p.frames.push(data.to_vec()), + None => self.pending = Some(Pending { track, ts: ts - cts, frames: vec![data.to_vec()] }), + } + return Ok(()); + } + self.flush_pending(); + let mut block = Vec::new(); + write_size(&mut block, track as u64 + 1, 1); + block.extend_from_slice(&((ts - cts) as i16).to_be_bytes()); + if let Some(d) = explicit_dur { + block.push(0); + block.extend_from_slice(data); + let mut g = Vec::new(); + el(&mut g, BLOCK, &block); + el_uint(&mut g, BLOCK_DURATION, (d as i64 / scale) as u64); + if !keyframe { + el(&mut g, REFERENCE_BLOCK, &[0xFF]); + } + el(&mut self.cluster, BLOCK_GROUP, &g); + } else { + block.push(if keyframe { 0x80 } else { 0 }); + block.extend_from_slice(data); + el(&mut self.cluster, SIMPLE_BLOCK, &block); + } + Ok(()) + } + + fn flush_pending(&mut self) { + let Some(p) = self.pending.take() else { return }; + let mut block = Vec::new(); + write_size(&mut block, p.track as u64 + 1, 1); + block.extend_from_slice(&(p.ts as i16).to_be_bytes()); + let n = p.frames.len(); + let mode = if n == 1 { LacingMode::None } else { self.opts.lacing }; + let fixed = p.frames.iter().all(|f| f.len() == p.frames[0].len()); + match mode { + LacingMode::None => block.push(0x80), + LacingMode::Xiph => { + block.push(0x80 | 0x02); + block.push((n - 1) as u8); + for f in &p.frames[..n - 1] { + let mut s = f.len(); + while s >= 255 { + block.push(255); + s -= 255; + } + block.push(s as u8); + } + } + LacingMode::FixedOrEbml if fixed => { + block.push(0x80 | 0x04); + block.push((n - 1) as u8); + } + LacingMode::Ebml | LacingMode::FixedOrEbml => { + block.push(0x80 | 0x06); + block.push((n - 1) as u8); + write_size(&mut block, p.frames[0].len() as u64, 1); + for i in 1..n - 1 { + let d = p.frames[i].len() as i64 - p.frames[i - 1].len() as i64; + // signed VINT: smallest length whose range holds d + let mut len = 1; + while len < 8 && d.unsigned_abs() >= (1u64 << (7 * len - 1)) - 1 { + len += 1; + } + let v = (d + (1i64 << (7 * len - 1)) - 1) as u64 | (1u64 << (7 * len)); + block.extend_from_slice(&v.to_be_bytes()[8 - len..]); + } + } + } + for f in &p.frames { + block.extend_from_slice(f); + } + el(&mut self.cluster, SIMPLE_BLOCK, &block); + } + + fn flush_cluster(&mut self) -> Result<()> { + self.flush_pending(); + if self.cluster_ts.is_some() { + let mut out = Vec::new(); + el(&mut out, CLUSTER, &self.cluster); + self.w.write_all(&out)?; + } + self.cluster.clear(); + self.cluster_ts = None; + self.cued_tracks_in_cluster.iter_mut().for_each(|c| *c = false); + Ok(()) + } + + /// Write Cues, back-patch SeekHead / Segment size / Duration, and return the writer. + pub fn finish(mut self) -> Result { + self.flush_cluster()?; + let cues_pos = self.w.stream_position()?; + let mut cues = Vec::new(); + for &(time, track, pos) in &self.cues { + let mut ctp = Vec::new(); + el_uint(&mut ctp, CUE_TRACK, track); + el_uint(&mut ctp, CUE_CLUSTER_POSITION, pos); + let mut cp = Vec::new(); + el_uint(&mut cp, CUE_TIME, time); + el(&mut cp, CUE_TRACK_POSITIONS, &ctp); + el(&mut cues, CUE_POINT, &cp); + } + let has_cues = !cues.is_empty(); + if has_cues { + let mut out = Vec::new(); + el(&mut out, CUES, &cues); + self.w.write_all(&out)?; + } + let end = self.w.stream_position()?; + // SeekHead + let mut sh = Vec::new(); + let mut entries = vec![(INFO, self.info_pos), (TRACKS, self.tracks_pos)]; + if has_cues { + entries.push((CUES, cues_pos)); + } + for (id, pos) in entries { + let mut s = Vec::new(); + let mut idb = Vec::new(); + write_id(&mut idb, id); + el(&mut s, SEEK_ID, &idb); + el_uint(&mut s, SEEK_POSITION, pos - self.segment_data_start); + el(&mut sh, SEEK, &s); + } + let mut out = Vec::new(); + el(&mut out, SEEK_HEAD, &sh); + if out.len() + 2 > SEEK_HEAD_SPACE { + return Err(Error::Invalid("SeekHead does not fit its reserved space".into())); + } + let rest = SEEK_HEAD_SPACE - out.len(); + void(&mut out, rest); + self.w.seek(SeekFrom::Start(self.seek_head_pos))?; + self.w.write_all(&out)?; + // Duration + let dur = self.end_ns as f64 / self.opts.timestamp_scale as f64; + self.w.seek(SeekFrom::Start(self.duration_pos))?; + self.w.write_all(&dur.to_be_bytes())?; + // Segment size + let mut sz = Vec::new(); + write_size(&mut sz, end - self.segment_data_start, 8); + self.w.seek(SeekFrom::Start(self.segment_size_pos))?; + self.w.write_all(&sz)?; + self.w.seek(SeekFrom::Start(end))?; + self.w.flush()?; + Ok(self.w) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Demuxer, TrackKind}; + use std::io::Cursor; + + fn roundtrip(lacing: LacingMode) { + let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG"); + v.video_size = Some((16, 16)); + v.default_duration_ns = Some(40_000_000); + let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT"); + a.audio = Some((48000.0, 1, Some(16))); + a.default_duration_ns = Some(10_000_000); + let mut s = TrackSpec::new(TrackKind::Subtitle, "S_TEXT/UTF8"); + s.language = Some("fre".into()); + let opts = MuxOptions { lacing, ..Default::default() }; + let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![v, a, s], opts).unwrap(); + let mut expect = Vec::new(); + for i in 0..50i64 { + let vf = vec![i as u8; 100 + i as usize]; + m.write_frame(0, i * 40_000_000, i % 10 == 0, &vf, None).unwrap(); + expect.push((0usize, i * 40, vf, i % 10 == 0)); + for k in 0..4 { + let n = if lacing == LacingMode::FixedOrEbml { 960 } else { 900 + (i as usize * 7 + k * 13) % 300 }; + let af = vec![(i * 4 + k as i64) as u8; n]; + let t = i * 40 + k as i64 * 10; + m.write_frame(1, t * 1_000_000, true, &af, None).unwrap(); + expect.push((1, t, af, true)); + } + if i % 20 == 5 { + m.write_frame(2, i * 40_000_000, true, b"hello", Some(500_000_000)).unwrap(); + expect.push((2, i * 40, b"hello".to_vec(), true)); + } + } + let bytes = m.finish().unwrap().into_inner(); + let mut d = Demuxer::from_slice(&bytes).unwrap(); + assert!(d.file().warnings.is_empty(), "{:?}", d.file().warnings); + assert_eq!(d.tracks()[2].language, "fre"); + assert!(!d.file().cues.is_empty()); + assert_eq!(d.duration_ns(), Some(2_300_000_000)); + let got: Vec<_> = d.by_ref().map(|p| p.unwrap()).collect(); + assert_eq!(got.len(), expect.len()); + for (g, e) in got.iter().zip(&expect) { + assert_eq!((g.track, g.pts, &g.data, g.keyframe), (e.0, e.1, &e.2, e.3)); + } + let sub = got.iter().find(|p| p.track == 2).unwrap(); + assert_eq!(sub.duration, 500); + // the index agrees with the packet stream + let f = d.file(); + for (ti, t) in f.tracks.iter().enumerate() { + let pk: Vec<_> = got.iter().filter(|p| p.track == ti).collect(); + assert_eq!(t.samples.len(), pk.len()); + for (i, p) in pk.iter().enumerate() { + assert_eq!(f.read_sample(&bytes[..], ti, i).unwrap(), p.data); + assert_eq!(t.samples[i].pts, p.pts); + } + } + // seeking + let sp = d.seek(0, 1_230_000_000).unwrap(); + assert_eq!(sp.pts, 1200); + let p = d.next_packet().unwrap().unwrap(); + assert_eq!((p.track, p.pts, p.keyframe), (0, 1200, true)); + let sp = d.seek(1, 1_235_000_000).unwrap(); + assert_eq!(sp.pts, 1230); + let p = d.next_packet().unwrap().unwrap(); + assert_eq!((p.track, p.pts), (1, 1230)); + } + + #[test] + fn roundtrip_unlaced() { + roundtrip(LacingMode::None); + } + #[test] + fn roundtrip_xiph() { + roundtrip(LacingMode::Xiph); + } + #[test] + fn roundtrip_ebml() { + roundtrip(LacingMode::Ebml); + } + #[test] + fn roundtrip_fixed() { + roundtrip(LacingMode::FixedOrEbml); + } +} diff --git a/crates/matroska/src/source.rs b/crates/matroska/src/source.rs new file mode 100644 index 000000000..da10d89ff --- /dev/null +++ b/crates/matroska/src/source.rs @@ -0,0 +1,173 @@ +//! Random-access byte sources for the demuxer. + +use std::cell::RefCell; +use std::io::{self, Read, Seek, SeekFrom}; +use std::sync::Arc; + +/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). +/// +/// Same shape as `filmcraft_isobmff::ByteSource`, so one adapter can serve both demuxers. +pub trait ByteSource { + /// Total length in bytes. + fn len(&self) -> u64; + /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; + /// True if the source holds no bytes. + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +fn eof() -> io::Error { + io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") +} + +impl ByteSource for [u8] { + fn len(&self) -> u64 { + <[u8]>::len(self) as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + let start = usize::try_from(offset).map_err(|_| eof())?; + let end = start.checked_add(buf.len()).ok_or_else(eof)?; + let src = self.get(start..end).ok_or_else(eof)?; + buf.copy_from_slice(src); + Ok(()) + } +} + +impl ByteSource for Vec { + fn len(&self) -> u64 { + self.as_slice().len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.as_slice().read_at(offset, buf) + } +} + +impl ByteSource for &T { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} + +impl ByteSource for Arc { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} + +impl ByteSource for Box { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl ByteSource for std::fs::File { + fn len(&self) -> u64 { + self.metadata().map(|m| m.len()).unwrap_or(0) + } + #[cfg(unix)] + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) + } + #[cfg(windows)] + fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { + use std::os::windows::fs::FileExt; + while !buf.is_empty() { + let n = self.seek_read(buf, offset)?; + if n == 0 { + return Err(eof()); + } + offset += n as u64; + buf = &mut buf[n..]; + } + Ok(()) + } + #[cfg(not(any(unix, windows)))] + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + let mut f = self; + f.seek(SeekFrom::Start(offset))?; + f.read_exact(buf) + } +} + +/// Adapts any `Read + Seek` (e.g. `BufReader`, `Cursor>`) into a [`ByteSource`]. +/// +/// The length is taken once at construction; the stream position is tracked so sequential reads +/// do not issue redundant seeks. +pub struct ReadSeekSource { + inner: RefCell<(R, u64)>, + len: u64, +} + +impl ReadSeekSource { + /// Wrap a reader; seeks to the end once to learn the length. + pub fn new(mut r: R) -> io::Result { + let len = r.seek(SeekFrom::End(0))?; + Ok(Self { inner: RefCell::new((r, len)), len }) + } + /// Unwrap the reader. + pub fn into_inner(self) -> R { + self.inner.into_inner().0 + } +} + +impl ByteSource for ReadSeekSource { + fn len(&self) -> u64 { + self.len + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + if offset.checked_add(buf.len() as u64).is_none_or(|e| e > self.len) { + return Err(eof()); + } + let mut g = self.inner.borrow_mut(); + let (r, pos) = &mut *g; + if *pos != offset { + r.seek(SeekFrom::Start(offset))?; + *pos = offset; + } + let res = r.read_exact(buf); + match res { + Ok(()) => { + *pos = offset + buf.len() as u64; + Ok(()) + } + Err(e) => { + *pos = u64::MAX; // unknown; force a seek next time + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slice_and_reader_sources() { + let v = vec![1u8, 2, 3, 4]; + let mut b = [0u8; 2]; + v.read_at(1, &mut b).unwrap(); + assert_eq!(b, [2, 3]); + assert!(v.read_at(3, &mut b).is_err()); + assert!(v.read_at(u64::MAX, &mut b).is_err()); + let r = ReadSeekSource::new(io::Cursor::new(v.clone())).unwrap(); + assert_eq!(r.len(), 4); + r.read_at(2, &mut b).unwrap(); + assert_eq!(b, [3, 4]); + r.read_at(0, &mut b).unwrap(); + assert_eq!(b, [1, 2]); + assert!(r.read_at(3, &mut b).is_err()); + } +} diff --git a/crates/matroska/src/synthetic_tests.rs b/crates/matroska/src/synthetic_tests.rs new file mode 100644 index 000000000..e1c604c0b --- /dev/null +++ b/crates/matroska/src/synthetic_tests.rs @@ -0,0 +1,203 @@ +//! Hand-built files exercising features FFmpeg does not write: unknown-size Segment/Clusters, +//! header stripping, BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32. + +use crate::codec::Codec; +use crate::ebml::{self, write_id, write_unknown_size}; +use crate::ids::*; +use crate::mux::{el, el_float, el_str, el_uint}; +use crate::track::ContentEncoding; +use crate::{Demuxer, OpenOptions, Packet, open_with}; + +fn block(track: u8, rel: i16, flags: u8, data: &[u8]) -> Vec { + let mut b = vec![0x80 | track]; + b.extend_from_slice(&rel.to_be_bytes()); + b.push(flags); + b.extend_from_slice(data); + b +} + +fn synthetic() -> Vec { + let mut f = Vec::new(); + let mut h = Vec::new(); + el_str(&mut h, DOC_TYPE, "matroska"); + el_uint(&mut h, DOC_TYPE_VERSION, 4); + el(&mut f, EBML, &h); + write_id(&mut f, SEGMENT); + write_unknown_size(&mut f, 8); + // Info with a correct CRC-32 and a Void + let mut info = Vec::new(); + el_uint(&mut info, TIMESTAMP_SCALE, 1_000_000); + el_str(&mut info, TITLE, "Synthetic"); + el(&mut info, VOID, &[0; 3]); + let mut crc_info = Vec::new(); + el(&mut crc_info, CRC32, &ebml::crc32(&info).to_le_bytes()); + crc_info.extend_from_slice(&info); + el(&mut f, INFO, &crc_info); + // Tracks + let mut v = Vec::new(); + el_uint(&mut v, TRACK_NUMBER, 1); + el_uint(&mut v, TRACK_UID, 11); + el_uint(&mut v, TRACK_TYPE, 1); + el_str(&mut v, CODEC_ID, "V_MPEG4/ISO/AVC"); + el(&mut v, CODEC_PRIVATE, &[1, 0x64, 0, 0x1F]); + el_uint(&mut v, DEFAULT_DURATION, 40_000_000); + let mut mm = Vec::new(); + for (id, val) in [(PRIMARY_R_X, 0.708), (PRIMARY_R_Y, 0.292), (PRIMARY_G_X, 0.17), (PRIMARY_G_Y, 0.797), (PRIMARY_B_X, 0.131), (PRIMARY_B_Y, 0.046)] { + el_float(&mut mm, id, val); + } + el_float(&mut mm, WHITE_POINT_X, 0.3127); + el_float(&mut mm, WHITE_POINT_Y, 0.329); + el_float(&mut mm, LUMINANCE_MAX, 1000.0); + el_float(&mut mm, LUMINANCE_MIN, 0.0001); + let mut col = Vec::new(); + el_uint(&mut col, MATRIX_COEFFICIENTS, 9); + el_uint(&mut col, RANGE, 2); + el_uint(&mut col, TRANSFER_CHARACTERISTICS, 16); + el_uint(&mut col, PRIMARIES, 9); + el_uint(&mut col, MAX_CLL, 1000); + el_uint(&mut col, MAX_FALL, 400); + el(&mut col, MASTERING_METADATA, &mm); + let mut vid = Vec::new(); + el_uint(&mut vid, PIXEL_WIDTH, 1440); + el_uint(&mut vid, PIXEL_HEIGHT, 1080); + el_uint(&mut vid, DISPLAY_WIDTH, 1920); + el_uint(&mut vid, DISPLAY_HEIGHT, 1080); + el(&mut vid, COLOUR, &col); + el(&mut v, VIDEO, &vid); + // header stripping: every frame starts with 00 00 00 01 + let mut comp = Vec::new(); + el_uint(&mut comp, CONTENT_COMP_ALGO, 3); + el(&mut comp, CONTENT_COMP_SETTINGS, &[0, 0, 0, 1]); + let mut enc = Vec::new(); + el(&mut enc, CONTENT_COMPRESSION, &comp); + let mut encs = Vec::new(); + el(&mut encs, CONTENT_ENCODING, &enc); + el(&mut v, CONTENT_ENCODINGS, &encs); + let mut a = Vec::new(); + el_uint(&mut a, TRACK_NUMBER, 2); + el_uint(&mut a, TRACK_TYPE, 2); + el_str(&mut a, CODEC_ID, "A_AAC/MPEG4/LC/SBR"); + el_str(&mut a, LANGUAGE, "ger"); + let mut au = Vec::new(); + el_float(&mut au, SAMPLING_FREQUENCY, 24000.0); + el_float(&mut au, OUTPUT_SAMPLING_FREQUENCY, 48000.0); + el_uint(&mut au, CHANNELS, 2); + el(&mut a, AUDIO, &au); + let mut tr = Vec::new(); + el(&mut tr, TRACK_ENTRY, &v); + el(&mut tr, TRACK_ENTRY, &a); + el(&mut f, TRACKS, &tr); + // Chapters + let mut disp = Vec::new(); + el_str(&mut disp, CHAP_STRING, "Intro"); + let mut atom = Vec::new(); + el_uint(&mut atom, CHAPTER_UID, 5); + el_uint(&mut atom, CHAPTER_TIME_START, 0); + el_uint(&mut atom, CHAPTER_TIME_END, 40_000_000); + el(&mut atom, CHAPTER_DISPLAY, &disp); + let mut ed = Vec::new(); + el(&mut ed, CHAPTER_ATOM, &atom); + let mut ch = Vec::new(); + el(&mut ch, EDITION_ENTRY, &ed); + el(&mut f, CHAPTERS, &ch); + // Attachments + let mut af = Vec::new(); + el_str(&mut af, FILE_NAME, "font.ttf"); + el_str(&mut af, FILE_MEDIA_TYPE, "font/ttf"); + el_uint(&mut af, FILE_UID, 77); + el(&mut af, FILE_DATA, &[9; 100]); + let mut att = Vec::new(); + el(&mut att, ATTACHED_FILE, &af); + el(&mut f, ATTACHMENTS, &att); + // Cluster 1 (unknown size): keyframe SimpleBlock, BlockGroup with ReferenceBlock + duration + write_id(&mut f, CLUSTER); + write_unknown_size(&mut f, 1); + el_uint(&mut f, TIMESTAMP, 1000); + el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 1, 2])); + el(&mut f, SIMPLE_BLOCK, &block(2, 5, 0x80, &[0xAA; 10])); + let mut g = Vec::new(); + el(&mut g, BLOCK, &block(1, 40, 0, &[0x41, 3])); + el(&mut g, REFERENCE_BLOCK, &[0xD8]); + el_uint(&mut g, BLOCK_DURATION, 80); + el(&mut f, BLOCK_GROUP, &g); + el(&mut f, SIMPLE_BLOCK, &block(9, 0, 0x80, &[1])); // unknown track: ignored + // Cluster 2 (unknown size), ended by Tags + write_id(&mut f, CLUSTER); + write_unknown_size(&mut f, 8); + el_uint(&mut f, TIMESTAMP, 1120); + el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 4])); + let mut st = Vec::new(); + el_str(&mut st, TAG_NAME, "ARTIST"); + el_str(&mut st, TAG_STRING, "Nobody"); + let mut tg = Vec::new(); + let mut tgt = Vec::new(); + el_uint(&mut tgt, TAG_TRACK_UID, 11); + el(&mut tg, TARGETS, &tgt); + el(&mut tg, SIMPLE_TAG, &st); + let mut tags = Vec::new(); + el(&mut tags, TAG, &tg); + el(&mut f, TAGS, &tags); + f +} + +#[test] +fn synthetic_file() { + let bytes = synthetic(); + let f = open_with(&bytes[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); + assert!(f.live); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + assert_eq!((f.crc_checked, f.crc_errors.len()), (1, 0)); + assert_eq!(f.info.title.as_deref(), Some("Synthetic")); + let v = &f.tracks[0]; + assert!(matches!(&v.codec, Codec::Avc { avcc } if avcc[1] == 0x64)); + let vi = v.video.as_ref().unwrap(); + assert_eq!(vi.pixel_aspect(), (4, 3)); + let c = vi.colour.as_ref().unwrap(); + assert!(c.full_range()); + assert_eq!((c.matrix_coefficients, c.transfer_characteristics, c.primaries, c.max_cll, c.max_fall), (Some(9), Some(16), Some(9), Some(1000), Some(400))); + let m = c.mastering.as_ref().unwrap(); + assert_eq!(m.primaries[1], (0.17, 0.797)); + assert_eq!(m.white_point, (0.3127, 0.329)); + assert_eq!((m.luminance_max, m.luminance_min), (1000.0, 0.0001)); + assert_eq!(v.encodings, vec![ContentEncoding::HeaderStripping { bytes: vec![0, 0, 0, 1], scope: 1 }]); + // legacy AAC ID: ASC synthesised with explicit SBR signalling (LC, 24 kHz, stereo → 48 kHz) + assert_eq!(f.tracks[1].codec, Codec::Aac { asc: vec![0x13, 0x10, 0x56, 0xE5, 0x98] }); + assert_eq!(f.tracks[1].language, "ger"); + assert_eq!(f.chapters[0].chapters[0].title(), Some("Intro")); + assert_eq!(f.chapters[0].chapters[0].end_ns, Some(40_000_000)); + assert_eq!(f.attachments.len(), 1); + let at = &f.attachments[0]; + assert_eq!((at.name.as_str(), at.media_type.as_str(), at.uid), ("font.ttf", "font/ttf", 77)); + assert_eq!(&bytes[at.data_offset as usize..][..100], &[9; 100]); + assert_eq!(f.tags[0].targets.track_uids, vec![11]); + assert_eq!(f.tags[0].simple[0].string.as_deref(), Some("Nobody")); + assert_eq!(f.clusters.len(), 2); + assert_eq!(v.samples.len(), 3); + assert_eq!(v.keyframes(), &[0, 2]); + + let d = Demuxer::from_slice(&bytes).unwrap(); + let p: Vec = d.map(|p| p.unwrap()).collect(); + assert_eq!(p.len(), 4); + assert_eq!((p[0].track, p[0].pts, p[0].keyframe, p[0].duration), (0, 1000, true, 40)); + assert_eq!(p[0].data, [0, 0, 0, 1, 0x65, 1, 2]); + assert_eq!((p[1].track, p[1].pts, p[1].data.len()), (1, 1005, 10)); + assert_eq!((p[2].pts, p[2].keyframe, p[2].duration), (1040, false, 80)); + assert_eq!(p[2].data, [0, 0, 0, 1, 0x41, 3]); + assert_eq!((p[3].pts, p[3].pts_ns), (1120, 1_120_000_000)); + assert_eq!(f.read_sample(&bytes[..], 0, 1).unwrap(), p[2].data); + + // CRC mismatch is reported, not fatal + let mut bad = bytes.clone(); + let title = bad.windows(9).position(|w| w == b"Synthetic").unwrap(); + bad[title] = b's'; + let f = open_with(&bad[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); + assert_eq!(f.crc_errors.len(), 1); + + // seeking without index or cues builds the index + let mut d = Demuxer::with_options(&bytes[..], OpenOptions { index: false, verify_crc: false }).unwrap(); + assert_eq!(d.file().tracks.len(), 2); + assert_eq!(d.seek(0, 1_100_000_000).unwrap().pts, 1000); + assert_eq!(d.seek(0, 1_130_000_000).unwrap().pts, 1120); + assert_eq!(d.next_packet().unwrap().unwrap().pts, 1120); + assert!(d.file().indexed); +} diff --git a/crates/matroska/src/track.rs b/crates/matroska/src/track.rs new file mode 100644 index 000000000..79fcc3c25 --- /dev/null +++ b/crates/matroska/src/track.rs @@ -0,0 +1,295 @@ +//! Track descriptions (RFC 9559 §5.1.4). + +use crate::codec::Codec; + +/// Track type (`TrackType`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrackKind { + Video, + Audio, + Complex, + Logo, + Subtitle, + Buttons, + Control, + Metadata, + Other(u8), +} + +impl TrackKind { + pub(crate) fn from_type(t: u64) -> TrackKind { + match t { + 1 => TrackKind::Video, + 2 => TrackKind::Audio, + 3 => TrackKind::Complex, + 0x10 => TrackKind::Logo, + 0x11 => TrackKind::Subtitle, + 0x12 => TrackKind::Buttons, + 0x20 => TrackKind::Control, + 0x21 => TrackKind::Metadata, + n => TrackKind::Other(n as u8), + } + } +} + +/// SMPTE ST 2086 mastering display metadata (`MasteringMetadata`). Chromaticities are CIE 1931 xy, +/// luminance in cd/m². +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MasteringMetadata { + /// Red, green, blue primaries as (x, y). + pub primaries: [(f64, f64); 3], + pub white_point: (f64, f64), + pub luminance_max: f64, + pub luminance_min: f64, +} + +/// `Colour` element. Code points use ITU-T H.273 values (as Matroska does). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Colour { + /// `MatrixCoefficients` (H.273; 2 = unspecified). + pub matrix_coefficients: Option, + pub bits_per_channel: Option, + pub chroma_subsampling_horz: Option, + pub chroma_subsampling_vert: Option, + pub cb_subsampling_horz: Option, + pub cb_subsampling_vert: Option, + pub chroma_siting_horz: Option, + pub chroma_siting_vert: Option, + /// `Range`: 0 unspecified, 1 broadcast (limited), 2 full, 3 defined by matrix/transfer. + pub range: Option, + /// `TransferCharacteristics` (H.273). + pub transfer_characteristics: Option, + /// `Primaries` (H.273). + pub primaries: Option, + /// Maximum content light level (cd/m²). + pub max_cll: Option, + /// Maximum frame-average light level (cd/m²). + pub max_fall: Option, + pub mastering: Option, +} + +impl Colour { + /// True if `Range` says full range. + pub fn full_range(&self) -> bool { + self.range == Some(2) + } +} + +/// `Video` element. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct VideoInfo { + pub pixel_width: u32, + pub pixel_height: u32, + /// `DisplayWidth`/`DisplayHeight`, in `display_unit` (0 = pixels, 3 = display aspect ratio). + pub display_width: Option, + pub display_height: Option, + pub display_unit: u64, + /// Crop (top, bottom, left, right) in pixels. + pub crop: (u32, u32, u32, u32), + /// `FlagInterlaced`: 0 undetermined, 1 interlaced, 2 progressive. + pub flag_interlaced: u64, + /// `FieldOrder` (0 progressive, 1 tff, 2 undetermined, 6 bff, 9/14 interleaved). + pub field_order: Option, + pub stereo_mode: Option, + /// `AlphaMode`: 1 if BlockAdditions carry alpha. + pub alpha_mode: u64, + /// `ColourSpace` FourCC (uncompressed video). + pub colour_space: Option<[u8; 4]>, + pub colour: Option, +} + +impl VideoInfo { + /// Pixel aspect ratio derived from the display size (pixels or aspect-ratio units), reduced. + pub fn pixel_aspect(&self) -> (u32, u32) { + let (pw, ph) = (self.pixel_width.saturating_sub(self.crop.2 + self.crop.3) as u64, self.pixel_height.saturating_sub(self.crop.0 + self.crop.1) as u64); + let (Some(dw), Some(dh)) = (self.display_width, self.display_height) else { return (1, 1) }; + if pw == 0 || ph == 0 || dw == 0 || dh == 0 || !matches!(self.display_unit, 0 | 3) { + return (1, 1); + } + // PAR = (dw/dh) / (pw/ph) + let (n, d) = (dw as u64 * ph, dh as u64 * pw); + let g = gcd(n, d); + ((n / g) as u32, (d / g) as u32) + } +} + +pub(crate) fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + (a, b) = (b, a % b); + } + a.max(1) +} + +/// `Audio` element. +#[derive(Clone, Debug, PartialEq)] +pub struct AudioInfo { + /// `SamplingFrequency` (Hz). + pub sampling_frequency: f64, + /// `OutputSamplingFrequency` (e.g. SBR output rate). + pub output_sampling_frequency: Option, + pub channels: u64, + pub bit_depth: Option, +} + +impl Default for AudioInfo { + fn default() -> Self { + Self { sampling_frequency: 8000.0, output_sampling_frequency: None, channels: 1, bit_depth: None } + } +} + +/// One `ContentEncoding`. +#[derive(Clone, Debug, PartialEq)] +pub enum ContentEncoding { + /// `ContentCompAlgo` 3: the given bytes were stripped from the start of every frame. + HeaderStripping { bytes: Vec, scope: u64 }, + /// Other compression (0 zlib, 1 bzlib, 2 lzo1x): not supported for reading frame data. + Compression { algo: u64, settings: Vec, scope: u64 }, + /// Encryption (not supported for reading frame data). + Encryption { scope: u64 }, +} + +/// One entry of a track's sample (frame) table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Sample { + /// Byte offset of the (stored) frame data in the file. + pub offset: u64, + /// Stored size (excluding header-stripped bytes; see [`Track::sample_size`]). + pub size: u32, + /// Presentation timestamp in the track timebase (`TimestampScale` ticks). + pub pts: i64, + /// Duration in timebase ticks (from `BlockDuration` or `DefaultDuration`), 0 if unknown. + pub duration: u64, + pub keyframe: bool, + /// Index of the owning cluster in [`crate::MkvFile::clusters`]. + pub cluster: u32, + /// Offset of the block element (SimpleBlock / BlockGroup) header. + pub block_offset: u64, + /// Index of this frame within a laced block. + pub lace: u16, +} + +/// A Matroska track (`TrackEntry`) plus, once indexed, its sample table. +#[derive(Clone, Debug)] +pub struct Track { + pub number: u64, + pub uid: u64, + pub kind: TrackKind, + pub enabled: bool, + pub default: bool, + pub forced: bool, + pub hearing_impaired: bool, + pub lacing: bool, + /// `DefaultDuration` in nanoseconds. + pub default_duration_ns: Option, + pub name: String, + /// `Language` (ISO 639-2, default `eng`). + pub language: String, + pub language_bcp47: Option, + pub codec_id: String, + pub codec_private: Vec, + pub codec_name: String, + pub codec: Codec, + /// `CodecDelay` in nanoseconds. + pub codec_delay_ns: u64, + /// `SeekPreRoll` in nanoseconds. + pub seek_pre_roll_ns: u64, + pub video: Option, + pub audio: Option, + pub encodings: Vec, + /// Timebase numerator/denominator in seconds: `TimestampScale / 1e9`, reduced. + pub timebase: (u64, u64), + /// Frame table (empty unless the file was opened with indexing). + pub samples: Vec, + pub(crate) sync: Vec, +} + +impl Track { + /// Bytes prepended to every frame by header-stripping compression, if any. + pub fn stripped_header(&self) -> Option<&[u8]> { + self.encodings.iter().find_map(|e| match e { + ContentEncoding::HeaderStripping { bytes, scope } if scope & 1 != 0 => Some(bytes.as_slice()), + _ => None, + }) + } + + /// True if frames can be returned verbatim or with header stripping undone. + pub fn frames_readable(&self) -> bool { + self.encodings.iter().all(|e| match e { + ContentEncoding::HeaderStripping { .. } => true, + ContentEncoding::Compression { scope, .. } | ContentEncoding::Encryption { scope } => scope & 1 == 0, + }) + } + + /// Bytes to put in front of a stored frame of `stored_size` bytes to get the codec frame: + /// header-stripped bytes, or for `V_PRORES` the 8-byte frame header (`size` + `icpf`) that the + /// Matroska ProRes mapping omits. + pub fn frame_prefix(&self, stored_size: u64) -> Vec { + let mut p = self.stripped_header().map(<[u8]>::to_vec).unwrap_or_default(); + if matches!(self.codec, Codec::ProRes { .. }) { + let total = stored_size + p.len() as u64 + 8; + let mut h = (total as u32).to_be_bytes().to_vec(); + h.extend_from_slice(b"icpf"); + h.extend_from_slice(&p); + p = h; + } + p + } + + /// Frame size of sample `i` as returned by `read_sample` / packets (stored size plus prefix). + pub fn sample_size(&self, i: usize) -> Option { + let s = self.samples.get(i)?; + Some(s.size as u64 + self.frame_prefix(s.size as u64).len() as u64) + } + + /// Indices of keyframe samples, ascending by file order. + pub fn keyframes(&self) -> &[u32] { + &self.sync + } + + /// Timebase ticks → nanoseconds. + pub fn ticks_to_ns(&self, t: i64) -> i64 { + (t as i128 * self.timebase.0 as i128 * 1_000_000_000 / self.timebase.1 as i128) as i64 + } + + /// Nanoseconds → timebase ticks (floor). + pub fn ns_to_ticks(&self, ns: i64) -> i64 { + let n = ns as i128 * self.timebase.1 as i128; + let d = self.timebase.0 as i128 * 1_000_000_000; + n.div_euclid(d) as i64 + } + + /// Index of the last sample with `pts <= t` (in file order), or the first sample. + pub fn sample_at_pts(&self, t: i64) -> Option { + if self.samples.is_empty() { + return None; + } + let mut best: Option = None; + for (i, s) in self.samples.iter().enumerate() { + if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) { + best = Some(i); + } + } + Some(best.unwrap_or(0)) + } + + /// Index of the keyframe with the greatest `pts <= t` (the first keyframe if none precedes `t`). + pub fn keyframe_before_pts(&self, t: i64) -> Option { + let mut best: Option = None; + for &k in &self.sync { + let s = &self.samples[k as usize]; + if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) { + best = Some(k as usize); + } + } + best.or_else(|| self.sync.first().map(|&k| k as usize)) + } + + /// Index of the nearest keyframe at or before sample `index` in file order. + pub fn sync_sample_before(&self, index: usize) -> usize { + match self.sync.binary_search(&(index as u32)) { + Ok(i) => self.sync[i] as usize, + Err(0) => 0, + Err(i) => self.sync[i - 1] as usize, + } + } +} diff --git a/crates/matroska/tests/common/mod.rs b/crates/matroska/tests/common/mod.rs new file mode 100644 index 000000000..2e0fd8722 --- /dev/null +++ b/crates/matroska/tests/common/mod.rs @@ -0,0 +1,137 @@ +//! Shared helpers for oracle tests: ffmpeg/ffprobe discovery and fixture generation. +//! ffmpeg/ffprobe are used only as external fixture generators and oracles (never linked). +#![allow(dead_code)] + +use serde_json::Value; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +pub fn tool(name: &str) -> Option { + for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] { + let p = Path::new(dir).join(name); + if p.exists() { + return Some(p); + } + } + let ok = Command::new(name).arg("-version").output().map(|o| o.status.success()).unwrap_or(false); + ok.then(|| PathBuf::from(name)) +} + +/// (ffmpeg, ffprobe), or `None` (test skipped) if either is missing. +pub fn tools() -> Option<(PathBuf, PathBuf)> { + match (tool("ffmpeg"), tool("ffprobe")) { + (Some(a), Some(b)) => Some((a, b)), + _ => { + eprintln!("ffmpeg/ffprobe not found; skipping oracle test"); + None + } + } +} + +pub fn fixture_dir() -> PathBuf { + let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/matroska"); + std::fs::create_dir_all(&d).unwrap(); + d +} + +const V: &[&str] = &["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25"]; +const A: &[&str] = &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"]; + +fn write_subs(dir: &Path) { + let srt = "1\n00:00:00,200 --> 00:00:00,900\nHello\n\n2\n00:00:01,000 --> 00:00:01,500\nWorld two\n\n"; + std::fs::write(dir.join("in.srt"), srt).unwrap(); + let vtt = "WEBVTT\n\n00:00:00.200 --> 00:00:00.900\nHello\n\n00:00:01.000 --> 00:00:01.500\nWorld\n\n"; + std::fs::write(dir.join("in.vtt"), vtt).unwrap(); +} + +/// Fixture name → ffmpeg arguments (after `-y -v error`, before the output path). +/// `None` for fixtures written through a pipe (no Cues, unknown sizes). +pub fn spec(name: &str) -> Option> { + let cat = |parts: &[&[&str]]| parts.concat().into_iter().map(String::from).collect::>(); + let x264: &[&str] = &["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"]; + Some(match name { + "h264_aac.mkv" => cat(&[V, A, &["-t", "2"], x264, &["-c:a", "aac"]]), + "vp9_opus.webm" => cat(&[V, A, &["-t", "2", "-c:v", "libvpx-vp9", "-deadline", "realtime", "-g", "10", "-c:a", "libopus"]]), + "flac.mkv" => cat(&[A, &["-t", "2", "-c:a", "flac"]]), + "vorbis.mkv" => cat(&[A, &["-t", "2", "-ac", "2", "-c:a", "vorbis", "-strict", "-2"]]), + "subs.mkv" => cat(&[ + V, + &["-i", "in.srt", "-i", "in.srt", "-t", "2", "-map", "0:v", "-map", "1:s", "-map", "2:s"], + x264, + &["-c:s:0", "srt", "-c:s:1", "ass", "-metadata:s:s:0", "language=fre", "-metadata:s:s:1", "title=Styled"], + ]), + "webvtt.webm" => cat(&[V, &["-i", "in.vtt", "-t", "2", "-map", "0:v", "-map", "1:s", "-c:v", "libvpx", "-deadline", "realtime", "-c:s", "webvtt"]]), + "cues_front.mkv" => cat(&[V, A, &["-t", "3"], x264, &["-c:a", "aac", "-cues_to_front", "1", "-cluster_time_limit", "500"]]), + "hevc_hdr.mkv" => cat(&[ + V, + &[ + "-t", + "1", + "-c:v", + "libx265", + "-pix_fmt", + "yuv420p10le", + "-x265-params", + "log-level=error:bframes=3:keyint=10:hdr10=1:master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1):max-cll=1000,400", + "-vf", + "setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv", + ], + ]), + "prores_pcm.mkv" => cat(&[V, A, &["-t", "1", "-c:v", "prores_ks", "-profile:v", "2", "-c:a", "pcm_s24le"]]), + "mjpeg_ac3.mkv" => cat(&[V, A, &["-t", "1", "-c:v", "mjpeg", "-q:v", "5", "-c:a", "ac3", "-metadata", "title=FilmCraft test"]]), + "av1.mkv" => cat(&[V, &["-t", "1", "-c:v", "libsvtav1", "-preset", "12", "-g", "10"]]), + "live.mkv" => return None, + _ => panic!("unknown fixture {name}"), + }) +} + +/// Generate (once) and return the path of a fixture; `None` if generation failed (encoder missing). +pub fn fixture(ffmpeg: &Path, name: &str) -> Option { + let dir = fixture_dir(); + let out = dir.join(name); + if out.exists() && std::fs::metadata(&out).map(|m| m.len() > 0).unwrap_or(false) { + return Some(out); + } + write_subs(&dir); + let tid = format!("{:?}", std::thread::current().id()).replace(|c: char| !c.is_ascii_alphanumeric(), ""); + let tmp = dir.join(format!("{name}.{}.{tid}.tmp", std::process::id())); + let ok = match spec(name) { + Some(args) => { + let mut c = Command::new(ffmpeg); + c.current_dir(&dir).args(["-y", "-v", "error"]).args(&args); + let fmt = if name.ends_with(".webm") { "webm" } else { "matroska" }; + c.args(["-f", fmt]).arg(&tmp); + c.status().map(|s| s.success()).unwrap_or(false) + } + None => { + // streamed to a pipe: unknown-size Segment, no Cues, no Duration + let f = std::fs::File::create(&tmp).unwrap(); + let mut c = Command::new(ffmpeg); + c.current_dir(&dir).args(["-y", "-v", "error"]).args(V).args(A); + c.args(["-t", "2", "-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p", "-c:a", "aac", "-f", "matroska", "pipe:1"]); + c.stdout(Stdio::from(f)); + c.status().map(|s| s.success()).unwrap_or(false) + } + }; + if !ok { + let _ = std::fs::remove_file(&tmp); + eprintln!("could not generate {name} (encoder unavailable?); skipping"); + return None; + } + std::fs::rename(&tmp, &out).unwrap(); // atomic: concurrent generators race harmlessly + Some(out) +} + +pub fn ffprobe_json(ffprobe: &Path, path: &Path) -> Value { + let out = Command::new(ffprobe) + .args(["-v", "error", "-show_packets", "-show_streams", "-show_format", "-show_chapters", "-of", "json"]) + .arg(path) + .output() + .expect("run ffprobe"); + assert!(out.status.success(), "ffprobe failed on {}: {}", path.display(), String::from_utf8_lossy(&out.stderr)); + serde_json::from_slice(&out.stdout).expect("ffprobe json") +} + +pub fn int(v: &Value) -> Option { + v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok())) +} diff --git a/crates/matroska/tests/oracle.rs b/crates/matroska/tests/oracle.rs new file mode 100644 index 000000000..bc278ac87 --- /dev/null +++ b/crates/matroska/tests/oracle.rs @@ -0,0 +1,362 @@ +//! Oracle tests: compare our demuxer's packets, timestamps, keyframes and track parameters with +//! ffprobe on ffmpeg-generated fixtures (skipped when ffmpeg/ffprobe are not installed). + +mod common; + +use common::*; +use filmcraft_matroska::*; +use serde_json::Value; +use std::path::Path; + +/// Compare every packet (file order) and stream parameter against ffprobe. +fn compare(ffprobe: &Path, path: &Path) -> (MkvFile, Vec) { + let json = ffprobe_json(ffprobe, path); + let bytes = std::fs::read(path).unwrap(); + let mut d = Demuxer::from_slice(&bytes).unwrap(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let file = d.file().clone(); + let streams = json["streams"].as_array().unwrap(); + assert_eq!(streams.len(), file.tracks.len(), "{name}: stream count"); + for (i, st) in streams.iter().enumerate() { + let t = &file.tracks[i]; + assert_eq!(st["codec_name"].as_str().unwrap_or(""), t.codec.name(), "{name}: track {i} codec ({})", t.codec_id); + assert_eq!(st["time_base"].as_str(), Some(format!("{}/{}", t.timebase.0, t.timebase.1).as_str()), "{name}: time_base"); + match t.kind { + TrackKind::Video => { + let v = t.video.as_ref().unwrap(); + assert_eq!(int(&st["width"]), Some(v.pixel_width as i64), "{name}: width"); + assert_eq!(int(&st["height"]), Some(v.pixel_height as i64), "{name}: height"); + } + TrackKind::Audio => { + let a = t.audio.as_ref().unwrap(); + assert_eq!(int(&st["sample_rate"]), Some(a.sampling_frequency as i64), "{name}: sample_rate"); + assert_eq!(int(&st["channels"]), Some(a.channels as i64), "{name}: channels"); + } + _ => {} + } + } + let packets: Vec = d.by_ref().collect::>().unwrap(); + let ff = json["packets"].as_array().unwrap(); + assert_eq!(packets.len(), ff.len(), "{name}: packet count"); + let (mut pts_checked, mut dur_checked) = (0, 0); + for (i, (p, f)) in packets.iter().zip(ff).enumerate() { + let ctx = format!("{name}: packet {i} (track {})", p.track); + assert_eq!(int(&f["stream_index"]), Some(p.track as i64), "{ctx}: stream"); + if file.tracks[p.track].codec_id.starts_with("D_WEBVTT") { + // WebM WebVTT blocks carry "id\nsettings\npayload"; FFmpeg moves id/settings to side data + assert!(p.data.len() as i64 >= int(&f["size"]).unwrap(), "{ctx}: size"); + } else { + assert_eq!(int(&f["size"]), Some(p.data.len() as i64), "{ctx}: size"); + } + let key = f["flags"].as_str().unwrap_or("").starts_with('K'); + assert_eq!(key, p.keyframe, "{ctx}: keyframe"); + if let Some(pts) = int(&f["pts"]) + && !(p.lace > 0 && p.duration == 0) + { + // FFmpeg truncates the per-lace duration to whole ticks; we place laces exactly + // (rounded once), so later laces may differ by up to one tick per lace. + assert!((pts - p.pts).abs() <= p.lace as i64, "{ctx}: pts ffprobe {pts} vs {}", p.pts); + pts_checked += 1; + } + if let Some(dur) = int(&f["duration"]) + && p.duration != 0 + { + assert!((dur - p.duration as i64).abs() <= 1, "{ctx}: duration ffprobe {dur} vs {}", p.duration); + dur_checked += 1; + } + } + assert!(pts_checked > 0, "{name}: no pts compared"); + eprintln!("{name}: {} packets, {pts_checked} pts and {dur_checked} durations compared", packets.len()); + // the sample index agrees with the packet stream + for (ti, t) in file.tracks.iter().enumerate() { + let pk: Vec<&Packet> = packets.iter().filter(|p| p.track == ti).collect(); + assert_eq!(t.samples.len(), pk.len(), "{name}: index size track {ti}"); + for (i, p) in pk.iter().enumerate() { + assert_eq!(t.samples[i].pts, p.pts); + assert_eq!(t.samples[i].keyframe, p.keyframe); + assert_eq!(t.sample_size(i), Some(p.data.len() as u64)); + } + if let Some(i) = pk.len().checked_sub(1) { + assert_eq!(file.read_sample(&bytes[..], ti, i).unwrap(), pk[i].data); + } + } + // duration + if let Some(fd) = json["format"]["duration"].as_str().and_then(|s| s.parse::().ok()) + && let Some(ours) = file.duration_ns() + { + assert!((fd - ours as f64 / 1e9).abs() < 0.05, "{name}: duration {fd} vs {ours}"); + } + (file, packets) +} + +/// Seek to a range of times on `track` and check we land on the latest keyframe ≤ target. +fn check_seeks(bytes: &[u8], opts: OpenOptions, track: usize, packets: &[Packet]) { + let mut d = Demuxer::with_options(bytes, opts).unwrap(); + let keys: Vec<(i64, i64)> = packets.iter().filter(|p| p.track == track && p.keyframe).map(|p| (p.pts, p.pts_ns)).collect(); + let last = packets.iter().filter(|p| p.track == track).map(|p| p.pts_ns).max().unwrap(); + let mut t = 0i64; + while t <= last + 100_000_000 { + let sp = d.seek(track, t).unwrap(); + let expect = keys.iter().filter(|k| k.1 <= t).map(|k| k.0).max().unwrap_or(keys[0].0); + assert_eq!(sp.pts, expect, "seek to {t}ns"); + assert!(sp.pts_ns <= t || sp.pts == keys[0].0); + // the first packet of that track after seeking is the keyframe + let p = loop { + let p = d.next_packet().unwrap().expect("packet after seek"); + if p.track == track { + break p; + } + }; + assert!(p.keyframe); + assert_eq!(p.pts, sp.pts); + t += 137_000_000; + } +} + +fn run(name: &str) -> Option<(Vec, MkvFile, Vec)> { + let (ffmpeg, ffprobe) = tools()?; + let path = fixture(&ffmpeg, name)?; + let (file, packets) = compare(&ffprobe, &path); + let bytes = std::fs::read(&path).unwrap(); + Some((bytes, file, packets)) +} + +#[test] +fn h264_aac() { + let Some((bytes, f, packets)) = run("h264_aac.mkv") else { return }; + assert_eq!(f.doc_type, "matroska"); + assert!(f.warnings.is_empty(), "{:?}", f.warnings); + let v = f.track_of_kind(TrackKind::Video).unwrap(); + let Codec::Avc { avcc } = &f.tracks[v].codec else { panic!("not avc") }; + assert_eq!(avcc[0], 1, "avcC version"); + let a = f.track_of_kind(TrackKind::Audio).unwrap(); + assert!(matches!(&f.tracks[a].codec, Codec::Aac { asc } if asc.len() >= 2)); + assert!(!f.cues.is_empty()); + assert!(!f.seek_head.is_empty()); + check_seeks(&bytes, OpenOptions::default(), v, &packets); + check_seeks(&bytes, OpenOptions { index: false, verify_crc: false }, v, &packets); + check_seeks(&bytes, OpenOptions::default(), a, &packets); + // Read + Seek entry point and CRC verification (ffmpeg writes CRC-32 in level-1 elements) + let d = Demuxer::from_reader(std::io::Cursor::new(bytes.clone())).unwrap(); + assert_eq!(d.file().tracks.len(), 2); + let crc = open_with(&bytes[..], &OpenOptions { index: true, verify_crc: true }).unwrap(); + assert!(crc.crc_checked > 0); + assert!(crc.crc_errors.is_empty()); + let kf = Demuxer::from_slice(&bytes).unwrap().keyframe_index(v).unwrap(); + assert_eq!(kf.len(), packets.iter().filter(|p| p.track == v && p.keyframe).count()); +} + +#[test] +fn vp9_opus_webm() { + let Some((bytes, f, packets)) = run("vp9_opus.webm") else { return }; + assert!(f.is_webm()); + let a = f.track_of_kind(TrackKind::Audio).unwrap(); + let t = &f.tracks[a]; + assert!(matches!(&t.codec, Codec::Opus { head } if head.starts_with(b"OpusHead"))); + assert!(t.codec_delay_ns > 0 && t.seek_pre_roll_ns > 0); + assert!(packets.iter().any(|p| p.track == a && p.pts < 0), "codec delay applied"); + let v = f.track_of_kind(TrackKind::Video).unwrap(); + assert!(matches!(f.tracks[v].codec, Codec::Vp9 { .. })); + check_seeks(&bytes, OpenOptions::default(), v, &packets); + check_seeks(&bytes, OpenOptions { index: false, verify_crc: false }, v, &packets); +} + +#[test] +fn flac() { + let Some((_, f, _)) = run("flac.mkv") else { return }; + assert!(matches!(&f.tracks[0].codec, Codec::Flac { private } if private.starts_with(b"fLaC"))); +} + +#[test] +fn vorbis() { + let Some((_, f, _)) = run("vorbis.mkv") else { return }; + let Codec::Vorbis { headers } = &f.tracks[0].codec else { panic!("not vorbis") }; + assert_eq!(headers.len(), 3); + assert_eq!(&headers[0][..7], b"\x01vorbis"); + assert_eq!(&headers[1][..7], b"\x03vorbis"); + assert_eq!(&headers[2][..7], b"\x05vorbis"); +} + +#[test] +fn subtitles() { + let Some((_, f, packets)) = run("subs.mkv") else { return }; + let subs: Vec = (0..f.tracks.len()).filter(|&i| f.tracks[i].kind == TrackKind::Subtitle).collect(); + assert_eq!(subs.len(), 2); + assert_eq!(f.tracks[subs[0]].codec, Codec::SubRip); + assert_eq!(f.tracks[subs[0]].language, "fre"); + assert!(matches!(&f.tracks[subs[1]].codec, Codec::Ass { header, ssa: false } if !header.is_empty())); + assert_eq!(f.tracks[subs[1]].name, "Styled"); + let first = packets.iter().find(|p| p.track == subs[0]).unwrap(); + assert_eq!(first.data, b"Hello"); + assert_eq!((first.pts, first.duration), (200, 700)); +} + +#[test] +fn webvtt_webm() { + let Some((_, f, packets)) = run("webvtt.webm") else { return }; + let s = f.track_of_kind(TrackKind::Subtitle).unwrap(); + assert_eq!(f.tracks[s].codec, Codec::WebVtt); + let cues: Vec<&Packet> = packets.iter().filter(|p| p.track == s).collect(); + assert_eq!(cues.len(), 2); + assert!(cues[0].data.ends_with(b"Hello")); +} + +#[test] +fn cues_at_front() { + let Some((bytes, f, packets)) = run("cues_front.mkv") else { return }; + assert!(!f.cues.is_empty()); + assert!(f.clusters.len() >= 5); + let v = f.track_of_kind(TrackKind::Video).unwrap(); + check_seeks(&bytes, OpenOptions { index: false, verify_crc: false }, v, &packets); +} + +#[test] +fn live_stream_without_cues() { + let Some((bytes, f, packets)) = run("live.mkv") else { return }; + assert!(f.cues.is_empty(), "streamed output has no Cues"); + assert!(f.live || f.info.duration.is_none()); + assert!(f.duration_ns().unwrap() > 1_900_000_000); + let v = f.track_of_kind(TrackKind::Video).unwrap(); + // no cues: seeking builds the index by scanning clusters + check_seeks(&bytes, OpenOptions { index: false, verify_crc: false }, v, &packets); + let mut d = Demuxer::with_options(&bytes[..], OpenOptions { index: false, verify_crc: false }).unwrap(); + assert!(!d.file().indexed); + assert!(!d.keyframe_index(v).unwrap().is_empty()); + assert!(d.file().indexed); +} + +#[test] +fn hevc_hdr_colour() { + let Some((_, f, _)) = run("hevc_hdr.mkv") else { return }; + let t = &f.tracks[0]; + assert!(matches!(&t.codec, Codec::Hevc { hvcc } if hvcc.first() == Some(&1))); + let c = t.video.as_ref().unwrap().colour.as_ref().expect("Colour element"); + assert_eq!(c.primaries, Some(9)); + assert_eq!(c.transfer_characteristics, Some(16)); + assert_eq!(c.matrix_coefficients, Some(9)); + assert_eq!(c.range, Some(1)); + // mastering metadata / CLL are written by ffmpeg from stream side data when available + if let Some(m) = &c.mastering { + assert!((m.primaries[1].0 - 0.265).abs() < 1e-3, "{m:?}"); + assert!((m.luminance_max - 1000.0).abs() < 1e-3); + } + if let Some(cll) = c.max_cll { + assert_eq!(cll, 1000); + } +} + +#[test] +fn prores_pcm() { + let Some((_, f, _)) = run("prores_pcm.mkv") else { return }; + let a = f.track_of_kind(TrackKind::Audio).unwrap(); + assert_eq!(f.tracks[a].codec, Codec::Pcm { float: false, big_endian: false, bits: 24 }); +} + +#[test] +fn mjpeg_ac3() { + let Some((_, f, _)) = run("mjpeg_ac3.mkv") else { return }; + assert_eq!(f.info.title.as_deref(), Some("FilmCraft test")); + assert!(f.info.muxing_app.starts_with("Lavf")); + assert!(f.tags.iter().flat_map(|t| &t.simple).any(|s| s.name == "ENCODER")); +} + +#[test] +fn av1() { + let Some((_, f, _)) = run("av1.mkv") else { return }; + assert!(matches!(&f.tracks[0].codec, Codec::Av1 { av1c } if av1c.first() == Some(&0x81))); +} + +/// Re-mux a fixture with our writer using each lacing mode; ffprobe must read the result identically +/// to our demuxer (and to the source packets). +#[test] +fn laced_remux() { + let Some((ffmpeg, ffprobe)) = tools() else { return }; + for (src, modes) in [ + ("vorbis.mkv", &[LacingMode::Xiph, LacingMode::Ebml][..]), + ("h264_aac.mkv", &[LacingMode::Ebml, LacingMode::Xiph][..]), + ("prores_pcm.mkv", &[LacingMode::FixedOrEbml][..]), + ("flac.mkv", &[LacingMode::Ebml][..]), + ] { + let Some(path) = fixture(&ffmpeg, src) else { continue }; + let bytes = std::fs::read(&path).unwrap(); + let d = Demuxer::from_slice(&bytes).unwrap(); + let file = d.file().clone(); + let packets: Vec = d.collect::>().unwrap(); + for &mode in modes { + let specs: Vec = file + .tracks + .iter() + .map(|t| { + let mut s = TrackSpec::new(t.kind, t.codec_id.clone()); + s.codec_private = t.codec_private.clone(); + s.default_duration_ns = t.default_duration_ns.or_else(|| { + // give AAC a DefaultDuration so every lace gets a timestamp + matches!(t.codec, Codec::Aac { .. }).then(|| (1024.0 * 1e9 / t.audio.as_ref().unwrap().sampling_frequency) as u64) + }); + s.video_size = t.video.as_ref().map(|v| (v.pixel_width, v.pixel_height)); + s.audio = t.audio.as_ref().map(|a| (a.sampling_frequency, a.channels, a.bit_depth)); + s.codec_delay_ns = t.codec_delay_ns; + s + }) + .collect(); + let opts = MuxOptions { lacing: mode, ..Default::default() }; + let mut w = MkvWriter::new(std::io::Cursor::new(Vec::new()), specs, opts).unwrap(); + for p in &packets { + w.write_frame(p.track, p.pts_ns, p.keyframe, &p.data, None).unwrap(); + } + let out = w.finish().unwrap().into_inner(); + let laced = { + let f = open(&out[..]).unwrap(); + f.tracks.iter().any(|t| t.samples.iter().any(|s| s.lace > 0)) + }; + assert!(laced, "{src} {mode:?}: nothing was laced"); + let outp = fixture_dir().join(format!("remux_{mode:?}_{src}")); + std::fs::write(&outp, &out).unwrap(); + let (_, got) = compare(&ffprobe, &outp); + assert_eq!(got.len(), packets.len()); + for (a, b) in got.iter().zip(&packets) { + assert_eq!(a.data, b.data, "{src} {mode:?}: payload"); + assert_eq!(a.track, b.track); + } + std::fs::remove_file(&outp).unwrap(); + } + } +} + +/// Damaged file: overwrite bytes in the middle of the clusters; the demuxer must not fail and must +/// resync on a later cluster. +#[test] +fn damaged_resync() { + let Some((mut bytes, f, packets)) = run("h264_aac.mkv") else { return }; + let mid = f.clusters[f.clusters.len() / 2]; + let at = mid.data_start as usize + 40; + for b in &mut bytes[at..at + 200] { + *b = 0x00; + } + let d = Demuxer::from_slice(&bytes).unwrap(); + assert!(!d.file().warnings.is_empty()); + let got: Vec = d.collect::>().unwrap(); + assert!(got.len() < packets.len()); + assert!(got.len() > packets.len() / 2); + // packets after the damaged cluster are intact + let last = packets.last().unwrap(); + assert_eq!(got.last().unwrap(), last); + // truncated file + let cut = &bytes[..bytes.len() * 2 / 3]; + let d = Demuxer::from_slice(cut).unwrap(); + let got: Vec = d.collect::>().unwrap(); + assert!(!got.is_empty()); + // garbage never panics + for n in [0usize, 10, 100, 1000] { + let _ = Demuxer::from_slice(&bytes[..n.min(bytes.len())]).map(|d| d.count()); + } +} + +#[test] +fn not_matroska() { + assert!(matches!(open(&b"hello world"[..]), Err(Error::NotMatroska(_)))); + let mut ebml = vec![0x1A, 0x45, 0xDF, 0xA3, 0x84, 0x42, 0x82, 0x81, b'x']; + ebml.extend_from_slice(&[0; 8]); + assert!(matches!(open(&ebml[..]), Err(Error::NotMatroska(_)))); + let _ = Value::Null; +}