Skip to main content

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}