container/demux/mod.rs
1/// Demux dispatch + shared types + box-walking primitives.
2///
3/// The full demux implementation is split across concern-scoped submodules:
4/// - `mp4` — ISOBMFF / MP4 / MOV demux, fragmented MP4, streaming init
5/// - `mkv` — Matroska / WebM demux, Colour mapping, EBML scanner, streaming init
6/// - `audio` — audio track extraction for all containers (AAC, Opus, AC-3, …)
7/// - `hdr` — HDR static metadata (`mdcv`/`clli`) pulled from visual sample entries
8/// - `tests` — unit tests (compiled only under `#[cfg(test)]`)
9use anyhow::{bail, Result};
10use codec::frame::StreamInfo;
11
12use crate::avi::demux_avi;
13use crate::ts::demux_ts;
14
15pub mod mp4;
16pub mod mkv;
17pub(crate) mod audio;
18pub(crate) mod hdr;
19
20#[cfg(test)]
21mod tests;
22
23// Re-export every item that was `pub` on the old flat `demux` module so
24// all existing `use crate::demux::…` call-sites remain valid.
25// Public surface (matches the original flat module's `pub` items).
26pub use mp4::{demux_mp4, Mp4StreamingDemuxer};
27pub use mkv::{demux_mkv, probe_mkv_color_info, MkvStreamingDemuxer};
28// Crate-internal entry points for the streaming dispatcher.
29pub(crate) use mkv::demux_mkv_streaming_init;
30pub(crate) use mp4::demux_mp4_streaming_init;
31// The remaining helpers (has_av01_sample_entry, prores_sample_entry_fourcc,
32// parse_avcc_param_sets, FragSample, mkv_codec_needs_annexb, extract_*_audio,
33// {ac3,eac3}_sample_rate_channels_*) were private in the original flat module
34// and stay internal — siblings reach them via `super::<sub>::`.
35
36// ---------------------------------------------------------------------------
37// Public shared types
38// ---------------------------------------------------------------------------
39
40pub struct DemuxResult {
41 pub codec: String,
42 pub info: StreamInfo,
43 pub samples: Vec<Vec<u8>>,
44 /// Optional audio track carried through for passthrough muxing. Populated
45 /// when the input has an AAC track (MP4: `mp4a` sample entry; MKV codec
46 /// id `A_AAC`). Other audio codecs log a warning and are dropped.
47 pub audio: Option<AudioTrack>,
48}
49
50/// Audio track extracted for passthrough or transcode. Supports two codec
51/// families today (Squad-18 + Squad-23):
52/// - **AAC-LC**: `codec = "aac"`, `asc` holds the verbatim
53/// AudioSpecificConfig bytes sourced from the MP4 esds descriptor (not
54/// the mp4 crate's rebuilt form) or MKV `CodecPrivate`, so HE-AAC /
55/// xHE-AAC signaling survives the copy. `codec_private` is empty.
56/// - **Opus**: `codec = "opus"`, `codec_private` holds the RFC 7845 §5.1
57/// `OpusHead` body verbatim — for MKV/WebM that's exactly the
58/// `CodecPrivate` element bytes (post-magic — RFC 7845 §5.2 specifies
59/// no magic prefix for the MKV CodecPrivate); for MP4-Opus that's the
60/// `dOps` body re-serialised in OpusHead's LE numeric convention. `asc`
61/// is empty.
62///
63/// `samples` are codec-native packets (AAC: ADTS-stripped raw access
64/// units; Opus: TOC-prefixed Opus packets, one per frame). `durations`
65/// are per-sample in `timescale` units.
66#[derive(Debug, Clone)]
67pub struct AudioTrack {
68 pub codec: String,
69 pub samples: Vec<Vec<u8>>,
70 pub sample_rate: u32,
71 pub channels: u16,
72 /// AAC-only: AudioSpecificConfig bytes. Empty for non-AAC codecs.
73 pub asc: Vec<u8>,
74 /// Opus-only: OpusHead body bytes (RFC 7845 §5.1). Empty for non-Opus
75 /// codecs. The 8-byte 'OpusHead' magic prefix is NOT included — only
76 /// the post-magic body.
77 pub codec_private: Vec<u8>,
78 pub timescale: u32,
79 pub durations: Vec<u32>,
80}
81
82// ---------------------------------------------------------------------------
83// Public dispatch entry point
84// ---------------------------------------------------------------------------
85
86/// Dispatch to the right demuxer based on container magic bytes.
87pub fn demux(data: &[u8]) -> Result<DemuxResult> {
88 match detect_container(data) {
89 // MOV shares its demuxer with MP4 — same ISOBMFF box tree, same
90 // sample-entry structure. `detect_container` returns "mp4" for
91 // both `ftyp mp4*` and `ftyp qt ` / bare-moov MOVs.
92 "mp4" => demux_mp4(data),
93 "mkv" => demux_mkv(data),
94 "avi" => demux_avi(data),
95 "ts" => demux_ts(data),
96 other => bail!("unsupported container: {other}"),
97 }
98}
99
100pub(crate) fn detect_container(data: &[u8]) -> &'static str {
101 if data.len() < 12 {
102 return "unknown";
103 }
104 // ISOBMFF: MP4 (`ftyp mp41`/`mp42`/`isom`/...) and MOV (`ftyp qt `)
105 // both land here. Older MOV files sometimes ship without a top-level
106 // `ftyp` and lead with `moov` or `mdat` directly — accept those too.
107 if &data[4..8] == b"ftyp" || &data[4..8] == b"moov" || &data[4..8] == b"mdat" {
108 return "mp4";
109 }
110 // Matroska/WebM: EBML signature.
111 if data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3 {
112 return "mkv";
113 }
114 // RIFF-based AVI: "RIFF" <size> "AVI ".
115 if &data[..4] == b"RIFF" && &data[8..12] == b"AVI " {
116 return "avi";
117 }
118 // MPEG-TS: 0x47 sync byte at offset 0 AND at offset 188 (and 376 if
119 // we have the bytes). A single 0x47 appears routinely in random
120 // payloads, so require two confirming hits before committing.
121 if data[0] == 0x47
122 && data.len() > 188
123 && data[188] == 0x47
124 && (data.len() <= 376 || data[376] == 0x47)
125 {
126 return "ts";
127 }
128 "unknown"
129}
130
131// ---------------------------------------------------------------------------
132// Shared box-walking primitives (used by mp4.rs, hdr.rs, audio.rs)
133// ---------------------------------------------------------------------------
134
135/// Follow a box type path from `data` (top level) down and return the body
136/// bytes (payload, excluding the 8-byte box header) of the last box in the
137/// path, or None if any hop is missing. Handles 32-bit box sizes only —
138/// adequate for moov/trak/stsd which are ~KB in practice.
139pub(super) fn find_box_body<'a>(data: &'a [u8], path: &[&[u8; 4]]) -> Option<&'a [u8]> {
140 let mut slice = data;
141 for (i, target) in path.iter().enumerate() {
142 let found = find_direct_child(slice, target)?;
143 if i + 1 == path.len() {
144 return Some(found);
145 }
146 slice = found;
147 }
148 None
149}
150
151pub(super) fn find_direct_child<'a>(data: &'a [u8], target: &[u8; 4]) -> Option<&'a [u8]> {
152 let mut pos = 0;
153 while pos + 8 <= data.len() {
154 let size =
155 u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
156 let btype = &data[pos + 4..pos + 8];
157 if size < 8 || pos.checked_add(size).is_none_or(|end| end > data.len()) {
158 return None;
159 }
160 if btype == target {
161 return Some(&data[pos + 8..pos + size]);
162 }
163 pos += size;
164 }
165 None
166}