container/avi/streaming.rs
1//! `AviStreamingDemuxer` — pull-based streaming AVI demuxer with two
2//! backends: legacy cursor walk (`Backend::Cursor`) and precomputed
3//! OpenDML index walk (`Backend::OpenDml`).
4
5use anyhow::{Context, Result, bail};
6use codec::frame::{ColorSpace, PixelFormat, StreamInfo};
7
8use crate::demux::AudioTrack;
9use crate::streaming::{DemuxHeader, Sample, StreamingDemuxer};
10
11use super::opendml::{locate_stream_indx, parse_ix_chunk, read_avih_total_frames,
12 read_dmlh_total_frames};
13use super::riff::{VideoStream, ascii, find_video_stream, fourcc_to_codec,
14 scan_top_level_records};
15
16// ---------------------------------------------------------------------------
17// Backend enum
18// ---------------------------------------------------------------------------
19
20pub(super) enum Backend {
21 /// Walk one or more `LIST movi` records linearly. The Vec is
22 /// initialised with one entry per top-level movi LIST in file
23 /// order; `rec ` sub-LISTs push additional frames during walk and
24 /// pop at EOF. We always operate on the LAST entry (top of stack).
25 Cursor(Vec<(usize, usize)>),
26 /// Precomputed (absolute_offset_of_chunk_data, data_size) list
27 /// drawn from the indx → ix## chain. `cursor` indexes into it.
28 OpenDml {
29 samples: Vec<(usize, usize)>,
30 cursor: usize,
31 },
32}
33
34// ---------------------------------------------------------------------------
35// AviStreamingDemuxer
36// ---------------------------------------------------------------------------
37
38/// Streaming AVI demuxer. Owns the input bytes and walks the `movi`
39/// LIST(s) one chunk at a time. Two backends:
40/// - **Legacy single-movi cursor walk** (`Backend::Cursor`): a stack of
41/// (pos, end) frames over a single `LIST movi`. `rec ` sub-LISTs push
42/// a new frame; we pop on EOF to resume the parent.
43/// - **OpenDML index walk** (`Backend::OpenDml`): a precomputed list of
44/// `(absolute byte offset, size)` sample chunks assembled from the
45/// stream's `indx` superindex + each `ix##` sub-index. `next_video_sample`
46/// advances `cursor` and reads `data[offset..offset+size]`.
47/// The streaming impl never holds more than the current sample's bytes
48/// regardless of backend.
49pub struct AviStreamingDemuxer {
50 data: Vec<u8>,
51 pub(super) header: DemuxHeader,
52 pub(super) backend: Backend,
53 /// Two-character stream prefix derived from the video stream's
54 /// index. e.g. stream 0 → "00". Only used by the cursor backend.
55 prefix: [u8; 2],
56 /// Frame index — used as a synthetic monotonic PTS in samples-since-
57 /// start. AVI doesn't carry per-sample PTS at the container layer.
58 next_idx: u64,
59 /// Lazily set on first sample: `pixel_format::detect` is one-shot
60 /// against the first sample, so we patch `header.info.pixel_format`
61 /// in place once and skip the probe thereafter.
62 pixel_format_detected: bool,
63}
64
65// ---------------------------------------------------------------------------
66// Construction
67// ---------------------------------------------------------------------------
68
69pub(crate) fn demux_avi_streaming_init(data: &[u8]) -> Result<AviStreamingDemuxer> {
70 if data.len() < 12 || &data[..4] != b"RIFF" || &data[8..12] != b"AVI " {
71 bail!("not a RIFF/AVI file");
72 }
73 let owned = data.to_vec();
74
75 let mut hdrl: Option<(usize, usize)> = None;
76 let mut movi_lists: Vec<(usize, usize)> = Vec::new();
77 scan_top_level_records(&owned, &mut hdrl, &mut movi_lists);
78
79 let (hdrl_start, hdrl_end) = hdrl.context("AVI: missing hdrl LIST")?;
80 if movi_lists.is_empty() {
81 bail!("AVI: missing movi LIST");
82 }
83
84 let video: VideoStream = find_video_stream(&owned[hdrl_start..hdrl_end])
85 .context("AVI: no video stream found in hdrl")?;
86 let codec = fourcc_to_codec(&video.handler)
87 .or_else(|| fourcc_to_codec(&video.compression))
88 .with_context(|| {
89 format!(
90 "AVI: unsupported video fourcc {:?}/{:?}",
91 ascii(&video.handler),
92 ascii(&video.compression)
93 )
94 })?;
95
96 let stream_idx = video.stream_index;
97 let prefix_str = format!("{:02}", stream_idx);
98 let prefix_bytes = prefix_str.as_bytes();
99 if prefix_bytes.len() != 2 {
100 bail!("AVI: stream index out of range");
101 }
102 let prefix = [prefix_bytes[0], prefix_bytes[1]];
103
104 // OpenDML detection: look for an `indx` superindex inside the
105 // chosen stream's `LIST strl`. Presence triggers the ix##-walking
106 // backend; absence falls back to the legacy cursor walk over each
107 // `LIST movi` LIST in order.
108 let backend =
109 if let Some(ix_refs) = locate_stream_indx(&owned[hdrl_start..hdrl_end], stream_idx) {
110 // Each `qwOffset` in ix_refs is an absolute file offset to an
111 // `ix##` chunk's 8-byte header. Parse each in turn and append
112 // its sample chunks to one big list, in superindex order.
113 let mut samples: Vec<(usize, usize)> = Vec::new();
114 for (ix_off, ix_size) in ix_refs {
115 parse_ix_chunk(&owned, ix_off, ix_size, &prefix, &mut samples);
116 }
117 Backend::OpenDml { samples, cursor: 0 }
118 } else {
119 Backend::Cursor(movi_lists)
120 };
121
122 // total_frames priority for the OpenDML era:
123 // 1. `dmlh.dwTotalFrames` inside `LIST hdrl > LIST odml > dmlh`
124 // — the spec-mandated 32-bit count for files that may have
125 // wrapped `avih.dwTotalFrames` (>1 GiB / very long clips).
126 // 2. `avih.dwTotalFrames` for legacy single-RIFF files.
127 // 3. 0 — same "unknown" sentinel as TS (pipeline tolerates).
128 let total_frames = read_dmlh_total_frames(&owned[hdrl_start..hdrl_end])
129 .or_else(|| read_avih_total_frames(&owned[hdrl_start..hdrl_end]))
130 .unwrap_or(0);
131 // Derive duration from total_frames + frame_rate when both are
132 // populated — saves the legacy `samples.len() as f64 / frame_rate`
133 // computation that needed the materialized Vec.
134 let duration = if total_frames > 0 && video.frame_rate > 0.0 {
135 total_frames as f64 / video.frame_rate
136 } else {
137 0.0
138 };
139
140 let info = StreamInfo {
141 codec: codec.clone(),
142 width: video.width,
143 height: video.height,
144 frame_rate: video.frame_rate,
145 duration,
146 pixel_format: PixelFormat::Yuv420p,
147 color_space: ColorSpace::Bt709,
148 color_metadata: Default::default(),
149 total_frames,
150 bitrate: 0,
151 };
152
153 Ok(AviStreamingDemuxer {
154 data: owned,
155 header: DemuxHeader { codec, info },
156 backend,
157 prefix,
158 next_idx: 0,
159 pixel_format_detected: false,
160 })
161}
162
163// ---------------------------------------------------------------------------
164// StreamingDemuxer impl
165// ---------------------------------------------------------------------------
166
167impl StreamingDemuxer for AviStreamingDemuxer {
168 fn header(&self) -> &DemuxHeader {
169 &self.header
170 }
171
172 fn next_video_sample(&mut self) -> Result<Option<Sample>> {
173 let payload_range = match &mut self.backend {
174 Backend::OpenDml { samples, cursor } => {
175 loop {
176 if *cursor >= samples.len() {
177 return Ok(None);
178 }
179 let (off, size) = samples[*cursor];
180 *cursor += 1;
181 let end = off
182 .checked_add(size)
183 .ok_or_else(|| anyhow::anyhow!("AVI: ix## entry overflows usize"))?;
184 if end > self.data.len() {
185 // Truncated tail — skip rather than bail; matches
186 // the cursor-walk's "stop on EOF" posture.
187 continue;
188 }
189 break Some((off, end));
190 }
191 }
192 Backend::Cursor(walk) => {
193 loop {
194 // Pop empty frames off the walk stack.
195 while let Some(&(pos, end)) = walk.last() {
196 if pos + 8 <= end {
197 break;
198 }
199 walk.pop();
200 }
201 let Some(&mut (ref mut pos, end)) = walk.last_mut() else {
202 return Ok(None);
203 };
204
205 let fcc: [u8; 4] = self.data[*pos..*pos + 4].try_into()?;
206 let size = u32::from_le_bytes([
207 self.data[*pos + 4],
208 self.data[*pos + 5],
209 self.data[*pos + 6],
210 self.data[*pos + 7],
211 ]) as usize;
212 let payload_start = *pos + 8;
213 let payload_end = payload_start + size;
214 if payload_end > end || payload_end > self.data.len() {
215 // Truncated — pop this frame and resume parent.
216 walk.pop();
217 continue;
218 }
219
220 // Advance past this chunk on the cursor for the NEXT call.
221 *pos = payload_end + (payload_end & 1);
222
223 if &fcc == b"LIST" && payload_start + 4 <= payload_end {
224 let list_type: [u8; 4] =
225 self.data[payload_start..payload_start + 4].try_into()?;
226 if &list_type == b"rec " {
227 // Push the inner walk frame and recurse.
228 walk.push((payload_start + 4, payload_end));
229 continue;
230 }
231 continue; // unknown LIST — skip
232 }
233
234 if fcc[0] != self.prefix[0] || fcc[1] != self.prefix[1] {
235 continue; // wrong stream
236 }
237 let kind = fcc[3];
238 if kind != b'c' && kind != b'b' {
239 continue; // not a video sample chunk
240 }
241 break Some((payload_start, payload_end));
242 }
243 }
244 };
245 let Some((start, end)) = payload_range else {
246 return Ok(None);
247 };
248
249 let pts_ticks = self.next_idx as i64;
250 self.next_idx += 1;
251 let data = self.data[start..end].to_vec();
252 if !self.pixel_format_detected {
253 let detected =
254 codec::pixel_format::detect(&self.header.codec, std::slice::from_ref(&data));
255 self.header.info.pixel_format = detected;
256 self.pixel_format_detected = true;
257 }
258 Ok(Some(Sample {
259 data,
260 pts_ticks,
261 duration_ticks: 0,
262 }))
263 }
264
265 fn audio(&self) -> Option<&AudioTrack> {
266 // AVI audio passthrough is not supported (the legacy path also
267 // returns audio: None) — out of scope for this sprint.
268 None
269 }
270}