Merge branch 'worktree-agent-a14267622437e4bbe'

This commit is contained in:
Brandon Thomas
2026-09-30 11:08:28 -04:00
17 changed files with 4535 additions and 0 deletions
Generated
+7
View File
@@ -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"
+1
View File
@@ -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" }
+15
View File
@@ -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 }
+115
View File
@@ -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<u8>, 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<TrackSpec>, 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).
+225
View File
@@ -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<u8> },
/// `V_MPEGH/ISO/HEVC`; payload is an `HEVCDecoderConfigurationRecord` (hvcC).
Hevc { hvcc: Vec<u8> },
/// `V_VP8`.
Vp8,
/// `V_VP9`; `CodecPrivate` (optional) holds VP9 codec feature metadata.
Vp9 { private: Vec<u8> },
/// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C).
Av1 { av1c: Vec<u8> },
/// `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<u8> },
/// `A_AAC` (and the legacy `A_AAC/MPEG{2,4}/<profile>` IDs); `asc` is the
/// `AudioSpecificConfig` (synthesised for legacy IDs without `CodecPrivate`).
Aac { asc: Vec<u8> },
/// `A_OPUS`; `head` is the `OpusHead` identification header.
Opus { head: Vec<u8> },
/// `A_VORBIS`; the three Vorbis headers (identification, comment, setup), split from the
/// Xiph-laced `CodecPrivate`.
Vorbis { headers: Vec<Vec<u8>> },
/// `A_FLAC`; `private` is `fLaC` + metadata blocks (STREAMINFO first).
Flac { private: Vec<u8> },
/// `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<u8>, 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<f64>, channels: u64, sbr: bool) -> Vec<u8> {
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<Vec<u8>> {
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]]);
}
}
File diff suppressed because it is too large Load Diff
+476
View File
@@ -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<usize> {
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<u64> {
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<u8>, 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<u8>, 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<u8>, 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<Elem<'a>> {
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<bool> {
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<Vec<(usize, usize)>> {
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::<Vec<_>>(), [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::<Vec<_>>(), [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::<Vec<_>>(), [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);
}
}
+53
View File
@@ -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<io::Error> for Error {
fn from(e: io::Error) -> Self {
Error::Io(e)
}
}
pub type Result<T> = std::result::Result<T, Error>;
pub(crate) fn invalid<T>(msg: impl Into<String>) -> Result<T> {
Err(Error::Invalid(msg.into()))
}
+206
View File
@@ -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;
+36
View File
@@ -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::*;
}
+526
View File
@@ -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<f64>,
pub title: Option<String>,
pub muxing_app: String,
pub writing_app: String,
/// `DateUTC`: nanoseconds since 2001-01-01T00:00:00 UTC.
pub date_utc: Option<i64>,
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<u64>,
pub block_number: Option<u64>,
pub duration: Option<u64>,
}
/// One `CuePoint`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CuePoint {
/// In timestamp ticks.
pub time: u64,
pub positions: Vec<CuePosition>,
}
/// 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<String>,
pub languages_bcp47: Vec<String>,
pub countries: Vec<String>,
}
/// `ChapterAtom` (possibly nested).
#[derive(Clone, Debug, PartialEq)]
pub struct Chapter {
pub uid: u64,
pub string_uid: Option<String>,
pub start_ns: u64,
pub end_ns: Option<u64>,
pub hidden: bool,
pub enabled: bool,
pub displays: Vec<ChapterDisplay>,
pub children: Vec<Chapter>,
}
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<u64>,
pub hidden: bool,
pub default: bool,
pub ordered: bool,
pub chapters: Vec<Chapter>,
}
/// `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<String>,
pub track_uids: Vec<u64>,
pub edition_uids: Vec<u64>,
pub chapter_uids: Vec<u64>,
pub attachment_uids: Vec<u64>,
}
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<String>,
pub binary: Option<Vec<u8>>,
pub children: Vec<SimpleTag>,
}
/// `Tag`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Tag {
pub targets: TagTargets,
pub simple: Vec<SimpleTag>,
}
/// `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<String>,
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<SeekEntry> {
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<CuePoint>) {
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<Edition>) {
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<Tag>) {
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<ContentEncoding> {
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<u8>)> = 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<Track> {
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
}
+539
View File
@@ -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<u8>,
pub default_duration_ns: Option<u64>,
pub language: Option<String>,
pub name: Option<String>,
/// 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<u64>)>,
pub codec_delay_ns: u64,
pub seek_pre_roll_ns: u64,
}
impl TrackSpec {
pub fn new(kind: TrackKind, codec_id: impl Into<String>) -> 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<String>,
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<u8>, 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<u8>, 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<u8>, id: u32, v: f64) {
el(out, id, &v.to_be_bytes());
}
pub(crate) fn el_str(out: &mut Vec<u8>, id: u32, s: &str) {
el(out, id, s.as_bytes());
}
fn void(out: &mut Vec<u8>, 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<Vec<u8>>,
}
const SEEK_HEAD_SPACE: usize = 128;
/// Streaming Matroska/WebM writer.
pub struct MkvWriter<W: Write + Seek> {
w: W,
opts: MuxOptions,
tracks: Vec<TrackSpec>,
segment_size_pos: u64,
segment_data_start: u64,
seek_head_pos: u64,
duration_pos: u64,
info_pos: u64,
tracks_pos: u64,
cluster: Vec<u8>,
cluster_ts: Option<i64>,
cluster_start_ns: i64,
pending: Option<Pending>,
cues: Vec<(u64, u64, u64)>, // (time ticks, track number, cluster position)
cued_tracks_in_cluster: Vec<bool>,
end_ns: i64,
has_video: bool,
}
impl<W: Write + Seek> MkvWriter<W> {
pub fn new(mut w: W, tracks: Vec<TrackSpec>, opts: MuxOptions) -> Result<Self> {
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<u64>) -> 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<W> {
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);
}
}
+173
View File
@@ -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<u8> {
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<T: ByteSource + ?Sized> 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<T: ByteSource + ?Sized> ByteSource for Arc<T> {
fn len(&self) -> u64 {
(**self).len()
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
(**self).read_at(offset, buf)
}
}
impl<T: ByteSource + ?Sized> ByteSource for Box<T> {
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<File>`, `Cursor<Vec<u8>>`) 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<R> {
inner: RefCell<(R, u64)>,
len: u64,
}
impl<R: Read + Seek> ReadSeekSource<R> {
/// Wrap a reader; seeks to the end once to learn the length.
pub fn new(mut r: R) -> io::Result<Self> {
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<R: Read + Seek> ByteSource for ReadSeekSource<R> {
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());
}
}
+203
View File
@@ -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<u8> {
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<u8> {
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<Packet> = 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);
}
+295
View File
@@ -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<u64>,
pub bits_per_channel: Option<u64>,
pub chroma_subsampling_horz: Option<u64>,
pub chroma_subsampling_vert: Option<u64>,
pub cb_subsampling_horz: Option<u64>,
pub cb_subsampling_vert: Option<u64>,
pub chroma_siting_horz: Option<u64>,
pub chroma_siting_vert: Option<u64>,
/// `Range`: 0 unspecified, 1 broadcast (limited), 2 full, 3 defined by matrix/transfer.
pub range: Option<u64>,
/// `TransferCharacteristics` (H.273).
pub transfer_characteristics: Option<u64>,
/// `Primaries` (H.273).
pub primaries: Option<u64>,
/// Maximum content light level (cd/m²).
pub max_cll: Option<u64>,
/// Maximum frame-average light level (cd/m²).
pub max_fall: Option<u64>,
pub mastering: Option<MasteringMetadata>,
}
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<u32>,
pub display_height: Option<u32>,
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<u64>,
pub stereo_mode: Option<u64>,
/// `AlphaMode`: 1 if BlockAdditions carry alpha.
pub alpha_mode: u64,
/// `ColourSpace` FourCC (uncompressed video).
pub colour_space: Option<[u8; 4]>,
pub colour: Option<Colour>,
}
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<f64>,
pub channels: u64,
pub bit_depth: Option<u64>,
}
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<u8>, scope: u64 },
/// Other compression (0 zlib, 1 bzlib, 2 lzo1x): not supported for reading frame data.
Compression { algo: u64, settings: Vec<u8>, 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<u64>,
pub name: String,
/// `Language` (ISO 639-2, default `eng`).
pub language: String,
pub language_bcp47: Option<String>,
pub codec_id: String,
pub codec_private: Vec<u8>,
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<VideoInfo>,
pub audio: Option<AudioInfo>,
pub encodings: Vec<ContentEncoding>,
/// 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<Sample>,
pub(crate) sync: Vec<u32>,
}
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<u8> {
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<u64> {
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<usize> {
if self.samples.is_empty() {
return None;
}
let mut best: Option<usize> = 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<usize> {
let mut best: Option<usize> = 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,
}
}
}
+137
View File
@@ -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<PathBuf> {
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 <i>two</i>\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<Vec<String>> {
let cat = |parts: &[&[&str]]| parts.concat().into_iter().map(String::from).collect::<Vec<_>>();
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<PathBuf> {
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<i64> {
v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))
}
+362
View File
@@ -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<Packet>) {
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<Packet> = d.by_ref().collect::<Result<_>>().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::<f64>().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<u8>, MkvFile, Vec<Packet>)> {
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<usize> = (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<Packet> = d.collect::<Result<_>>().unwrap();
for &mode in modes {
let specs: Vec<TrackSpec> = 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<Packet> = d.collect::<Result<_>>().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<Packet> = d.collect::<Result<_>>().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;
}