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.
This commit is contained in:
Dylan M. Taylor
2026-11-11 15:26:20 -05:00
committed by GitHub
parent 01cbc50269
commit de1daefe8c
8 changed files with 1556 additions and 9 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
AC-3/E-AC-3, MP1/MP2), streaming and seeking, and packet decoding for any demuxer. See its
README for formats and features.
- [`craft-media`](crates/media/README.md): bit-level I/O (`bitstream`), clean-room MP4/MOV
(`isobmff`) and Matroska/WebM (`matroska`) demuxers and muxers, an Ogg demuxer (`ogg`), from
(`isobmff`) and Matroska/WebM (`matroska`) demuxers and muxers, Ogg (`ogg`) and AVI (`avi`) demuxers, from
FilmCraft, and an ISO BMFF box reader. Containers only; codecs are in `craft-audio`, and
video codecs will go in a separate crate.
- [`craft-menu`](crates/menu/README.md): an app's menus as a macOS menu bar: the Mac layout
+7 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "craft-media"
description = "Bit-level I/O, clean-room ISO BMFF (MP4), QuickTime (MOV) and Matroska (MKV, WebM) demuxers and muxers, and an Ogg demuxer."
description = "Bit-level I/O, clean-room ISO BMFF (MP4), QuickTime (MOV) and Matroska (MKV, WebM) demuxers and muxers, and Ogg and AVI demuxers."
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -18,6 +18,8 @@ default = ["isobmff"]
bitstream = []
isobmff = ["bitstream", "dep:thiserror"]
matroska = []
# AVI (RIFF) and OpenDML demuxer (`craft_media::avi`).
avi = []
# Ogg demuxer (Ogg Opus and Vorbis mapping, `craft_media::ogg`).
ogg = []
# Serialize and Deserialize for `Container`, `CodecId`, `CodecParams` and `PcmFormat`.
@@ -40,6 +42,10 @@ required-features = ["isobmff"]
name = "fuzz_matroska"
required-features = ["matroska"]
[[test]]
name = "oracle_avi"
required-features = ["avi"]
[[test]]
name = "oracle_demux"
required-features = ["isobmff"]
+55 -6
View File
@@ -1,8 +1,8 @@
# craft-media
Media containers and bit-level I/O for the Craft apps: an MSB-first bit reader and writer,
clean-room ISO BMFF (MP4), QuickTime (MOV) and Matroska (MKV, WebM) demuxers and muxers, and an Ogg
demuxer, written from the public specifications. No `unsafe`, no panics on malformed input, builds
clean-room ISO BMFF (MP4), QuickTime (MOV) and Matroska (MKV, WebM) demuxers and muxers, and Ogg and
AVI demuxers, written from the public specifications. No `unsafe`, no panics on malformed input, builds
for `wasm32-unknown-unknown`. Dependencies: `thiserror` (for `isobmff`), `serde` (optional).
Codecs are in separate crates that depend on this one: [`craft-audio`](../audio/README.md) for
@@ -18,12 +18,13 @@ that codec crates use to describe a file and configure a decoder.
| `isobmff` | `craft_media::isobmff` | `bitstream`, `thiserror` |
| `matroska` | `craft_media::matroska` | none |
| `ogg` | `craft_media::ogg` | none |
| `avi` | `craft_media::avi` | none |
| (none) | `Container`, `CodecId`, `CodecParams`, `PcmFormat`, `xiph_lace`, `ByteSource`, `ReadSeekSource` | none |
| `serde` | `Serialize`/`Deserialize` on `Container`, `CodecId`, `CodecParams` and `PcmFormat` | `serde` |
`default = ["isobmff"]`, so `bitstream` and `isobmff` build. `matroska` is opt-in: it is about as
much code as `isobmff`, and most users of MP4/MOV (LightCraft, FigCraft, SoundCraft, EffectCraft)
do not read Matroska. `ogg` is opt-in as well. The features are independent.
do not read Matroska. `ogg` and `avi` are opt-in as well. The features are independent.
```toml
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "<commit>", features = ["matroska"] }
@@ -112,7 +113,7 @@ Other differences:
- `isobmff::ByteSource`, `matroska::ByteSource` and `ogg::ByteSource` are one trait,
`craft_media::ByteSource` (with `ReadSeekSource`, which was Matroska's). A type FilmCraft
implements both traits for keeps one of its two impls.
- `isobmff::Track`, `matroska::Track` and `ogg::Stream` have `codec_params()`, all returning a
- `isobmff::Track`, `matroska::Track`, `ogg::Stream` and `avi::Stream` have `codec_params()`, all returning a
`CodecParams` (`CodecId::Unknown` for a codec without an identifier). The MP4 one describes
the first sample's sample description; `codec_params_for(description_index)` gives any other,
and `has_mixed_descriptions()` says whether the samples use more than one.
@@ -442,12 +443,60 @@ bounded too, with a warning when a bound is reached.
`OpusTiming` gives each Opus packet's start, the pre-skip and the end trimming of the final page.
No muxer.
## `avi`
A clean-room AVI demuxer from FilmCraft's `filmcraft-avi` (storytold/filmcraft@4bec22b), written
from Microsoft's published AVI RIFF file reference and the OpenDML AVI File Format Extensions 1.02.
No FFmpeg, libavformat or other AVI implementation was read; ffmpeg and ffprobe are test oracles
only. No muxer.
```rust
use craft_media::avi::{open, StreamKind};
let file = open(&bytes)?; // any ByteSource
for (i, s) in file.streams.iter().enumerate() {
let params = s.codec_params(); // CodecId, FourCC, size or sample rate and channels
if s.kind == StreamKind::Video {
println!("{} {} fps", params.codec.name(), s.rate as f64 / s.scale as f64);
}
let first = file.read_chunk(&bytes, i, 0)?; // frame data, on demand
}
```
| Part | |
|---|---|
| Headers | `avih`; per stream `strh`, `strf` (`BITMAPINFOHEADER`, `WAVEFORMATEX` / `WAVEFORMATEXTENSIBLE`), `strn` |
| Index | OpenDML `indx` super index → `ix##` standard indexes (field indexes too), else `idx1` (offsets relative to `movi` or absolute), else a scan of the `movi` lists |
| Large files | `RIFF AVIX` extensions: through the OpenDML index, or scanned after `idx1`, which only covers the first RIFF |
| Key frames | from the index; a scan marks every chunk and says so (`keyframes_known`) |
| Damage | sizes clamped to the file (a cut-off recording opens with what is there) |
Limits, each with a test: a header list of at most 16 MiB (larger is refused), at most 64 streams
(more are ignored), `rec ` lists nested at most 3 deep, at most 64 MiB of OpenDML standard indexes
(past that the file opens from `idx1` or a scan), and chunk tables bounded by what the file could
hold.
`Stream::codec_params()` maps `biCompression` and the `WAVEFORMATEX` format tag to a `CodecId`
(Motion JPEG, H.264, HEVC, MPEG-4 Part 2, MPEG-2, VP8/VP9, AV1, ProRes, DNxHD; PCM, ADPCM, A-law,
µ-law, MP2, MP3, AAC, AC-3, Vorbis, FLAC). AVI stores H.264 and HEVC as Annex B, so `extradata`
is the bytes after the format header as stored, not an `avcC` / `hvcC` record. Uncompressed video
is `CodecId::Unknown` with the FourCC in `tag`.
Differences from `filmcraft-avi`: `ByteSource` is `craft_media::ByteSource`; `Error::Io` holds the
`io::Error` (FilmCraft: its text), so `Error` is no longer `Clone`/`PartialEq`, and is
`#[non_exhaustive]`; `Stream::codec_params()` is new.
Tests: hand-built files for every index path, truncation, every single-byte mutation and random
multi-byte damage (never a panic). `tests/oracle_avi.rs` checks ffmpeg-made files (Motion JPEG,
H.264 with B-frames, BGR24, YUY2; PCM and MP3) against ffprobe; the ignored `opendml_file_past_1_gb`
checks a 1.16 GB OpenDML file with `AVIX` extensions.
## Containers, codecs and codec parameters
Always built, with no dependencies, so codec crates share them:
- `Container`: the file format (`Wav`, `Aiff`, `Flac`, `Mp4`, `QuickTime`, `Matroska`, `WebM`,
`Ogg`, `Caf`, `Adts`, `MpegAudio`, `Ac3`), with `name()`.
`Ogg`, `Caf`, `Adts`, `MpegAudio`, `Ac3`, `Avi`), with `name()`.
- `CodecId`: audio and video codecs (`Aac`, `HeAac`, `Opus`, `Eac3`, `Speex`, …, `H264`, `Hevc`,
`Av1`, `ProRes`, `Dnxhd`, `Apv`, `Theora`, …, `Unknown`), with `name()`, `is_audio()`,
`is_video()`.
@@ -469,7 +518,7 @@ All are `#[non_exhaustive]`: new formats and fields are minor changes.
`craft_media::ByteSource` (`len`, `read_at(offset, buf)`) is what every demuxer reads from. It is
always built, so a crate that uses only `bitstream` can name it too. `isobmff::ByteSource`,
`matroska::ByteSource` and `ogg::ByteSource` are the same trait. It is implemented for `[u8]`,
`matroska::ByteSource`, `ogg::ByteSource` and `avi::ByteSource` are the same trait. It is implemented for `[u8]`,
`Vec<u8>`, `&T`, `Arc<T>`, `Box<T>` 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.
+709
View File
@@ -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<io::Error> for Error {
fn from(e: io::Error) -> Self {
Error::Io(e)
}
}
/// Result alias for this module.
pub type Result<T> = std::result::Result<T, Error>;
/// 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<u8>,
}
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<u8>,
}
/// 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<BitmapInfo>,
/// `strf` of an audio stream.
pub audio: Option<WaveFormat>,
/// `strn`.
pub name: Option<String>,
/// The stored frames or audio chunks, in file order.
pub chunks: Vec<Chunk>,
/// 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<u64>,
}
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<usize> {
// 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<Stream>,
/// 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<S: ByteSource + ?Sized>(src: &S, at: u64, n: u64) -> Result<Vec<u8>> {
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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(src: &S) -> Result<AviFile> {
open_with(src, OpenOptions::default())
}
/// Open an AVI file, skipping the indexes `opts` names.
pub fn open_with<S: ByteSource + ?Sized>(src: &S, opts: OpenOptions) -> Result<AviFile> {
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<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream], &(_, from, to): &(u64, u64, u64)) {
let missing: Vec<bool> = 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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Result<(MainHeader, Vec<Stream>)> {
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<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Result<Stream> {
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<u64> {
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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(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<S: ByteSource + ?Sized>(&self, src: &S, stream: usize, i: usize) -> Result<Vec<u8>> {
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<S: ByteSource + ?Sized>(&self, src: &S, stream: usize, i: usize, off: usize, len: usize) -> Result<Vec<u8>> {
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)
}
}
+635
View File
@@ -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<u8> {
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<u8> {
let mut d = ltype.to_vec();
d.extend_from_slice(body);
chunk(b"LIST", &d)
}
fn strl(video: bool) -> Vec<u8> {
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<u8> {
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<u8>, 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` <size>, what `riff` holds so far, `LIST` <size>
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<usize>);
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<u8>, [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 <size> 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::<Vec<_>>(), f.streams[0].chunks.iter().map(|c| c.offset).collect::<Vec<_>>());
// 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<u8> = (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<u8> {
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<u8> = (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<u8>, u64, std::cell::Cell<u64>);
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);
}
}
}
}
}
+3
View File
@@ -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",
}
}
}
+4 -1
View File
@@ -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;
+142
View File
@@ -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<PathBuf> {
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::<u64>().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);
}