Skip to main content

oxideav_mkv/
lib.rs

1//! Pure-Rust Matroska (MKV/WebM) container.
2//!
3//! Implements the EBML primitives plus enough of the Matroska schema to
4//! demux the audio codecs oxideav already understands (FLAC, Opus, Vorbis,
5//! PCM). The muxer can write back any codec we can carry — there are no
6//! codec-specific assumptions in the container layer.
7//!
8//! WebM is exposed as a first-class peer of Matroska: the demuxer is
9//! shared (the two formats are byte-identical except for the DocType
10//! string and a restricted codec set), but the registry holds separate
11//! `"matroska"` and `"webm"` entries with their own probes and muxer
12//! factories. The WebM muxer enforces the [WebM container
13//! guidelines](https://www.webmproject.org/docs/container/): only VP8,
14//! VP9, AV1 for video and Vorbis, Opus for audio.
15
16pub mod avc;
17pub mod codec_id;
18pub mod demux;
19pub mod ebml;
20pub mod ids;
21pub mod mux;
22
23use oxideav_core::ContainerRegistry;
24
25/// Register both the `"matroska"` and `"webm"` containers.
26///
27/// They share a demuxer factory but each gets its own probe and muxer
28/// factory, so callers asking for `"webm"` explicitly get the WebM muxer
29/// (codec whitelist enforced, `DocType="webm"` in the EBML header) and
30/// callers asking for `"matroska"` get the general muxer.
31pub fn register_containers(reg: &mut ContainerRegistry) {
32    // Matroska entry.
33    reg.register_demuxer("matroska", demux::open);
34    reg.register_muxer("matroska", mux::open);
35    reg.register_probe("matroska", probe_matroska);
36
37    // WebM entry — same demuxer, dedicated muxer and probe.
38    reg.register_demuxer("webm", demux::open);
39    reg.register_muxer("webm", mux::open_webm);
40    reg.register_probe("webm", probe_webm);
41
42    // Extensions.
43    reg.register_extension("mkv", "matroska");
44    reg.register_extension("mka", "matroska");
45    reg.register_extension("mks", "matroska");
46    reg.register_extension("webm", "webm");
47}
48
49/// Install the Matroska / WebM containers into a
50/// [`oxideav_core::RuntimeContext`].
51///
52/// Convenience wrapper around [`register_containers`] that matches the
53/// uniform `register(&mut RuntimeContext)` entry point every sibling
54/// crate exposes.
55///
56/// Also wired into [`oxideav_meta::register_all`] via the
57/// [`oxideav_core::register!`] macro below.
58pub fn register(ctx: &mut oxideav_core::RuntimeContext) {
59    register_containers(&mut ctx.containers);
60}
61
62oxideav_core::register!("mkv", register);
63
64/// EBML signature at offset 0 — common to both Matroska and WebM.
65const EBML_MAGIC: [u8; 4] = [0x1A, 0x45, 0xDF, 0xA3];
66
67/// Probe score returned when the on-disk DocType matches exactly. The
68/// registry picks the highest scorer across all registered probes, so
69/// this needs to beat the "signature-only" fallback score.
70const SCORE_DOCTYPE_MATCH: u8 = 100;
71
72/// Probe score returned when we recognise the EBML signature but the
73/// DocType points at the other flavour (still a valid fallback — either
74/// demuxer can read either flavour).
75const SCORE_SIGNATURE_ONLY: u8 = 60;
76
77/// Matroska probe: high score if DocType reads "matroska", moderate
78/// score on any EBML file (accepts WebM as a fallback).
79fn probe_matroska(p: &oxideav_core::ProbeData) -> u8 {
80    match probe_doctype(p.buf) {
81        DocTypeProbe::Matroska => SCORE_DOCTYPE_MATCH,
82        DocTypeProbe::Webm => SCORE_SIGNATURE_ONLY,
83        DocTypeProbe::EbmlOnly => SCORE_SIGNATURE_ONLY,
84        DocTypeProbe::NotEbml => 0,
85    }
86}
87
88/// WebM probe: high score if DocType reads "webm", zero otherwise so a
89/// plain .mkv does not get reported as webm. (A truly ambiguous case —
90/// EBML magic but no readable DocType — falls through to the matroska
91/// entry, which is the conventional fall-through for ambiguous EBML.)
92fn probe_webm(p: &oxideav_core::ProbeData) -> u8 {
93    match probe_doctype(p.buf) {
94        DocTypeProbe::Webm => SCORE_DOCTYPE_MATCH,
95        DocTypeProbe::Matroska => 0,
96        DocTypeProbe::EbmlOnly => 0,
97        DocTypeProbe::NotEbml => 0,
98    }
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102enum DocTypeProbe {
103    /// EBML signature seen AND DocType reads exactly "matroska".
104    Matroska,
105    /// EBML signature seen AND DocType reads exactly "webm".
106    Webm,
107    /// EBML signature seen, DocType either missing from the buffer or
108    /// set to something else.
109    EbmlOnly,
110    /// No EBML signature at the head of the buffer.
111    NotEbml,
112}
113
114/// Scan `buf` for the EBML magic and, if found, the `DocType` (0x4282)
115/// child element inside the EBML header. Returns a classification the
116/// two probe functions can act on. Does not consume I/O; parses the
117/// buffer in-place.
118fn probe_doctype(buf: &[u8]) -> DocTypeProbe {
119    if buf.len() < 4 || buf[0..4] != EBML_MAGIC {
120        return DocTypeProbe::NotEbml;
121    }
122    // Parse the EBML header size (VINT after the 4-byte ID) and walk its
123    // children looking for DocType (0x4282). If we can't parse it, return
124    // EbmlOnly — neither probe should fail catastrophically on a torn
125    // read.
126    let mut cur = std::io::Cursor::new(&buf[4..]);
127    let (hdr_size, _) = match ebml::read_vint(&mut cur, false) {
128        Ok(v) => v,
129        Err(_) => return DocTypeProbe::EbmlOnly,
130    };
131    let hdr_start = 4 + cur.position() as usize;
132    let hdr_end = hdr_start.saturating_add(hdr_size as usize);
133    let scan_end = hdr_end.min(buf.len());
134    let mut pos = hdr_start;
135    while pos < scan_end {
136        let mut sub = std::io::Cursor::new(&buf[pos..scan_end]);
137        let (id, id_len) = match ebml::read_vint(&mut sub, true) {
138            Ok(v) => v,
139            Err(_) => return DocTypeProbe::EbmlOnly,
140        };
141        let (size, size_len) = match ebml::read_vint(&mut sub, false) {
142            Ok(v) => v,
143            Err(_) => return DocTypeProbe::EbmlOnly,
144        };
145        let data_start = pos + id_len + size_len;
146        let data_end = data_start.saturating_add(size as usize);
147        if data_end > scan_end {
148            return DocTypeProbe::EbmlOnly;
149        }
150        if id == ids::EBML_DOC_TYPE as u64 {
151            let slice = &buf[data_start..data_end];
152            // Strip trailing NULs, the common Matroska padding.
153            let trimmed = slice.split(|&b| b == 0).next().unwrap_or(&[]);
154            return match trimmed {
155                b"matroska" => DocTypeProbe::Matroska,
156                b"webm" => DocTypeProbe::Webm,
157                _ => DocTypeProbe::EbmlOnly,
158            };
159        }
160        pos = data_end;
161    }
162    DocTypeProbe::EbmlOnly
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    /// Build a tiny EBML header with a given DocType plus enough
170    /// surrounding bytes to look like a real file head.
171    fn synth_ebml_head(doc_type: &str) -> Vec<u8> {
172        use crate::ebml::{write_element_id, write_vint};
173        // Body: EBMLVersion(1), DocType(...), DocTypeVersion(2),
174        // DocTypeReadVersion(2).
175        let mut body = Vec::new();
176        // EBMLVersion (0x4286) = 1
177        body.extend_from_slice(&write_element_id(ids::EBML_VERSION));
178        body.extend_from_slice(&write_vint(1, 0));
179        body.push(0x01);
180        // DocType
181        body.extend_from_slice(&write_element_id(ids::EBML_DOC_TYPE));
182        body.extend_from_slice(&write_vint(doc_type.len() as u64, 0));
183        body.extend_from_slice(doc_type.as_bytes());
184        // DocTypeVersion
185        body.extend_from_slice(&write_element_id(ids::EBML_DOC_TYPE_VERSION));
186        body.extend_from_slice(&write_vint(1, 0));
187        body.push(0x02);
188        // DocTypeReadVersion
189        body.extend_from_slice(&write_element_id(ids::EBML_DOC_TYPE_READ_VERSION));
190        body.extend_from_slice(&write_vint(1, 0));
191        body.push(0x02);
192        // Wrap in EBML header element.
193        let mut out = Vec::new();
194        out.extend_from_slice(&write_element_id(ids::EBML_HEADER));
195        out.extend_from_slice(&write_vint(body.len() as u64, 0));
196        out.extend_from_slice(&body);
197        out
198    }
199
200    #[test]
201    fn probe_doctype_webm() {
202        let head = synth_ebml_head("webm");
203        assert_eq!(probe_doctype(&head), DocTypeProbe::Webm);
204    }
205
206    #[test]
207    fn probe_doctype_matroska() {
208        let head = synth_ebml_head("matroska");
209        assert_eq!(probe_doctype(&head), DocTypeProbe::Matroska);
210    }
211
212    #[test]
213    fn probe_doctype_non_ebml() {
214        let buf = b"RIFF....WAVEfmt ";
215        assert_eq!(probe_doctype(buf), DocTypeProbe::NotEbml);
216    }
217
218    #[test]
219    fn webm_probe_prefers_webm() {
220        let head = synth_ebml_head("webm");
221        let p = oxideav_core::ProbeData {
222            buf: &head,
223            ext: None,
224        };
225        let webm_score = probe_webm(&p);
226        let mkv_score = probe_matroska(&p);
227        assert!(
228            webm_score > mkv_score,
229            "webm probe ({}) should outrank matroska probe ({}) on DocType=webm",
230            webm_score,
231            mkv_score
232        );
233        assert_eq!(webm_score, SCORE_DOCTYPE_MATCH);
234    }
235
236    #[test]
237    fn matroska_probe_prefers_matroska() {
238        let head = synth_ebml_head("matroska");
239        let p = oxideav_core::ProbeData {
240            buf: &head,
241            ext: None,
242        };
243        let webm_score = probe_webm(&p);
244        let mkv_score = probe_matroska(&p);
245        assert!(
246            mkv_score > webm_score,
247            "matroska probe ({}) should outrank webm probe ({}) on DocType=matroska",
248            mkv_score,
249            webm_score
250        );
251        assert_eq!(mkv_score, SCORE_DOCTYPE_MATCH);
252        assert_eq!(webm_score, 0);
253    }
254
255    #[test]
256    fn registry_extension_mapping() {
257        let mut reg = ContainerRegistry::new();
258        register_containers(&mut reg);
259        assert_eq!(reg.container_for_extension("webm"), Some("webm"));
260        assert_eq!(reg.container_for_extension("mkv"), Some("matroska"));
261        assert_eq!(reg.container_for_extension("mka"), Some("matroska"));
262        assert_eq!(reg.container_for_extension("mks"), Some("matroska"));
263    }
264
265    #[test]
266    fn register_via_runtime_context_installs_container() {
267        let mut ctx = oxideav_core::RuntimeContext::new();
268        register(&mut ctx);
269        assert_eq!(
270            ctx.containers.container_for_extension("mkv"),
271            Some("matroska")
272        );
273        assert_eq!(ctx.containers.container_for_extension("webm"), Some("webm"));
274    }
275}