mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user