From de1daefe8cafa7ebde12735aebb747683a5d2697 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 15:26:20 -0500 Subject: [PATCH] craft-media: AVI and OpenDML demuxer from FilmCraft (feature `avi`) (#856) Moves filmcraft-avi (storytold/filmcraft@4bec22b) into craft_media::avi. ByteSource is the crate's trait, Error::Io holds the io::Error, and Stream::codec_params() maps the FourCC and the WAVEFORMATEX tag to a CodecId. Adds Container::Avi and tests for the stream, header-size, nesting and OpenDML index limits. --- README.md | 2 +- crates/media/Cargo.toml | 8 +- crates/media/README.md | 61 ++- crates/media/src/avi/mod.rs | 709 +++++++++++++++++++++++++++++++ crates/media/src/avi/tests.rs | 635 +++++++++++++++++++++++++++ crates/media/src/id.rs | 3 + crates/media/src/lib.rs | 5 +- crates/media/tests/oracle_avi.rs | 142 +++++++ 8 files changed, 1556 insertions(+), 9 deletions(-) create mode 100644 crates/media/src/avi/mod.rs create mode 100644 crates/media/src/avi/tests.rs create mode 100644 crates/media/tests/oracle_avi.rs diff --git a/README.md b/README.md index 64aa342e..38a6317a 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "`, `&T`, `Arc`, `Box` and `std::fs::File` (not on wasm). `ReadSeekSource` adapts any `Read + Seek`; it is `Send + Sync` when the reader is `Send`. Implement the trait once for a custom source (a web `Blob`, a network range reader) to use every container with it. diff --git a/crates/media/src/avi/mod.rs b/crates/media/src/avi/mod.rs new file mode 100644 index 00000000..85b504cf --- /dev/null +++ b/crates/media/src/avi/mod.rs @@ -0,0 +1,709 @@ +//! Clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the +//! OpenDML AVI File Format Extensions 1.02. +//! +//! [`open`] reads the `hdrl` header list (`avih`, and per stream `strh` / `strf` / `strn` / +//! `indx`) of the first `RIFF AVI ` and builds a per-stream table of [`Chunk`]s from the best index +//! the file has: the OpenDML super index (`indx` → `ix##` standard indexes, which also cover the +//! `RIFF AVIX` extensions of files past 1 GB), else the AVI 1.0 `idx1` (plus a scan of any `AVIX` +//! extension it cannot see), else a scan of the `movi` lists. Frame data is read on demand with +//! [`AviFile::read_chunk`]. Truncated files (a recording cut off) open with what is there. +//! +//! Every size and count comes from the file and is treated as hostile: reads are bounded by the +//! file length, tables by the number of chunks the file could hold, and nesting by a fixed depth. +//! +//! No dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. +//! +//! From FilmCraft's `filmcraft-avi` (storytold/filmcraft@4bec22b). Public items keep their names. +//! `ByteSource` is the crate's one source trait, and [`Error::Io`] holds the `io::Error`. + +#![warn(missing_docs)] + +#[cfg(test)] +mod tests; + +pub use crate::source::{ByteSource, ReadSeekSource}; + +use std::collections::HashSet; +use std::io; + +/// Errors from reading an AVI file. +#[non_exhaustive] +#[derive(Debug)] +pub enum Error { + /// Not an AVI file (no `RIFF` … `AVI ` header). + NotAvi, + /// A read failed. + Io(io::Error), + /// Structurally invalid data. + Invalid(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotAvi => f.write_str("not an AVI file"), + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::Invalid(e) => write!(f, "invalid AVI: {e}"), + } + } +} + +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 for Error { + fn from(e: io::Error) -> Self { + Error::Io(e) + } +} + +/// Result alias for this module. +pub type Result = std::result::Result; + +/// Whether `head` starts like an AVI file: `RIFF`, a size, `AVI `. +pub fn sniff(head: &[u8]) -> bool { + head.get(..4) == Some(b"RIFF") && head.get(8..12) == Some(b"AVI ") +} + +/// `avih`: the main header (frame period, flags, frame count, size). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MainHeader { + /// `dwMicroSecPerFrame`: the frame period in microseconds. + pub micro_sec_per_frame: u32, + /// `dwFlags` (`AVIF_HASINDEX` 0x10, `AVIF_ISINTERLEAVED` 0x100…). + pub flags: u32, + /// `dwTotalFrames` (of the first RIFF only in an OpenDML file). + pub total_frames: u32, + /// `dwStreams`: the stream count the header declares. + pub streams: u32, + /// Picture width in pixels. + pub width: u32, + /// Picture height in pixels. + pub height: u32, +} + +/// What a stream carries (`fccType` of its `strh`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StreamKind { + /// `vids`. + Video, + /// `auds`. + Audio, + /// Text, MIDI or anything else (`fccType`). + Other([u8; 4]), +} + +/// `strf` of a video stream: a `BITMAPINFOHEADER`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BitmapInfo { + /// `biWidth` in pixels. + pub width: i32, + /// Positive: rows stored bottom-up (uncompressed RGB); negative: top-down. + pub height: i32, + /// `biBitCount`: bits per pixel. + pub bit_count: u16, + /// `biCompression` as four bytes: a FourCC (`MJPG`, `H264`, `YUY2`…) or 0 / 3 (`BI_RGB`, + /// `BI_BITFIELDS`) in little-endian. + pub compression: [u8; 4], + /// `biSizeImage`: bytes of one uncompressed picture, often 0. + pub size_image: u32, + /// Bytes after the 40-byte header (codec setup, palette). + pub extra: Vec, +} + +impl BitmapInfo { + /// Uncompressed RGB (`BI_RGB` / `BI_BITFIELDS`). + pub fn is_rgb(&self) -> bool { + matches!(u32::from_le_bytes(self.compression), 0 | 3) + } +} + +/// `strf` of an audio stream: a `WAVEFORMATEX` (`WAVEFORMATEXTENSIBLE` is resolved to its sub-format). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WaveFormat { + /// 1 PCM, 3 IEEE float, 0x50 MPEG audio, 0x55 MP3, 0x2000 AC-3, 0xFF AAC… + pub format_tag: u16, + /// `nChannels`. + pub channels: u16, + /// `nSamplesPerSec`. + pub sample_rate: u32, + /// `nAvgBytesPerSec`. + pub avg_bytes_per_sec: u32, + /// `nBlockAlign`: bytes per sample frame for PCM. + pub block_align: u16, + /// `wBitsPerSample`. + pub bits_per_sample: u16, + /// `cbSize` bytes after the 18-byte header. + pub extra: Vec, +} + +/// One stored frame or audio chunk. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Chunk { + /// Offset of the data (after the 8-byte chunk header). + pub offset: u64, + /// Bytes of data. + pub size: u32, + /// A key frame (always true for audio and when the index does not say). + pub key: bool, +} + +/// Where the chunk tables came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IndexSource { + /// OpenDML `indx` super index and `ix##` standard indexes. + OpenDml, + /// The AVI 1.0 `idx1` (plus a scan of the `AVIX` extensions). + Idx1, + /// No usable index: the `movi` lists were scanned (key frames unknown). + Scan, +} + +/// One stream: its `strl` headers and its chunk table. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stream { + /// What the stream carries. + pub kind: StreamKind, + /// `fccHandler`. + pub handler: [u8; 4], + /// Time base: `rate / scale` units per second (frames per second for video). + pub scale: u32, + /// See `scale`. + pub rate: u32, + /// Start time in `scale / rate` units. + pub start: u32, + /// Length in `scale / rate` units. + pub length: u32, + /// Bytes per sample for fixed-size samples (PCM block align), 0 when samples vary. + pub sample_size: u32, + /// `strf` of a video stream. + pub video: Option, + /// `strf` of an audio stream. + pub audio: Option, + /// `strn`. + pub name: Option, + /// The stored frames or audio chunks, in file order. + pub chunks: Vec, + /// Whether [`Chunk::key`] comes from an index (a scan cannot tell). + pub keyframes_known: bool, + /// The OpenDML super index entries (offsets of the `ix##` chunks). + super_index: Vec, +} + +impl Stream { + /// What a decoder needs for this stream, in the container-neutral form the codec crates take. + /// + /// The codec comes from `biCompression` (video) or the format tag (audio). `tag` is the video + /// FourCC as stored. `extradata` is the bytes after the format header as stored: AVI keeps + /// H.264 and HEVC as Annex B, so it is not an `avcC` / `hvcC` record, and it may be empty + /// (the parameter sets are then in the stream). Uncompressed video is [`CodecId::Unknown`] + /// with its FourCC (or `BI_RGB` / `BI_BITFIELDS`) in `tag`; see [`BitmapInfo`] for its layout. + /// + /// [`CodecId::Unknown`]: crate::CodecId::Unknown + pub fn codec_params(&self) -> crate::CodecParams { + use crate::CodecId as Id; + if let Some(b) = &self.video { + let mut f = b.compression; + f.make_ascii_uppercase(); + let id = match &f { + b"MJPG" | b"AVRN" | b"LJPG" | b"JPGL" | b"DMB1" | b"JPEG" | b"MJPA" => Id::Mjpeg, + b"H264" | b"X264" | b"AVC1" | b"DAVC" | b"VSSH" => Id::H264, + b"HEVC" | b"H265" | b"HEV1" | b"HVC1" | b"X265" => Id::Hevc, + b"DIVX" | b"DX50" | b"XVID" | b"FMP4" | b"MP4V" | b"M4S2" | b"3IV2" => Id::Mpeg4Video, + b"MPG2" | b"MPEG" | b"MPG1" => Id::Mpeg2Video, + b"VP80" => Id::Vp8, + b"VP90" => Id::Vp9, + b"AV01" => Id::Av1, + [b'A', b'P', b'C' | b'4', _] => Id::ProRes, + b"AVDN" | b"AVDH" => Id::Dnxhd, + _ => Id::Unknown, + }; + return crate::CodecParams::new(id) + .with_tag(b.compression) + .with_extradata(b.extra.clone()) + .with_size(b.width.unsigned_abs(), b.height.unsigned_abs()); + } + let Some(w) = &self.audio else { return crate::CodecParams::new(Id::Unknown) }; + let bits = w.bits_per_sample; + let (id, pcm) = match w.format_tag { + // 8-bit PCM is unsigned, as in WAV + 1 => (Id::Pcm, Some(crate::PcmFormat::int(bits, bits > 8, false))), + 3 => (Id::Pcm, Some(crate::PcmFormat::float(bits, false))), + 2 | 0x11 => (Id::Adpcm, None), + 6 => (Id::ALaw, None), + 7 => (Id::MuLaw, None), + 0x50 => (Id::Mp2, None), + 0x55 => (Id::Mp3, None), + 0xFF | 0x1610 => (Id::Aac, None), + 0x2000 => (Id::Ac3, None), + 0x566F | 0x6750 | 0x6751 => (Id::Vorbis, None), + 0xF1AC => (Id::Flac, None), + _ => (Id::Unknown, None), + }; + let mut p = crate::CodecParams::new(id).with_extradata(w.extra.clone()).with_sample_rate(w.sample_rate).with_channels(w.channels); + p.pcm = pcm; + p + } + + /// Stream index of a `movi` chunk id (`00dc`, `01wb`…): its first two characters. + fn number(id: &[u8; 4]) -> Option { + // ffmpeg-style writers number streams in decimal ("10dc" = 10), Video for Windows in hex + // ("0adc" = 10): a hex letter means hex, two decimal digits mean decimal + let [a, b, ..] = *id; + let radix = if [a, b].iter().any(|c| matches!(c, b'a'..=b'f' | b'A'..=b'F')) { 16 } else { 10 }; + let d = |c: u8| (c as char).to_digit(radix).map(|v| v as usize); + Some(d(a)? * radix as usize + d(b)?) + } +} + +/// An opened AVI file: headers and per-stream chunk tables. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AviFile { + /// `avih`. + pub header: MainHeader, + /// The streams, in `strl` order (at most 64). + pub streams: Vec, + /// Which index the chunk tables came from. + pub index: IndexSource, +} + +/// Options for [`open_with`] (tests reach the fallback indexes with them). +#[derive(Clone, Copy, Debug, Default)] +pub struct OpenOptions { + /// Do not use the OpenDML index. + pub ignore_odml: bool, + /// Do not use `idx1`. + pub ignore_idx1: bool, +} + +/// Headers larger than this are refused (a real `hdrl` is a few kilobytes). +const MAX_HEADER: u64 = 16 << 20; +/// Total OpenDML standard-index bytes accepted while opening one file. This bounds hostile +/// repeated index references without constraining practical large-file indexes. +const MAX_ODML_INDEX_BYTES: u64 = 64 << 20; +/// Streams beyond this are ignored. +const MAX_STREAMS: usize = 64; +/// `LIST rec ` inside `movi` nests once; deeper is refused. +const MAX_DEPTH: u32 = 3; + +fn read(src: &S, at: u64, n: u64) -> Result> { + let end = at.checked_add(n).filter(|e| *e <= src.len()).ok_or_else(|| Error::Invalid(format!("{n} bytes at {at} run past the end")))?; + let len = usize::try_from(end - at).map_err(|_| Error::Invalid("chunk too large".into()))?; + let mut buf = vec![0; len]; + src.read_at(at, &mut buf)?; + Ok(buf) +} + +fn fourcc(b: &[u8], at: usize) -> [u8; 4] { + let mut f = [0; 4]; + if let Some(s) = b.get(at..at + 4) { + f.copy_from_slice(s); + } + f +} + +fn le16(b: &[u8], at: usize) -> u16 { + b.get(at..at + 2).map_or(0, |s| u16::from_le_bytes([s[0], s[1]])) +} + +fn le32(b: &[u8], at: usize) -> u32 { + b.get(at..at + 4).map_or(0, |s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +fn le64(b: &[u8], at: usize) -> u64 { + u64::from(le32(b, at)) | (u64::from(le32(b, at + 4)) << 32) +} + +/// A chunk header at `at`: (id, size, data offset). The size is clamped to the file (a truncated +/// recording keeps what is there). +fn header_at(src: &S, at: u64) -> Result<([u8; 4], u64, u64)> { + let h = read(src, at, 8)?; + let data = at + 8; + let size = u64::from(le32(&h, 4)).min(src.len().saturating_sub(data)); + Ok((fourcc(&h, 0), size, data)) +} + +/// Iterate the chunks of `[from, to)`: (id, size, data offset). Stops at the first unreadable +/// header; every step moves forward, so it ends. A child's size is clamped to its parent's end, +/// so lists never reach past their container: overlapping `movi` or `rec ` lists would otherwise +/// each scan (and record) the same physical chunks again. +fn children(src: &S, from: u64, to: u64) -> Vec<([u8; 4], u64, u64)> { + let mut out = Vec::new(); + let mut at = from; + while at.checked_add(8).is_some_and(|e| e <= to) { + let Ok((id, size, data)) = header_at(src, at) else { break }; + let size = size.min(to.saturating_sub(data)); + out.push((id, size, data)); + // chunks are padded to an even size + let Some(next) = data.checked_add(size).and_then(|e| e.checked_add(size & 1)) else { break }; + if next <= at { + break; + } + at = next; + } + out +} + +/// Open an AVI file. +pub fn open(src: &S) -> Result { + open_with(src, OpenOptions::default()) +} + +/// Open an AVI file, skipping the indexes `opts` names. +pub fn open_with(src: &S, opts: OpenOptions) -> Result { + let head = read(src, 0, 12.min(src.len())).map_err(|_| Error::NotAvi)?; + if !sniff(&head) { + return Err(Error::NotAvi); + } + // the RIFF forms: the first `AVI `, then any `AVIX` extensions (OpenDML) + let mut hdrl = None; + let mut movis: Vec<(u64, u64, u64)> = Vec::new(); // (position of the `movi` id, data start, data end) + let mut idx1 = None; + for (id, size, data) in children(src, 0, src.len()) { + if id != *b"RIFF" || size < 4 { + continue; + } + let end = data + size; + let form = fourcc(&read(src, data, 4)?, 0); + if form != *b"AVI " && form != *b"AVIX" { + continue; + } + for (cid, csize, cdata) in children(src, data + 4, end) { + match &cid { + b"LIST" if csize >= 4 => { + let ltype = fourcc(&read(src, cdata, 4)?, 0); + if ltype == *b"hdrl" && hdrl.is_none() && form == *b"AVI " { + hdrl = Some((cdata + 4, cdata + csize)); + } else if ltype == *b"movi" { + movis.push((cdata, cdata + 4, cdata + csize)); + } + } + b"idx1" if form == *b"AVI " => idx1 = Some((cdata, csize)), + _ => {} + } + } + } + let (hfrom, hto) = hdrl.ok_or_else(|| Error::Invalid("no hdrl header list".into()))?; + if hto - hfrom > MAX_HEADER { + return Err(Error::Invalid("header list too large".into())); + } + let (header, mut streams) = parse_hdrl(src, hfrom, hto)?; + if streams.is_empty() { + return Err(Error::Invalid("no streams".into())); + } + // chunk tables from the best index + let odml = !opts.ignore_odml && streams.iter().any(|s| !s.super_index.is_empty()); + let index = if odml && read_odml(src, &mut streams) { + IndexSource::OpenDml + } else if let (false, Some((at, size)), Some(first)) = (opts.ignore_idx1, idx1, movis.first()) { + clear(&mut streams); + read_idx1(src, &mut streams, at, size, first.0); + if streams.iter().all(|s| s.chunks.is_empty()) { + scan_all(src, &mut streams, &movis) + } else { + scan_missing(src, &mut streams, first); + // idx1 only covers the first RIFF: the AVIX extensions are scanned + for &(_, from, to) in movis.iter().skip(1) { + scan(src, &mut streams, &|_| true, from, to, 0); + } + IndexSource::Idx1 + } + } else { + scan_all(src, &mut streams, &movis) + }; + Ok(AviFile { header, streams, index }) +} + +/// A stream that declares data (`length`) but that `idx1` left without a single chunk while +/// others have theirs (its entries damaged or missing) gets its chunks from a scan of the first +/// `movi` list (the one `idx1` covers), as a file without an index does, before the AVIX +/// extensions add theirs; its key frames are then unknown. The indexed streams keep their +/// tables, and a stream that declares no data is not looked for. +fn scan_missing(src: &S, streams: &mut [Stream], &(_, from, to): &(u64, u64, u64)) { + let missing: Vec = streams.iter().map(|s| s.chunks.is_empty() && s.length > 0).collect(); + if !missing.contains(&true) { + return; + } + scan(src, streams, &|n| missing.get(n) == Some(&true), from, to, 0); + for (s, _) in streams.iter_mut().zip(&missing).filter(|(_, m)| **m) { + s.keyframes_known = false; + } +} + +fn clear(streams: &mut [Stream]) { + for s in streams.iter_mut() { + s.chunks.clear(); + s.keyframes_known = true; + } +} + +fn scan_all(src: &S, streams: &mut [Stream], movis: &[(u64, u64, u64)]) -> IndexSource { + clear(streams); + for &(_, from, to) in movis { + scan(src, streams, &|_| true, from, to, 0); + } + for s in streams.iter_mut() { + s.keyframes_known = false; + } + IndexSource::Scan +} + +fn parse_hdrl(src: &S, from: u64, to: u64) -> Result<(MainHeader, Vec)> { + let mut header = MainHeader::default(); + let mut streams = Vec::new(); + for (id, size, data) in children(src, from, to) { + match &id { + b"avih" => { + let b = read(src, data, size.min(64))?; + header = MainHeader { + micro_sec_per_frame: le32(&b, 0), + flags: le32(&b, 12), + total_frames: le32(&b, 16), + streams: le32(&b, 24), + width: le32(&b, 32), + height: le32(&b, 36), + }; + } + b"LIST" if size >= 4 && streams.len() < MAX_STREAMS && fourcc(&read(src, data, 4)?, 0) == *b"strl" => { + streams.push(parse_strl(src, data + 4, data + size)?); + } + _ => {} + } + } + Ok((header, streams)) +} + +fn parse_strl(src: &S, from: u64, to: u64) -> Result { + let mut s = Stream { + kind: StreamKind::Other([0; 4]), + handler: [0; 4], + scale: 1, + rate: 1, + start: 0, + length: 0, + sample_size: 0, + video: None, + audio: None, + name: None, + chunks: Vec::new(), + keyframes_known: true, + super_index: Vec::new(), + }; + let mut strf = None; + for (id, size, data) in children(src, from, to) { + match &id { + b"strh" => { + let b = read(src, data, size.min(64))?; + s.kind = match &fourcc(&b, 0) { + b"vids" => StreamKind::Video, + b"auds" => StreamKind::Audio, + other => StreamKind::Other(*other), + }; + s.handler = fourcc(&b, 4); + s.scale = le32(&b, 20); + s.rate = le32(&b, 24); + s.start = le32(&b, 28); + s.length = le32(&b, 32); + s.sample_size = le32(&b, 44); + } + b"strf" => strf = Some(read(src, data, size.min(1 << 20))?), + b"strn" => { + let b = read(src, data, size.min(4096))?; + let name = String::from_utf8_lossy(b.split(|&c| c == 0).next().unwrap_or_default()).trim().to_string(); + s.name = (!name.is_empty()).then_some(name); + } + b"indx" => s.super_index = super_index(&read(src, data, size.min(MAX_HEADER))?), + _ => {} + } + } + if let Some(b) = strf { + match s.kind { + StreamKind::Video => { + s.video = Some(BitmapInfo { + width: le32(&b, 4) as i32, + height: le32(&b, 8) as i32, + bit_count: le16(&b, 14), + compression: fourcc(&b, 16), + size_image: le32(&b, 20), + extra: b.get(40.min(b.len())..).unwrap_or_default().to_vec(), + }); + } + StreamKind::Audio => { + let cb = usize::from(le16(&b, 16)); + let extra = b.get(18.min(b.len())..(18 + cb).min(b.len())).unwrap_or_default().to_vec(); + let mut tag = le16(&b, 0); + // WAVE_FORMAT_EXTENSIBLE: the sub-format GUID starts with the real tag + if tag == 0xFFFE && extra.len() >= 22 { + tag = le16(&extra, 6); + } + s.audio = Some(WaveFormat { + format_tag: tag, + channels: le16(&b, 2), + sample_rate: le32(&b, 4), + avg_bytes_per_sec: le32(&b, 8), + block_align: le16(&b, 12), + bits_per_sample: le16(&b, 14), + extra, + }); + } + StreamKind::Other(_) => {} + } + } + if s.scale == 0 || s.rate == 0 { + (s.scale, s.rate) = (1, 1); + } + Ok(s) +} + +/// The `ix##` chunk offsets of an `indx` super index (`AVI_INDEX_OF_INDEXES`, 4 longs per entry). +fn super_index(b: &[u8]) -> Vec { + let longs = usize::from(le16(b, 0)); + let (subtype, itype, n) = (b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0xFF), le32(b, 4) as usize); + if itype != 0 || subtype != 0 || longs != 4 { + return Vec::new(); + } + // header 24 bytes, entries of 16: qwOffset, dwSize, dwDuration + let n = n.min(b.len().saturating_sub(24) / 16); + (0..n).map(|k| le64(b, 24 + k * 16)).filter(|&o| o > 0).collect() +} + +/// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream +/// that has data lacks one or none could be read. +fn read_odml(src: &S, streams: &mut [Stream]) -> bool { + let entry_limit = src.len() / 8; + let mut entries = 0u64; + let mut index_bytes = 0u64; + let mut seen = HashSet::new(); + for s in streams.iter_mut() { + s.chunks.clear(); + s.keyframes_known = true; + for &at in &s.super_index { + // A super index can repeat an offset (even across streams). Read and process a + // standard index once, rather than multiplying its work and allocations. + if !seen.insert(at) { + continue; + } + let Ok((id, size, data)) = header_at(src, at) else { continue }; + if &id[..2] != b"ix" { + continue; + } + // Validate the fixed header before allocating the variable entry body. + if size < 24 || index_bytes.checked_add(24).is_none_or(|n| n > MAX_ODML_INDEX_BYTES) { + return false; + } + let Ok(header) = read(src, data, 24) else { continue }; + index_bytes += 24; + let (longs, subtype, itype) = (usize::from(le16(&header, 0)), header.get(2).copied().unwrap_or(0), header.get(3).copied().unwrap_or(0)); + // AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD) + if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) { + continue; + } + let step = longs * 4; + let n = u64::from(le32(&header, 4)).min(size.saturating_sub(24) / step as u64); + let body = n.saturating_mul(step as u64); + let Some(next_bytes) = index_bytes.checked_add(body) else { return false }; + let Some(next_entries) = entries.checked_add(n) else { return false }; + if next_bytes > MAX_ODML_INDEX_BYTES || next_entries > entry_limit { + return false; + } + let Ok(b) = read(src, data, 24 + body) else { continue }; + index_bytes = next_bytes; + entries = next_entries; + let base = le64(&b, 12); + let n = usize::try_from(n).unwrap_or(0); + for k in 0..n { + let e = 24 + k * step; + let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4)); + let size = raw & 0x7FFF_FFFF; + let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { + continue; + }; + s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 }); + } + } + } + let ok = + streams.iter().any(|s| !s.chunks.is_empty()) && streams.iter().all(|s| !s.super_index.is_empty() || matches!(s.kind, StreamKind::Other(_))); + if !ok { + clear(streams); + } + ok +} + +/// Fill the chunk tables from `idx1` (16-byte entries: id, flags, offset, size). Offsets are +/// relative to the `movi` list's type id, or (some writers) absolute: the first entry decides. +fn read_idx1(src: &S, streams: &mut [Stream], at: u64, size: u64, movi: u64) { + let Ok(b) = read(src, at, size.min(src.len() / 2)) else { return }; + let entries: Vec<&[u8]> = b.as_chunks::<16>().0.iter().map(|e| e.as_slice()).collect(); + let id_at = |pos: u64| read(src, pos, 4).ok().map(|v| fourcc(&v, 0)); + let first = entries.iter().find(|e| le32(e, 4) & 1 == 0); + let relative = match first { + Some(e) => { + let (id, off) = (fourcc(e, 0), u64::from(le32(e, 8))); + id_at(movi + off) == Some(id) || id_at(off) != Some(id) + } + None => true, + }; + let base = if relative { movi } else { 0 }; + for e in entries { + let (id, flags, off, len) = (fourcc(e, 0), le32(e, 4), u64::from(le32(e, 8)), le32(e, 12)); + // AVIIF_LIST entries (`rec ` lists) and palette changes carry no samples + if flags & 1 != 0 || &id[2..] == b"pc" { + continue; + } + let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) else { continue }; + let Some(offset) = + base.checked_add(off).and_then(|o| o.checked_add(8)).filter(|o| o.checked_add(u64::from(len)).is_some_and(|end| end <= src.len())) + else { + continue; + }; + s.chunks.push(Chunk { offset, size: len, key: flags & 0x10 != 0 || s.kind != StreamKind::Video }); + } +} + +/// Append the data chunks of a `movi` list (`[from, to)`) to the tables of the streams `want` +/// picks, in file order. +fn scan(src: &S, streams: &mut [Stream], want: &dyn Fn(usize) -> bool, from: u64, to: u64, depth: u32) { + if depth >= MAX_DEPTH { + return; + } + for (id, size, data) in children(src, from, to) { + if id == *b"LIST" { + if size >= 4 { + scan(src, streams, want, data + 4, data + size, depth + 1); + } + continue; + } + if &id[..2] == b"ix" || &id[2..] == b"pc" { + continue; + } + if let Some(s) = Stream::number(&id).filter(|&n| want(n)).and_then(|n| streams.get_mut(n)) { + s.chunks.push(Chunk { offset: data, size: u32::try_from(size).unwrap_or(u32::MAX), key: true }); + } + } +} + +impl AviFile { + /// The bytes of chunk `i` of stream `stream`. + pub fn read_chunk(&self, src: &S, stream: usize, i: usize) -> Result> { + let c = self.streams.get(stream).and_then(|s| s.chunks.get(i)).ok_or_else(|| Error::Invalid(format!("no chunk {i} in stream {stream}")))?; + read(src, c.offset, u64::from(c.size)) + } + + /// `len` bytes of chunk `i` of stream `stream`, from `off` bytes into it (only those bytes are read). + pub fn read_chunk_range(&self, src: &S, stream: usize, i: usize, off: usize, len: usize) -> Result> { + let c = self.streams.get(stream).and_then(|s| s.chunks.get(i)).ok_or_else(|| Error::Invalid(format!("no chunk {i} in stream {stream}")))?; + let end = off + .checked_add(len) + .filter(|&e| e as u64 <= u64::from(c.size)) + .ok_or_else(|| Error::Invalid(format!("range outside chunk {i} of stream {stream}")))?; + let at = c.offset.checked_add(off as u64).ok_or_else(|| Error::Invalid(format!("chunk {i} offset overflows")))?; + read(src, at, (end - off) as u64) + } +} diff --git a/crates/media/src/avi/tests.rs b/crates/media/src/avi/tests.rs new file mode 100644 index 00000000..9ae5cdf8 --- /dev/null +++ b/crates/media/src/avi/tests.rs @@ -0,0 +1,635 @@ +use super::*; + +#[test] +fn stream_number_supports_decimal_and_hex_ids() { + assert_eq!(Stream::number(b"10dc"), Some(10)); + assert_eq!(Stream::number(b"0adc"), Some(10)); + assert_eq!(Stream::number(b"00dc"), Some(0)); + assert_eq!(Stream::number(b"09wb"), Some(9)); + assert_eq!(Stream::number(b"0Fdc"), Some(15)); + // `from_str_radix` would take a sign; a chunk id never has one + assert_eq!(Stream::number(b"+1dc"), None); + assert_eq!(Stream::number(b"ixdc"), None); +} + +fn chunk(id: &[u8; 4], data: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend_from_slice(&(data.len() as u32).to_le_bytes()); + v.extend_from_slice(data); + if data.len() % 2 == 1 { + v.push(0); + } + v +} + +fn list(ltype: &[u8; 4], body: &[u8]) -> Vec { + let mut d = ltype.to_vec(); + d.extend_from_slice(body); + chunk(b"LIST", &d) +} + +fn strl(video: bool) -> Vec { + let mut strh = vec![0u8; 56]; + strh[..4].copy_from_slice(if video { b"vids" } else { b"auds" }); + strh[4..8].copy_from_slice(if video { b"MJPG" } else { &[0; 4] }); + let (scale, rate, ss) = if video { (1u32, 25u32, 0u32) } else { (4, 192_000, 4) }; + strh[20..24].copy_from_slice(&scale.to_le_bytes()); + strh[24..28].copy_from_slice(&rate.to_le_bytes()); + // declared length: 3 frames, 6 audio blocks + strh[32..36].copy_from_slice(&(if video { 3u32 } else { 6 }).to_le_bytes()); + strh[44..48].copy_from_slice(&ss.to_le_bytes()); + let strf = if video { + let mut b = vec![0u8; 40]; + b[..4].copy_from_slice(&40u32.to_le_bytes()); + b[4..8].copy_from_slice(&64i32.to_le_bytes()); + b[8..12].copy_from_slice(&48i32.to_le_bytes()); + b[14..16].copy_from_slice(&24u16.to_le_bytes()); + b[16..20].copy_from_slice(b"MJPG"); + b + } else { + let mut b = vec![0u8; 18]; + b[..2].copy_from_slice(&1u16.to_le_bytes()); + b[2..4].copy_from_slice(&2u16.to_le_bytes()); + b[4..8].copy_from_slice(&48_000u32.to_le_bytes()); + b[8..12].copy_from_slice(&192_000u32.to_le_bytes()); + b[12..14].copy_from_slice(&4u16.to_le_bytes()); + b[14..16].copy_from_slice(&16u16.to_le_bytes()); + b + }; + let mut body = chunk(b"strh", &strh); + body.extend(chunk(b"strf", &strf)); + body.extend(chunk(b"strn", if video { b"Camera\0" } else { b"Mic\0" })); + list(b"strl", &body) +} + +/// A small AVI: 3 video frames (the second a delta, the third odd-sized) and 2 audio chunks, +/// optionally inside a `rec ` list, with an `idx1` whose offsets are relative to `movi` or absolute. +fn avi(idx1: bool, absolute: bool, rec: bool) -> Vec { + let mut avih = vec![0u8; 56]; + avih[0..4].copy_from_slice(&40_000u32.to_le_bytes()); + avih[12..16].copy_from_slice(&0x10u32.to_le_bytes()); + avih[16..20].copy_from_slice(&3u32.to_le_bytes()); + avih[24..28].copy_from_slice(&2u32.to_le_bytes()); + avih[32..36].copy_from_slice(&64u32.to_le_bytes()); + avih[36..40].copy_from_slice(&48u32.to_le_bytes()); + let mut hdrl = chunk(b"avih", &avih); + hdrl.extend(strl(true)); + hdrl.extend(strl(false)); + let hdrl = list(b"hdrl", &hdrl); + let samples: [(&[u8; 4], Vec, bool); 5] = [ + (b"00dc", vec![1; 10], true), + (b"01wb", vec![2; 16], true), + (b"00dc", vec![3; 6], false), + (b"01wb", vec![4; 8], true), + (b"00dc", vec![5; 7], true), + ]; + let mut body = Vec::new(); + let mut entries = Vec::new(); + for (id, data, key) in &samples { + entries.push((**id, body.len() as u32 + 4, data.len() as u32, *key)); + body.extend(chunk(id, data)); + } + let movi_body = if rec { + let r = list(b"rec ", &body); + // the samples moved 12 bytes in (the rec list's header and type) + for e in &mut entries { + e.1 += 12; + } + r + } else { + body + }; + let movi = list(b"movi", &movi_body); + let mut riff = b"AVI ".to_vec(); + riff.extend(hdrl); + // position of the `movi` type id: after `RIFF` , what `riff` holds so far, `LIST` + let movi_at = (8 + riff.len() + 8) as u32; + riff.extend(movi); + if idx1 { + let mut ix = Vec::new(); + for (id, off, len, key) in entries { + ix.extend_from_slice(&id); + ix.extend_from_slice(&(if key { 0x10u32 } else { 0 }).to_le_bytes()); + ix.extend_from_slice(&(if absolute { off + movi_at } else { off }).to_le_bytes()); + ix.extend_from_slice(&len.to_le_bytes()); + } + riff.extend(chunk(b"idx1", &ix)); + } + chunk(b"RIFF", &riff) +} + +fn table(f: &AviFile, s: usize) -> Vec<(u32, bool)> { + f.streams[s].chunks.iter().map(|c| (c.size, c.key)).collect() +} + +#[test] +fn headers_and_every_index_path_agree() { + for (idx1, absolute, rec, want) in [ + (true, false, false, IndexSource::Idx1), + (true, true, false, IndexSource::Idx1), + (true, false, true, IndexSource::Idx1), + (false, false, false, IndexSource::Scan), + (false, false, true, IndexSource::Scan), + ] { + let file = avi(idx1, absolute, rec); + let f = open(&file).unwrap(); + let what = format!("idx1 {idx1} absolute {absolute} rec {rec}"); + assert_eq!(f.index, want, "{what}"); + assert_eq!((f.header.width, f.header.height, f.header.total_frames, f.header.micro_sec_per_frame), (64, 48, 3, 40_000)); + let (v, a) = (&f.streams[0], &f.streams[1]); + assert_eq!((v.kind, v.handler, v.scale, v.rate, v.name.as_deref()), (StreamKind::Video, *b"MJPG", 1, 25, Some("Camera"))); + assert_eq!(v.video.as_ref().map(|b| (b.width, b.height, b.bit_count, b.compression)), Some((64, 48, 24, *b"MJPG"))); + let w = a.audio.as_ref().unwrap(); + assert_eq!((w.format_tag, w.channels, w.sample_rate, w.block_align, w.bits_per_sample, a.sample_size), (1, 2, 48_000, 4, 16, 4)); + // the index knows the delta frame; a scan cannot + let keys = if idx1 { [true, false, true] } else { [true; 3] }; + assert_eq!(table(&f, 0), vec![(10, keys[0]), (6, keys[1]), (7, keys[2])], "{what}"); + assert_eq!(f.streams[0].keyframes_known, idx1); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)], "{what}"); + assert_eq!(f.read_chunk(&file, 0, 1).unwrap(), vec![3; 6], "{what}"); + assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7], "{what}"); + assert_eq!(f.read_chunk(&file, 1, 1).unwrap(), vec![4; 8], "{what}"); + assert!(f.read_chunk(&file, 0, 3).is_err() && f.read_chunk(&file, 5, 0).is_err()); + } + // the idx1 path can be skipped + let f = open_with(&avi(true, false, false), OpenOptions { ignore_idx1: true, ..Default::default() }).unwrap(); + assert_eq!(f.index, IndexSource::Scan); +} + +#[test] +fn not_avi_and_truncated_recordings() { + assert!(matches!(open(&b"RIFF\0\0\0\0WAVEfmt ".to_vec()), Err(Error::NotAvi))); + assert!(matches!(open(&Vec::new()), Err(Error::NotAvi))); + assert!(sniff(&avi(true, false, false)) && !sniff(b"RIFF")); + // a recording cut off in its last frame: the headers and the whole frames are there + let full = avi(false, false, false); + let cut = full[..full.len() - 4].to_vec(); + let f = open(&cut).unwrap(); + assert_eq!(f.streams[0].chunks.len(), 3); + assert!(f.read_chunk(&cut, 0, 0).is_ok()); +} + +/// The idx1 entries of one stream damaged (offsets past the file) while the other's are fine: the +/// video still comes from a scan of `movi` instead of vanishing, its key frames unknown, and the +/// audio keeps its index. A stream with no data at all stays empty. +#[test] +fn a_stream_whose_index_entries_are_damaged_is_scanned() { + let mut file = avi(true, false, false); + let ix = file.windows(4).rposition(|w| w == b"idx1").unwrap() + 8; + for e in file[ix..].as_chunks_mut::<16>().0.iter_mut().filter(|e| &e[..4] == b"00dc") { + e[8..12].copy_from_slice(&0xFFFF_FF00u32.to_le_bytes()); + } + let f = open(&file).unwrap(); + assert_eq!(f.index, IndexSource::Idx1); + assert_eq!(table(&f, 0), vec![(10, true), (6, true), (7, true)]); + assert!(!f.streams[0].keyframes_known); + assert_eq!(f.read_chunk(&file, 0, 1).unwrap(), vec![3; 6]); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)]); + assert!(f.streams[1].keyframes_known); + // with an AVIX extension after it: the first RIFF's frames come first, then the extension's + let mut extended = file.clone(); + extended.extend(chunk(b"RIFF", &[b"AVIX".to_vec(), list(b"movi", &chunk(b"00dc", &[6; 9]))].concat())); + let f = open(&extended).unwrap(); + assert_eq!(table(&f, 0), vec![(10, true), (6, true), (7, true), (9, true)]); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)]); + // an audio-only recording of a file that declares video: nothing to find + let mut silent = avi(true, false, false); + while let Some(i) = silent.windows(4).position(|w| w == b"00dc") { + silent[i..i + 4].copy_from_slice(b"JUNK"); + } + let f = open(&silent).unwrap(); + assert!(f.streams[0].chunks.is_empty()); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)]); +} + +/// A healthy file whose index leaves a stream empty because it declares no data (an audio track +/// that never recorded a sample) opens from the index alone: the stream is not looked for in +/// `movi`, which a large file would pay for with a read per chunk. +#[test] +fn an_empty_stream_that_declares_no_data_is_not_scanned() { + struct Counted<'a>(&'a [u8], std::cell::Cell); + impl ByteSource for Counted<'_> { + fn len(&self) -> u64 { + self.0.len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.1.set(self.1.get() + 1); + self.0.read_at(offset, buf) + } + } + let open_counted = |file: &[u8]| { + let src = Counted(file, Default::default()); + (open(&src).unwrap(), src.1.get()) + }; + let intact = avi(true, false, false); + // the audio's index entries gone and its length zero; its chunks stay where a scan would find them + let mut file = intact.clone(); + let ix = file.windows(4).rposition(|w| w == b"idx1").unwrap() + 8; + for e in file[ix..].as_chunks_mut::<16>().0.iter_mut().filter(|e| &e[..4] == b"01wb") { + e[..4].copy_from_slice(b"JUNK"); + } + let strh = file.windows(4).enumerate().filter(|(_, w)| w == b"strh").nth(1).unwrap().0 + 8; + file[strh + 32..strh + 36].fill(0); + let (f, reads) = open_counted(&file); + assert_eq!(f.index, IndexSource::Idx1); + assert_eq!(table(&f, 0), vec![(10, true), (6, false), (7, true)]); + assert!(f.streams[1].chunks.is_empty() && f.streams[1].keyframes_known); + assert_eq!(reads, open_counted(&intact).1); +} + +/// Every single-byte change and every truncation of a valid file opens or fails, never panics, and +/// whatever opens reads its chunks without panicking. +#[test] +fn mutations_never_panic() { + for base in [avi(true, false, true), avi(false, false, false), avi(true, true, false)] { + for cut in 0..base.len() { + let b = base[..cut].to_vec(); + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for i in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, i); + } + } + } + } + for at in 0..base.len() { + for v in [0x00u8, 0xFF, 0x7F, 0x80] { + let mut b = base.clone(); + b[at] = v; + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for i in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, i); + } + } + } + } + } + } +} + +/// An OpenDML file: each stream's `indx` super index points at an `ix##` standard index (base +/// offset + offsets to the data, bit 31 of the size marking delta frames). Built twice: the second +/// pass knows where the `ix##` chunks landed. +fn avi_odml(ix_at: [u64; 2]) -> (Vec, [u64; 2]) { + let indx = |stream: u8, at: u64| { + let mut b = Vec::new(); + b.extend_from_slice(&4u16.to_le_bytes()); // wLongsPerEntry + b.extend_from_slice(&[0, 0]); // subtype, AVI_INDEX_OF_INDEXES + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&[b'0', b'0' + stream, if stream == 0 { b'd' } else { b'w' }, if stream == 0 { b'c' } else { b'b' }]); + b.extend_from_slice(&[0; 12]); + b.extend_from_slice(&at.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + chunk(b"indx", &b) + }; + let mut hdrl = chunk(b"avih", &[0; 56]); + for (s, at) in ix_at.iter().enumerate() { + let l = strl(s == 0); + // strl = LIST strl …: append indx inside it + let mut body = l[12..].to_vec(); + body.extend(indx(s as u8, *at)); + hdrl.extend(list(b"strl", &body)); + } + let mut riff = b"AVI ".to_vec(); + riff.extend(list(b"hdrl", &hdrl)); + // movi: video 10 (key) 6 (delta) 7 (key), audio 16 8; then ix00, ix01 + let movi_data_at = 8 + riff.len() as u64 + 12; // RIFF hdr, so far, LIST hdr + `movi` + let mut body = Vec::new(); + let mut at: [Vec<(u64, u32, bool)>; 2] = [Vec::new(), Vec::new()]; + for (s, data, key) in + [(0usize, vec![1u8; 10], true), (1, vec![2; 16], true), (0, vec![3; 6], false), (1, vec![4; 8], true), (0, vec![5; 7], true)] + { + let id = if s == 0 { b"00dc" } else { b"01wb" }; + at[s].push((movi_data_at + body.len() as u64 + 8, data.len() as u32, key)); + body.extend(chunk(id, &data)); + } + let mut where_ix = [0u64; 2]; + for (s, entries) in at.iter().enumerate() { + let base = 100u64; // offsets are relative to qwBaseOffset + let mut b = Vec::new(); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&[0, 1]); // subtype 0, AVI_INDEX_OF_CHUNKS + b.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + b.extend_from_slice(if s == 0 { b"00dc" } else { b"01wb" }); + b.extend_from_slice(&base.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + for &(off, size, key) in entries { + b.extend_from_slice(&((off - base) as u32).to_le_bytes()); + b.extend_from_slice(&(size | if key { 0 } else { 0x8000_0000 }).to_le_bytes()); + } + where_ix[s] = movi_data_at + body.len() as u64; + body.extend(chunk(if s == 0 { b"ix00" } else { b"ix01" }, &b)); + } + riff.extend(list(b"movi", &body)); + (chunk(b"RIFF", &riff), where_ix) +} + +#[test] +fn opendml_standard_indexes() { + let (_, at) = avi_odml([0, 0]); + let (file, again) = avi_odml(at); + assert_eq!(at, again, "the layout does not depend on the offsets"); + let f = open(&file).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(table(&f, 0), vec![(10, true), (6, false), (7, true)]); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)]); + assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7]); + // ignoring the OpenDML index, the scan finds the same chunks (and skips the ix## chunks) + let s = open_with(&file, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap(); + assert_eq!(s.index, IndexSource::Scan); + assert_eq!(s.streams[0].chunks.iter().map(|c| c.offset).collect::>(), f.streams[0].chunks.iter().map(|c| c.offset).collect::>()); + // a broken super index (pointing nowhere) falls back to a scan + let (bad, _) = avi_odml([3, 5]); + assert_eq!(open(&bad).unwrap().index, IndexSource::Scan); + // and mutations of the OpenDML file never panic + for i in 0..file.len() { + let mut b = file.clone(); + b[i] ^= 0xFF; + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for k in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, k); + } + } + } + } +} + +#[test] +fn opendml_repeated_index_offsets_are_read_once() { + let (_, at) = avi_odml([0, 0]); + let (file, _) = avi_odml(at); + let mut streams = open(&file).unwrap().streams; + // A malicious super index repeats each valid standard-index address. It must not multiply + // its chunks, allocations, or traversal work. + for (stream, &index) in streams.iter_mut().zip(&at) { + stream.super_index = vec![index; 100]; + } + assert!(read_odml(&file, &mut streams)); + assert_eq!(streams[0].chunks.len(), 3); + assert_eq!(streams[1].chunks.len(), 2); +} + +/// Tiny `AVIX` forms whose `movi` lists claim the rest of the file: each list is held to its +/// form, so the chunks after them are neither recorded once per list nor recorded at all. +#[test] +fn lists_do_not_reach_past_their_container() { + let mut file = avi(false, false, false); + let forms = 64; + for _ in 0..forms { + let mut riff = b"RIFF".to_vec(); + riff.extend_from_slice(&16u32.to_le_bytes()); + riff.extend_from_slice(b"AVIX"); + riff.extend_from_slice(b"LIST"); + riff.extend_from_slice(&0xFFFF_FFF0u32.to_le_bytes()); + riff.extend_from_slice(b"movi"); + file.extend(riff); + } + for _ in 0..32 { + file.extend(chunk(b"00dc", &[9; 8])); + } + let f = open(&file).unwrap(); + assert_eq!(f.index, IndexSource::Scan); + assert_eq!(table(&f, 0), vec![(10, true), (6, true), (7, true)]); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)]); + + // `rec ` lists inside one `movi` that each claim the rest of it + let mut body = Vec::new(); + for _ in 0..64 { + // LIST(16) rec { LIST(huge) rec … } + body.extend_from_slice(b"LIST"); + body.extend_from_slice(&16u32.to_le_bytes()); + body.extend_from_slice(b"rec LIST"); + body.extend_from_slice(&0xFFFF_FFF0u32.to_le_bytes()); + body.extend_from_slice(b"rec "); + } + for _ in 0..32 { + body.extend(chunk(b"00dc", &[9; 8])); + } + let mut streams = open(&avi(false, false, false)).unwrap().streams; + clear(&mut streams); + let movi = list(b"movi", &body); + scan(&movi, &mut streams, &|_| true, 12, movi.len() as u64, 0); + assert_eq!(streams[0].chunks.len(), 32); +} + +#[test] +fn read_chunk_range_reads_only_the_range_and_refuses_one_outside_the_chunk() { + let data: Vec = (0..100).collect(); + let stream = Stream { + kind: StreamKind::Audio, + handler: [0; 4], + scale: 1, + rate: 1, + start: 0, + length: 0, + sample_size: 0, + video: None, + audio: None, + name: None, + chunks: vec![Chunk { offset: 10, size: 50, key: true }], + keyframes_known: false, + super_index: Vec::new(), + }; + let file = AviFile { header: MainHeader::default(), streams: vec![stream], index: IndexSource::Scan }; + assert_eq!(file.read_chunk_range(&data, 0, 0, 5, 3).unwrap(), [15, 16, 17]); + assert_eq!(file.read_chunk_range(&data, 0, 0, 0, 50).unwrap().len(), 50); + assert!(file.read_chunk_range(&data, 0, 0, 48, 3).is_err(), "past the chunk's size"); + assert!(file.read_chunk_range(&data, 0, 0, usize::MAX, 2).is_err()); + assert!(file.read_chunk_range(&data, 0, 1, 0, 1).is_err()); +} + +/// A file with the given `hdrl` body and one video chunk in `movi`. +fn avi_with_hdrl(hdrl: &[u8]) -> Vec { + let mut riff = b"AVI ".to_vec(); + riff.extend(list(b"hdrl", hdrl)); + riff.extend(list(b"movi", &chunk(b"00dc", &[1; 4]))); + chunk(b"RIFF", &riff) +} + +#[test] +fn streams_past_the_limit_are_ignored() { + let hdrl: Vec = (0..MAX_STREAMS + 6).flat_map(|_| strl(true)).collect(); + let f = open(&avi_with_hdrl(&hdrl)).unwrap(); + assert_eq!(f.streams.len(), MAX_STREAMS); +} + +#[test] +fn a_header_list_past_the_limit_is_refused() { + let mut hdrl = strl(true); + hdrl.extend(chunk(b"JUNK", &vec![0; MAX_HEADER as usize - hdrl.len() - 8])); + assert!(open(&avi_with_hdrl(&hdrl)).is_ok(), "exactly the limit"); + hdrl.extend(chunk(b"JUNK", &[0; 2])); + assert!(matches!(open(&avi_with_hdrl(&hdrl)), Err(Error::Invalid(_)))); +} + +#[test] +fn rec_lists_nested_past_the_limit_are_not_scanned() { + let nested = |depth: u32| { + let mut body = chunk(b"00dc", &[7; 4]); + for _ in 0..depth { + body = list(b"rec ", &body); + } + let mut riff = b"AVI ".to_vec(); + riff.extend(list(b"hdrl", &strl(true))); + riff.extend(list(b"movi", &body)); + open(&chunk(b"RIFF", &riff)).unwrap().streams[0].chunks.len() + }; + // `movi` itself is depth 0 + assert_eq!(nested(MAX_DEPTH - 1), 1); + assert_eq!(nested(MAX_DEPTH), 0); + assert_eq!(nested(500), 0); +} + +/// A source that is `.1` long and zero after its real bytes, and counts the bytes read. +struct Sparse(Vec, u64, std::cell::Cell); + +impl ByteSource for Sparse { + fn len(&self) -> u64 { + self.1 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.2.set(self.2.get() + buf.len() as u64); + buf.fill(0); + let real = self.0.get(offset.min(self.0.len() as u64) as usize..).unwrap_or_default(); + let n = real.len().min(buf.len()); + buf[..n].copy_from_slice(&real[..n]); + Ok(()) + } +} + +/// A standard index that declares more entries than the index budget is not read (nor +/// allocated): the file opens from a scan. +#[test] +fn opendml_indexes_past_the_byte_budget_are_not_read() { + let (_, at) = avi_odml([0, 0]); + let (mut file, _) = avi_odml(at); + // the video's ix00: claim the rest of a 1 GiB file, full of entries + let ix = at[0] as usize; + file[ix + 4..ix + 8].copy_from_slice(&0x3000_0000u32.to_le_bytes()); + file[ix + 12..ix + 16].copy_from_slice(&((MAX_ODML_INDEX_BYTES / 8) as u32 + 1).to_le_bytes()); + let mut streams = open(&file).unwrap().streams; + let src = Sparse(file, 1 << 30, Default::default()); + assert!(!read_odml(&src, &mut streams)); + assert!(src.2.get() < 1024, "only headers were read, not {} bytes", src.2.get()); + assert!(streams[0].chunks.is_empty()); + // one entry fewer fits the budget in bytes (and is then read) + let mut file = src.0; + file[ix + 12..ix + 16].copy_from_slice(&(((MAX_ODML_INDEX_BYTES - 24) / 8) as u32).to_le_bytes()); + let src = Sparse(file, 1 << 30, Default::default()); + read_odml(&src, &mut streams); + assert!(src.2.get() >= MAX_ODML_INDEX_BYTES); +} + +#[test] +fn codec_params_map_fourccs_and_format_tags() { + use crate::{CodecId, PcmFormat}; + let f = open(&avi(true, false, false)).unwrap(); + let v = f.streams[0].codec_params(); + assert_eq!((v.codec, v.tag, v.width, v.height), (CodecId::Mjpeg, Some(*b"MJPG"), Some(64), Some(48))); + let a = f.streams[1].codec_params(); + assert_eq!((a.codec, a.sample_rate, a.channels, a.pcm), (CodecId::Pcm, Some(48_000), Some(2), Some(PcmFormat::int(16, true, false)))); + let mut s = f.streams[0].clone(); + let video = |s: &mut Stream, fourcc: &[u8; 4], height: i32| { + let b = s.video.as_mut().unwrap(); + (b.compression, b.height, b.extra) = (*fourcc, height, vec![1, 2]); + s.codec_params() + }; + for (fourcc, codec) in [ + (b"h264", CodecId::H264), + (b"X264", CodecId::H264), + (b"hvc1", CodecId::Hevc), + (b"XVID", CodecId::Mpeg4Video), + (b"apch", CodecId::ProRes), + (b"AVdn", CodecId::Dnxhd), + (b"YUY2", CodecId::Unknown), + (&[0; 4], CodecId::Unknown), + ] { + let p = video(&mut s, fourcc, -48); + assert_eq!((p.codec, p.tag, p.height, p.extradata.as_slice()), (codec, Some(*fourcc), Some(48), &[1u8, 2][..]), "{fourcc:?}"); + } + assert_eq!(video(&mut s, b"MJPG", i32::MIN).height, Some(1 << 31)); + let mut s = f.streams[1].clone(); + for (tag, bits, codec, pcm) in [ + (1, 8, CodecId::Pcm, Some(PcmFormat::int(8, false, false))), + (3, 32, CodecId::Pcm, Some(PcmFormat::float(32, false))), + (0x55, 0, CodecId::Mp3, None), + (0x50, 0, CodecId::Mp2, None), + (0x2000, 0, CodecId::Ac3, None), + (0xFF, 0, CodecId::Aac, None), + (0x1234, 0, CodecId::Unknown, None), + ] { + let w = s.audio.as_mut().unwrap(); + (w.format_tag, w.bits_per_sample) = (tag, bits); + let p = s.codec_params(); + assert_eq!((p.codec, p.pcm), (codec, pcm), "tag {tag:#x}"); + } + // a stream that is neither + s.audio = None; + assert_eq!(s.codec_params().codec, CodecId::Unknown); +} + +/// `WAVE_FORMAT_EXTENSIBLE` resolves to the tag its sub-format GUID starts with. +#[test] +fn wave_format_extensible_resolves_to_its_sub_format() { + let file = avi(true, false, false); + let strf = file.windows(4).enumerate().filter(|(_, w)| w == b"strf").nth(1).unwrap().0; + // an 18-byte WAVEFORMATEX becomes 40 bytes: cbSize 22, then valid bits, mask, GUID + let mut ext = file[strf + 8..strf + 26].to_vec(); + ext[..2].copy_from_slice(&0xFFFEu16.to_le_bytes()); + ext[16..18].copy_from_slice(&22u16.to_le_bytes()); + ext.extend_from_slice(&[16, 0, 3, 0, 0, 0]); + ext.extend_from_slice(&[3, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xAA, 0, 0x38, 0x9B, 0x71]); + // rebuild the stream list around the longer strf + let strl_at = file.windows(4).enumerate().filter(|(_, w)| w == b"strl").nth(1).unwrap().0 - 8; + let old = le32(&file, strl_at + 4) as usize; + let mut body = file[strl_at + 12..strf].to_vec(); + body.extend(chunk(b"strf", &ext)); + body.extend_from_slice(&file[strf + 26..strl_at + 8 + old]); + let mut hdrl = chunk(b"avih", &[0; 56]); + hdrl.extend(strl(true)); + hdrl.extend(list(b"strl", &body)); + let f = open(&avi_with_hdrl(&hdrl)).unwrap(); + let w = f.streams[1].audio.as_ref().unwrap(); + assert_eq!((w.format_tag, w.extra.len()), (3, 22)); + assert_eq!(f.streams[1].codec_params().codec, crate::CodecId::Pcm); +} + +/// Random multi-byte damage to every kind of file (including OpenDML): opening, reading every +/// chunk and mapping the codecs never panics. +#[test] +fn random_damage_never_panics() { + let (_, at) = avi_odml([0, 0]); + let mut state = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for base in [avi(true, false, true), avi(false, false, false), avi(true, true, false), avi_odml(at).0] { + for _ in 0..4000 { + let mut b = base.clone(); + for _ in 0..1 + next() % 6 { + let at = (next() % b.len() as u64) as usize; + // whole 32-bit fields as often as single bytes: sizes, counts and offsets + let v = [0u32, 1, 0x7FFF_FFFF, 0x8000_0000, 0xFFFF_FFFF, next() as u32][(next() % 6) as usize].to_le_bytes(); + let n = if next() % 2 == 0 { 1 } else { 4 }; + for (d, s) in b[at..].iter_mut().zip(&v[..n]) { + *d = *s; + } + } + b.truncate(b.len() - (next() % 3) as usize * (next() % 40) as usize); + let Ok(f) = open(&b) else { continue }; + for (s, st) in f.streams.iter().enumerate() { + let _ = st.codec_params(); + for i in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, i); + let _ = f.read_chunk_range(&b, s, i, 1, usize::MAX); + } + } + } + } +} diff --git a/crates/media/src/id.rs b/crates/media/src/id.rs index d391d708..982acfd2 100644 --- a/crates/media/src/id.rs +++ b/crates/media/src/id.rs @@ -36,6 +36,8 @@ pub enum Container { MpegAudio, /// A raw AC-3 or E-AC-3 stream. Ac3, + /// AVI (RIFF), including OpenDML. + Avi, } impl Container { @@ -54,6 +56,7 @@ impl Container { Container::Adts => "ADTS", Container::MpegAudio => "MPEG audio", Container::Ac3 => "AC-3", + Container::Avi => "AVI", } } } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index d9d550c3..9a6a872a 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -8,12 +8,15 @@ //! QuickTime (MOV) demuxer and muxer, and a box reader for other ISO BMFF formats. //! - `matroska` (feature `matroska`): a clean-room Matroska (MKV) and WebM demuxer and muxer. //! - `ogg` (feature `ogg`): a clean-room Ogg demuxer with Ogg Opus and Ogg Vorbis stream mapping. +//! - `avi` (feature `avi`): a clean-room AVI (RIFF) and OpenDML demuxer. //! - [`Container`] and [`CodecId`] (no feature): the identifiers the codec crates report a file's //! container and codec with. //! -//! `bitstream` and `isobmff` are on by default; `matroska` and `ogg` are opt-in. A crate that needs +//! `bitstream` and `isobmff` are on by default; `matroska`, `ogg` and `avi` are opt-in. A crate that needs //! only bit I/O uses `default-features = false, features = ["bitstream"]`. +#[cfg(feature = "avi")] +pub mod avi; #[cfg(feature = "bitstream")] pub mod bitstream; mod id; diff --git a/crates/media/tests/oracle_avi.rs b/crates/media/tests/oracle_avi.rs new file mode 100644 index 00000000..88415610 --- /dev/null +++ b/crates/media/tests/oracle_avi.rs @@ -0,0 +1,142 @@ +//! ffmpeg-made AVI files (skipped without ffmpeg): the chunk tables match ffprobe's packet counts, +//! and every index the file has (`idx1`; OpenDML once a file passes 1 GB) and a `movi` scan describe +//! the same chunks. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use craft_media::avi::{IndexSource, OpenOptions, StreamKind, open, open_with}; + +fn fixture(name: &str, args: &[&str]) -> Option { + let ffmpeg = craft_media_testkit::ffmpeg_or_skip("avi oracle")?; + let out = craft_media_testkit::fixtures_dir("avi").join(name); + craft_media_testkit::fixtures::generate(&out, |tmp: &Path| { + let mut c = Command::new(&ffmpeg); + c.args(["-v", "error", "-y"]).args(args).args(["-f", "avi"]).arg(tmp); + c.status().is_ok_and(|s| s.success()) + }) +} + +fn packets(path: &Path, stream: usize) -> usize { + let ffprobe = craft_media_testkit::ffprobe().expect("ffprobe next to ffmpeg"); + let out = Command::new(ffprobe) + .args(["-v", "error", "-select_streams", &stream.to_string(), "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0"]) + .arg(path) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0) +} + +fn packet_bytes(path: &Path, stream: usize) -> u64 { + let ffprobe = craft_media_testkit::ffprobe().expect("ffprobe next to ffmpeg"); + let out = Command::new(ffprobe) + .args(["-v", "error", "-select_streams", &stream.to_string(), "-show_entries", "packet=size", "-of", "csv=p=0"]) + .arg(path) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.trim().parse::().ok()).sum() +} + +const TONE: [&str; 4] = ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2"]; + +fn cases() -> Vec<(&'static str, Vec<&'static str>, [u8; 4])> { + let pic = vec!["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2"]; + let with = |v: &[&'static str], a: &[&'static str]| -> Vec<&'static str> { + let mut x = pic.clone(); + x.extend_from_slice(&TONE); + x.extend_from_slice(v); + x.extend_from_slice(a); + x + }; + vec![ + ("mjpeg_pcm.avi", with(&["-c:v", "mjpeg", "-q:v", "4", "-pix_fmt", "yuvj420p"], &["-c:a", "pcm_s16le"]), *b"MJPG"), + ( + "h264_mp3.avi", + with(&["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"], &["-c:a", "libmp3lame", "-b:a", "128k"]), + *b"H264", + ), + ("bgr24_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "bgr24"], &["-c:a", "pcm_s16le"]), [0; 4]), + ("yuyv_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "yuyv422"], &["-c:a", "pcm_s16le"]), *b"YUY2"), + ] +} + +#[test] +fn ffmpeg_files_match_ffprobe_on_every_index_path() { + for (name, args, fourcc) in cases() { + let Some(path) = fixture(name, &args) else { return }; + let bytes = std::fs::read(&path).unwrap(); + let f = open(&bytes).unwrap(); + // ffmpeg reserves room for the OpenDML index but writes only idx1 under 1 GB + assert_eq!(f.index, IndexSource::Idx1, "{name}"); + assert_eq!(f.streams.len(), 2, "{name}"); + let (v, a) = (&f.streams[0], &f.streams[1]); + assert_eq!((v.kind, a.kind), (StreamKind::Video, StreamKind::Audio), "{name}"); + let bmp = v.video.as_ref().unwrap(); + assert_eq!((bmp.width, bmp.height.abs(), bmp.compression), (160, 120, fourcc), "{name}"); + assert_eq!((v.rate as f64 / v.scale as f64).round(), 25.0, "{name}"); + assert_eq!(a.audio.as_ref().map(|w| (w.sample_rate, w.channels)), Some((48_000, 1)), "{name}"); + // the tables hold what ffprobe reads + assert_eq!(v.chunks.len(), packets(&path, 0), "{name}: video"); + // PCM chunks are packets; ffprobe re-splits MP3 into its frames, which AVI chunks need not + // follow, so compressed audio compares by bytes + if a.audio.as_ref().is_some_and(|w| w.format_tag == 1) { + assert_eq!(a.chunks.len(), packets(&path, 1), "{name}: audio"); + } else { + let ours: u64 = a.chunks.iter().map(|c| u64::from(c.size)).sum(); + assert_eq!(ours, packet_bytes(&path, 1), "{name}: audio bytes"); + } + assert!(v.chunks[0].key, "{name}: the first frame is a key frame"); + if fourcc == *b"H264" { + assert!(v.chunks.iter().any(|c| !c.key), "{name}: the index marks the delta frames"); + } + // a scan finds the same chunks (it cannot know key frames) + let scanned = open_with(&bytes, OpenOptions { ignore_odml: true, ignore_idx1: true }).unwrap(); + assert_eq!(scanned.index, IndexSource::Scan, "{name}"); + for s in 0..2 { + assert_eq!(sizes(&scanned, s), sizes(&f, s), "{name}: scan stream {s}"); + } + } +} + +fn sizes(f: &craft_media::avi::AviFile, s: usize) -> Vec<(u64, u32)> { + f.streams[s].chunks.iter().map(|c| (c.offset, c.size)).collect() +} + +/// A file past 1 GB, as long recordings are: ffmpeg writes `RIFF AVIX` extensions and the +/// OpenDML index. Needs ffmpeg and 1.2 GB of disk: `cargo test -p craft-media --features avi +/// --test oracle_avi -- --ignored`. +#[test] +#[ignore] +fn opendml_file_past_1_gb() { + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=s=640x480:r=25:d=50", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:d=50", + "-c:v", + "rawvideo", + "-pix_fmt", + "bgr24", + "-c:a", + "pcm_s16le", + ]; + let Some(path) = fixture("large_opendml.avi", &args) else { return }; + let src = std::fs::File::open(&path).unwrap(); + let f = open(&src).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(f.streams[0].chunks.len(), packets(&path, 0)); + assert_eq!(f.streams[1].chunks.len(), packets(&path, 1)); + assert_eq!(f.streams[0].chunks.len(), 1250); + assert!(f.streams[0].chunks.last().unwrap().offset > 1 << 30, "the last frames are in an AVIX extension"); + // idx1 covers the first RIFF only; the AVIX extensions are scanned: the same chunks + let idx1 = open_with(&src, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap(); + assert_eq!(idx1.index, IndexSource::Idx1); + for s in 0..2 { + assert_eq!(sizes(&idx1, s), sizes(&f, s), "stream {s}"); + } + assert_eq!(f.read_chunk(&src, 0, 1249).unwrap().len(), 640 * 480 * 3); +}