mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
craft-media: AVI muxer (AviWriter)
Write AVI files: hdrl with avih and one strl (strh, strf, strn, indx) per stream, odml/dmlh, an interleaved movi and idx1. Past the RIFF limit (default 1 GiB) the file continues in OpenDML RIFF AVIX extensions with ix## standard indexes and an indx super index per stream. - VideoSpec::for_codec builds the stream from CodecParams; MPEG-4 Part 2 is tagged FMP4. - Audio is timed by bytes (PCM-style) or one packet per chunk. - Bad options and chunks are errors; finish() closes a readable file after a failed write. Tests read the files back through this crate's demuxer by every index, and through ffmpeg (decode identical to the sources, seeks through the index, ffprobe fields), in one RIFF and across AVIX extensions. The MPEG-4 encoder tests play its packets from an AVI file.
This commit is contained in:
8 files changed
+1071
-11
No files matched your search
@@ -18,7 +18,7 @@ default = ["isobmff"]
|
||||
bitstream = []
|
||||
isobmff = ["bitstream", "dep:thiserror"]
|
||||
matroska = []
|
||||
# AVI (RIFF) and OpenDML demuxer (`craft_media::avi`).
|
||||
# AVI (RIFF) and OpenDML demuxer and muxer (`craft_media::avi`).
|
||||
avi = []
|
||||
# MXF demuxer and writer (`craft_media::mxf`); the writer formats dates with chrono.
|
||||
mxf = ["dep:chrono"]
|
||||
@@ -51,6 +51,10 @@ required-features = ["matroska"]
|
||||
name = "oracle_avi"
|
||||
required-features = ["avi"]
|
||||
|
||||
[[test]]
|
||||
name = "oracle_avi_mux"
|
||||
required-features = ["avi"]
|
||||
|
||||
[[test]]
|
||||
name = "oracle_demux"
|
||||
required-features = ["isobmff"]
|
||||
|
||||
+45
-8
@@ -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) and MXF demuxers and muxers, and
|
||||
Ogg, AVI and MPEG-2 Systems (transport and program stream) demuxers, written from the public specifications. No `unsafe`, no panics on malformed
|
||||
clean-room ISO BMFF (MP4), QuickTime (MOV), Matroska (MKV, WebM), MXF and AVI demuxers and muxers, and
|
||||
Ogg and MPEG-2 Systems (transport and program stream) demuxers, written from the public specifications. No `unsafe`, no panics on malformed
|
||||
input, builds for `wasm32-unknown-unknown`. Dependencies: `thiserror` (for `isobmff`), `chrono`
|
||||
(for `mxf`), `serde` (optional).
|
||||
|
||||
@@ -44,8 +44,7 @@ craft-media = { git = "https://github.com/storytold/craft-libs", rev = "<commit>
|
||||
- In scope: bit-level reading and writing, and container demuxing and muxing (boxes, sample
|
||||
tables, sample entries and codec configuration records) for audio and video tracks.
|
||||
- Out of scope: codecs. Audio codecs are in `craft-audio`; video codecs (H.264, HEVC, ProRes,
|
||||
DNx, VP8, VP9, AV1) are in `craft-video`. Containers only FilmCraft reads (MXF,
|
||||
MPEG-TS/PS, AVI) are not here yet.
|
||||
DNx, VP8, VP9, AV1, MPEG-4 Part 2) are in `craft-video`.
|
||||
- Anything both the audio and the video codec crates need belongs here, so the two never depend
|
||||
on each other.
|
||||
|
||||
@@ -448,10 +447,10 @@ 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.
|
||||
A clean-room AVI demuxer from FilmCraft's `filmcraft-avi` (storytold/filmcraft@4bec22b), and a
|
||||
muxer, both 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.
|
||||
|
||||
```rust
|
||||
use craft_media::avi::{open, StreamKind};
|
||||
@@ -496,6 +495,44 @@ Tests: hand-built files for every index path, truncation, every single-byte muta
|
||||
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.
|
||||
|
||||
### Muxer
|
||||
|
||||
```rust
|
||||
use craft_media::avi::{AudioSpec, AviWriter, MuxOptions, StreamSpec, VideoSpec};
|
||||
|
||||
let video = VideoSpec::new(*b"XVID", 640, 360, 25, 1); // or VideoSpec::for_codec(&encoder.codec_params(), 25, 1)
|
||||
let audio = AudioSpec::pcm(48_000, 2, 16);
|
||||
let mut w = AviWriter::new(file, vec![StreamSpec::Video(video), StreamSpec::Audio(audio)], MuxOptions::default())?;
|
||||
w.write_chunk(0, &frame, keyframe)?; // chunks go in the order written: the caller interleaves
|
||||
w.write_chunk(1, &pcm, true)?;
|
||||
let file = w.finish()?; // Write + Seek: sizes and the header list are rewritten at the end
|
||||
```
|
||||
|
||||
- Layout: `RIFF AVI ` with `hdrl` (`avih`; per stream `strh`, `strf`, `strn` and an OpenDML
|
||||
`indx`; `odml`/`dmlh`), `LIST movi`, `idx1`. Chunks are `NNdc` (video) and `NNwb` (audio) with
|
||||
decimal stream numbers, padded to even sizes. Empty video chunks (repeat the previous picture)
|
||||
are allowed.
|
||||
- Large files: when a RIFF would pass `MuxOptions::riff_limit` (1 GiB by default, 16 KiB to
|
||||
2 GiB) the file continues in a `RIFF AVIX`. Every RIFF ends its `movi` with an `ix##` standard
|
||||
index per stream, listed in that stream's `indx`; `idx1` covers the first RIFF only, and
|
||||
`avih` counts the first RIFF's frames while `dmlh` and `strh` count all of them, as OpenDML
|
||||
asks. The `indx` has room for `MuxOptions::max_riffs` (256) RIFFs, reserved in the header.
|
||||
- Streams: video with any `BITMAPINFOHEADER` (`VideoSpec::for_codec` picks the FourCC of an
|
||||
encoder's `CodecParams`: `FMP4`, `H264`, `HEVC`, `MJPG`, `MPG2`, `VP80`, `VP90`, `AV01`; `FMP4` and
|
||||
not `XVID` for MPEG-4 Part 2, which decoders take to name the encoder), and audio
|
||||
with any `WAVEFORMATEX`, timed by its bytes (`AudioTiming::Bytes`: PCM and other fixed-size
|
||||
sample frames) or a packet a chunk (`AudioTiming::Packets`: MP3, AAC, AC-3). Up to 100 streams.
|
||||
- Errors, not panics: no streams, a zero rate, a chunk that does not fit in a RIFF, more RIFFs
|
||||
than `max_riffs` (what was written still closes into a readable file).
|
||||
|
||||
Tests: `mux_tests.rs` reads files back through the demuxer on every index path (OpenDML, `idx1`
|
||||
with the `AVIX` extensions scanned, a scan), with odd and empty chunks, several `AVIX`
|
||||
extensions, a file starting inside its output, packet-timed audio and the error cases.
|
||||
`tests/oracle_avi_mux.rs` muxes an ffmpeg-made MPEG-4 Part 2 stream and PCM, in one RIFF and
|
||||
across `AVIX` extensions: ffmpeg decodes the AVI to exactly the pictures and samples of the
|
||||
sources, seeking through the index lands on the right picture, and ffprobe reads the codec,
|
||||
FourCC, size, frame rate, packet count, audio format and duration.
|
||||
## `mpegts`
|
||||
|
||||
A clean-room MPEG-2 Systems demuxer from FilmCraft's `filmcraft-mpegts`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the
|
||||
//! OpenDML AVI File Format Extensions 1.02.
|
||||
//! Clean-room AVI demuxer and muxer ([`AviWriter`]), 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
|
||||
@@ -18,9 +18,14 @@
|
||||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod mux;
|
||||
#[cfg(test)]
|
||||
mod mux_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use mux::{AudioSpec, AudioTiming, AviWriter, MuxOptions, StreamSpec, VideoSpec};
|
||||
|
||||
pub use crate::source::{ByteSource, ReadSeekSource};
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
//! Clean-room AVI muxer, written from Microsoft's published AVI RIFF file reference and the
|
||||
//! OpenDML AVI File Format Extensions 1.02.
|
||||
//!
|
||||
//! [`AviWriter`] writes one `RIFF AVI ` with the `hdrl` header list, a `movi` list of chunks in
|
||||
//! the order they are given, and the AVI 1.0 `idx1` index. A file that grows past
|
||||
//! [`MuxOptions::riff_limit`] (1 GiB) continues in `RIFF AVIX` extensions. Every RIFF ends its
|
||||
//! `movi` list with an OpenDML standard index (`ix##`) per stream, and each stream's `strl`
|
||||
//! holds a super index (`indx`) of them, so readers find the chunks past the first RIFF; `idx1`
|
||||
//! covers the first RIFF only, as OpenDML asks.
|
||||
//!
|
||||
//! The header list is written at the start with room reserved for the super indexes and
|
||||
//! rewritten with the final counts by [`AviWriter::finish`]: the output must be `Write + Seek`.
|
||||
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
use super::{BitmapInfo, Error, Result, WaveFormat};
|
||||
use crate::{CodecId, CodecParams};
|
||||
|
||||
/// `AVIF_HASINDEX`: the file has an `idx1`.
|
||||
const AVIF_HASINDEX: u32 = 0x10;
|
||||
/// `AVIF_ISINTERLEAVED`: the chunks of the streams are interleaved.
|
||||
const AVIF_ISINTERLEAVED: u32 = 0x100;
|
||||
/// `AVIIF_KEYFRAME` in an `idx1` entry.
|
||||
const AVIIF_KEYFRAME: u32 = 0x10;
|
||||
/// Bit 31 of a standard index entry's size: not a key frame.
|
||||
const NOT_KEY: u32 = 0x8000_0000;
|
||||
/// `dmlh` is 248 bytes: `dwTotalFrames` and reserved space.
|
||||
const DMLH_SIZE: usize = 248;
|
||||
/// Chunk ids carry a two-digit decimal stream number.
|
||||
const MAX_STREAMS: usize = 100;
|
||||
/// The smallest RIFF size the writer accepts.
|
||||
const MIN_RIFF_LIMIT: u64 = 16 << 10;
|
||||
/// The largest: RIFF and chunk sizes are 32-bit, and OpenDML entries use bit 31 as a flag.
|
||||
const MAX_RIFF_LIMIT: u64 = 1 << 31;
|
||||
|
||||
/// How the chunks of an audio stream measure time (`strh` `dwScale`, `dwRate`, `dwSampleSize`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AudioTiming {
|
||||
/// Chunks are runs of fixed-size sample frames of `block_align` bytes (PCM, A-law, constant
|
||||
/// bitrate MP3 written as a byte stream): the stream is timed by its bytes.
|
||||
Bytes,
|
||||
/// Each chunk is one packet of `samples_per_packet` sample frames (1152 for MPEG audio Layer
|
||||
/// II/III at 32 kHz and above, 1024 for AAC, 1536 for AC-3).
|
||||
Packets {
|
||||
/// Sample frames in a packet.
|
||||
samples_per_packet: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A video stream for [`AviWriter`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VideoSpec {
|
||||
/// The `BITMAPINFOHEADER` written as `strf`. `compression` is the FourCC (`XVID`, `MJPG`,
|
||||
/// `H264`…), and `extra` follows the 40-byte header.
|
||||
pub format: BitmapInfo,
|
||||
/// `fccHandler`; the compression FourCC when `None`.
|
||||
pub handler: Option<[u8; 4]>,
|
||||
/// Frames per second is `rate / scale`.
|
||||
pub rate: u32,
|
||||
/// See `rate`.
|
||||
pub scale: u32,
|
||||
/// `strn`.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl VideoSpec {
|
||||
/// A compressed stream of `fourcc` pictures, 24 bits per pixel in the header, at `rate / scale`
|
||||
/// frames per second.
|
||||
pub fn new(fourcc: [u8; 4], width: u32, height: u32, rate: u32, scale: u32) -> VideoSpec {
|
||||
VideoSpec {
|
||||
format: BitmapInfo {
|
||||
width: i32::try_from(width).unwrap_or(i32::MAX),
|
||||
height: i32::try_from(height).unwrap_or(i32::MAX),
|
||||
bit_count: 24,
|
||||
compression: fourcc,
|
||||
size_image: width.saturating_mul(height).saturating_mul(3),
|
||||
extra: Vec::new(),
|
||||
},
|
||||
handler: None,
|
||||
rate,
|
||||
scale,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A stream for an encoder's track description: the usual AVI FourCC of its codec (`FMP4` for
|
||||
/// MPEG-4 Part 2, `H264`, `HEVC`, `MJPG`, `MPG2`, `VP80`, `VP90`, `AV01`), its picture size
|
||||
/// and its extradata after the header. MPEG-4 Part 2 gets `FMP4` rather than `XVID` or `DIVX`
|
||||
/// because decoders read those as naming the encoder: ffmpeg, for one, decodes an `XVID`
|
||||
/// stream with Xvid's own IDCT. Set `format.compression` for a player that wants another. `None` for a codec without a FourCC here or a
|
||||
/// description without a size. H.264 and HEVC chunks must then be Annex B with an Annex B or
|
||||
/// empty extradata, as AVI readers expect.
|
||||
pub fn for_codec(params: &CodecParams, rate: u32, scale: u32) -> Option<VideoSpec> {
|
||||
let fourcc = match params.codec {
|
||||
CodecId::Mpeg4Video => *b"FMP4",
|
||||
CodecId::H264 => *b"H264",
|
||||
CodecId::Hevc => *b"HEVC",
|
||||
CodecId::Mjpeg => *b"MJPG",
|
||||
CodecId::Mpeg2Video => *b"MPG2",
|
||||
CodecId::Vp8 => *b"VP80",
|
||||
CodecId::Vp9 => *b"VP90",
|
||||
CodecId::Av1 => *b"AV01",
|
||||
_ => return None,
|
||||
};
|
||||
let mut v = VideoSpec::new(fourcc, params.width?, params.height?, rate, scale);
|
||||
v.format.extra = params.extradata.clone();
|
||||
Some(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// An audio stream for [`AviWriter`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AudioSpec {
|
||||
/// The `WAVEFORMATEX` written as `strf`.
|
||||
pub format: WaveFormat,
|
||||
/// How its chunks measure time.
|
||||
pub timing: AudioTiming,
|
||||
/// `strn`.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl AudioSpec {
|
||||
/// Interleaved little-endian integer PCM (`bits` 8, 16, 24 or 32), timed by its bytes.
|
||||
pub fn pcm(sample_rate: u32, channels: u16, bits: u16) -> AudioSpec {
|
||||
let block_align = channels.saturating_mul(bits.div_ceil(8));
|
||||
AudioSpec {
|
||||
format: WaveFormat {
|
||||
format_tag: 1,
|
||||
channels,
|
||||
sample_rate,
|
||||
avg_bytes_per_sec: sample_rate.saturating_mul(u32::from(block_align)),
|
||||
block_align,
|
||||
bits_per_sample: bits,
|
||||
extra: Vec::new(),
|
||||
},
|
||||
timing: AudioTiming::Bytes,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One stream of an AVI file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum StreamSpec {
|
||||
/// `vids`.
|
||||
Video(VideoSpec),
|
||||
/// `auds`.
|
||||
Audio(AudioSpec),
|
||||
}
|
||||
|
||||
/// Options for [`AviWriter`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MuxOptions {
|
||||
/// Largest size of one RIFF (the first, then each `AVIX` extension), 16 KiB to 2 GiB.
|
||||
/// 1 GiB by default, which OpenDML recommends for the first RIFF so AVI 1.0 readers can use it.
|
||||
pub riff_limit: u64,
|
||||
/// RIFFs the super indexes have room for (each takes 16 bytes a stream in the header). 256 by
|
||||
/// default: 256 GiB at the default `riff_limit`.
|
||||
pub max_riffs: u32,
|
||||
}
|
||||
|
||||
impl Default for MuxOptions {
|
||||
fn default() -> Self {
|
||||
MuxOptions { riff_limit: 1 << 30, max_riffs: 256 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A chunk's entry in the standard index of its RIFF.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Entry {
|
||||
/// Offset of the data from the `movi` list's type id (the index's base).
|
||||
offset: u32,
|
||||
size: u32,
|
||||
key: bool,
|
||||
}
|
||||
|
||||
/// One `ix##` written: its position, its size and the stream time it covers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct SuperEntry {
|
||||
at: u64,
|
||||
size: u32,
|
||||
duration: u32,
|
||||
}
|
||||
|
||||
/// The RIFF being written.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Riff {
|
||||
/// Position of `RIFF`.
|
||||
start: u64,
|
||||
/// Position of the `movi` list's `LIST`.
|
||||
movi: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct StreamState {
|
||||
spec: StreamSpec,
|
||||
/// `00dc`, `01wb`…
|
||||
id: [u8; 4],
|
||||
chunks: u64,
|
||||
bytes: u64,
|
||||
largest: u32,
|
||||
/// The chunks of the current RIFF.
|
||||
entries: Vec<Entry>,
|
||||
/// Stream time of the current RIFF's chunks.
|
||||
duration: u64,
|
||||
supers: Vec<SuperEntry>,
|
||||
}
|
||||
|
||||
impl StreamState {
|
||||
/// Stream time (`dwScale / dwRate` units) of a chunk of `len` bytes.
|
||||
fn units(&self, len: u64) -> u64 {
|
||||
match &self.spec {
|
||||
StreamSpec::Audio(a) if a.timing == AudioTiming::Bytes => len / u64::from(a.format.block_align.max(1)),
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// `(dwScale, dwRate, dwSampleSize)`.
|
||||
fn time_base(&self) -> (u32, u32, u32) {
|
||||
match &self.spec {
|
||||
StreamSpec::Video(v) => (v.scale, v.rate, 0),
|
||||
StreamSpec::Audio(a) => match a.timing {
|
||||
AudioTiming::Bytes => (u32::from(a.format.block_align), a.format.avg_bytes_per_sec, u32::from(a.format.block_align)),
|
||||
AudioTiming::Packets { samples_per_packet } => (samples_per_packet, a.format.sample_rate, 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The stream's length in its own units.
|
||||
fn length(&self) -> u64 {
|
||||
match &self.spec {
|
||||
StreamSpec::Audio(a) if a.timing == AudioTiming::Bytes => self.bytes / u64::from(a.format.block_align.max(1)),
|
||||
_ => self.chunks,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stream's duration in seconds.
|
||||
fn seconds(&self) -> f64 {
|
||||
let (scale, rate, _) = self.time_base();
|
||||
self.length() as f64 * f64::from(scale) / f64::from(rate.max(1))
|
||||
}
|
||||
}
|
||||
|
||||
fn le16(v: &mut Vec<u8>, x: u16) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
|
||||
fn le32(v: &mut Vec<u8>, x: u32) {
|
||||
v.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
|
||||
fn sat32(x: u64) -> u32 {
|
||||
u32::try_from(x).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// A chunk: id, size, data, a pad byte to an even size.
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = Vec::with_capacity(data.len() + 9);
|
||||
v.extend_from_slice(id);
|
||||
le32(&mut v, sat32(data.len() as u64));
|
||||
v.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
v.push(0);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn list(ltype: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
chunk(b"LIST", &[ltype.as_slice(), body].concat())
|
||||
}
|
||||
|
||||
fn invalid(msg: impl Into<String>) -> Error {
|
||||
Error::Invalid(msg.into())
|
||||
}
|
||||
|
||||
/// The `hdrl` list. Its length depends only on the stream descriptions and `max_riffs`, so the
|
||||
/// final one ([`AviWriter::finish`]) replaces the first in place.
|
||||
fn hdrl(streams: &[StreamState], main: Option<usize>, max_riffs: u32, first_frames: u32) -> Vec<u8> {
|
||||
let video = main.and_then(|i| streams.get(i)).and_then(|s| match &s.spec {
|
||||
StreamSpec::Video(v) => Some((v, s)),
|
||||
StreamSpec::Audio(_) => None,
|
||||
});
|
||||
let seconds = streams.iter().map(StreamState::seconds).fold(0.0, f64::max);
|
||||
let total: u64 = streams.iter().map(|s| s.bytes).sum();
|
||||
let largest = streams.iter().map(|s| s.largest).max().unwrap_or(0);
|
||||
|
||||
let mut avih = Vec::with_capacity(56);
|
||||
le32(&mut avih, video.map_or(0, |(v, _)| (1e6 * f64::from(v.scale) / f64::from(v.rate)).round() as u32));
|
||||
le32(&mut avih, if seconds > 0.0 { (total as f64 / seconds).ceil() as u32 } else { 0 });
|
||||
le32(&mut avih, 0); // dwPaddingGranularity
|
||||
le32(&mut avih, AVIF_HASINDEX | AVIF_ISINTERLEAVED);
|
||||
le32(&mut avih, first_frames);
|
||||
le32(&mut avih, 0); // dwInitialFrames
|
||||
le32(&mut avih, streams.len() as u32);
|
||||
le32(&mut avih, largest.saturating_add(8));
|
||||
le32(&mut avih, video.map_or(0, |(v, _)| v.format.width.unsigned_abs()));
|
||||
le32(&mut avih, video.map_or(0, |(v, _)| v.format.height.unsigned_abs()));
|
||||
avih.extend_from_slice(&[0; 16]);
|
||||
|
||||
let mut body = chunk(b"avih", &avih);
|
||||
for s in streams {
|
||||
let (scale, rate, sample_size) = s.time_base();
|
||||
let mut strh = Vec::with_capacity(56);
|
||||
let (fcc, handler, frame) = match &s.spec {
|
||||
StreamSpec::Video(v) => (*b"vids", v.handler.unwrap_or(v.format.compression), (v.format.width, v.format.height.abs())),
|
||||
StreamSpec::Audio(_) => (*b"auds", [0; 4], (0, 0)),
|
||||
};
|
||||
strh.extend_from_slice(&fcc);
|
||||
strh.extend_from_slice(&handler);
|
||||
le32(&mut strh, 0); // dwFlags
|
||||
le16(&mut strh, 0); // wPriority
|
||||
le16(&mut strh, 0); // wLanguage
|
||||
le32(&mut strh, 0); // dwInitialFrames
|
||||
le32(&mut strh, scale);
|
||||
le32(&mut strh, rate);
|
||||
le32(&mut strh, 0); // dwStart
|
||||
le32(&mut strh, sat32(s.length()));
|
||||
le32(&mut strh, s.largest);
|
||||
le32(&mut strh, u32::MAX); // dwQuality: default
|
||||
le32(&mut strh, sample_size);
|
||||
for v in [0, 0, frame.0, frame.1] {
|
||||
le16(&mut strh, i16::try_from(v).unwrap_or(i16::MAX) as u16);
|
||||
}
|
||||
|
||||
let mut strf = Vec::new();
|
||||
match &s.spec {
|
||||
StreamSpec::Video(v) => {
|
||||
let f = &v.format;
|
||||
le32(&mut strf, sat32(40 + f.extra.len() as u64));
|
||||
le32(&mut strf, f.width as u32);
|
||||
le32(&mut strf, f.height as u32);
|
||||
le16(&mut strf, 1); // biPlanes
|
||||
le16(&mut strf, f.bit_count);
|
||||
strf.extend_from_slice(&f.compression);
|
||||
le32(&mut strf, f.size_image);
|
||||
strf.extend_from_slice(&[0; 16]); // pels per metre, colours used and important
|
||||
strf.extend_from_slice(&f.extra);
|
||||
}
|
||||
StreamSpec::Audio(a) => {
|
||||
let f = &a.format;
|
||||
le16(&mut strf, f.format_tag);
|
||||
le16(&mut strf, f.channels);
|
||||
le32(&mut strf, f.sample_rate);
|
||||
le32(&mut strf, f.avg_bytes_per_sec);
|
||||
le16(&mut strf, f.block_align);
|
||||
le16(&mut strf, f.bits_per_sample);
|
||||
le16(&mut strf, u16::try_from(f.extra.len()).unwrap_or(u16::MAX));
|
||||
strf.extend_from_slice(&f.extra[..f.extra.len().min(usize::from(u16::MAX))]);
|
||||
}
|
||||
}
|
||||
|
||||
// indx: AVI_INDEX_OF_INDEXES, room for `max_riffs` entries
|
||||
let mut indx = Vec::with_capacity(24 + 16 * max_riffs as usize);
|
||||
le16(&mut indx, 4); // wLongsPerEntry
|
||||
indx.push(0); // bIndexSubType
|
||||
indx.push(0); // bIndexType: AVI_INDEX_OF_INDEXES
|
||||
le32(&mut indx, s.supers.len() as u32);
|
||||
indx.extend_from_slice(&s.id);
|
||||
indx.extend_from_slice(&[0; 12]);
|
||||
for e in &s.supers {
|
||||
indx.extend_from_slice(&e.at.to_le_bytes());
|
||||
le32(&mut indx, e.size);
|
||||
le32(&mut indx, e.duration);
|
||||
}
|
||||
indx.resize(24 + 16 * max_riffs as usize, 0);
|
||||
|
||||
let mut strl = chunk(b"strh", &strh);
|
||||
strl.extend(chunk(b"strf", &strf));
|
||||
let name = match &s.spec {
|
||||
StreamSpec::Video(v) => v.name.as_deref(),
|
||||
StreamSpec::Audio(a) => a.name.as_deref(),
|
||||
};
|
||||
if let Some(n) = name {
|
||||
strl.extend(chunk(b"strn", &[n.as_bytes(), &[0]].concat()));
|
||||
}
|
||||
strl.extend(chunk(b"indx", &indx));
|
||||
body.extend(list(b"strl", &strl));
|
||||
}
|
||||
let mut dmlh = vec![0u8; DMLH_SIZE];
|
||||
let frames = video.map_or(0, |(_, s)| sat32(s.chunks));
|
||||
dmlh[..4].copy_from_slice(&frames.to_le_bytes());
|
||||
body.extend(list(b"odml", &chunk(b"dmlh", &dmlh)));
|
||||
list(b"hdrl", &body)
|
||||
}
|
||||
|
||||
/// Writes an AVI file chunk by chunk; see the [module documentation](self).
|
||||
#[derive(Debug)]
|
||||
pub struct AviWriter<W: Write + Seek> {
|
||||
w: W,
|
||||
/// Where the file starts in `w`.
|
||||
base: u64,
|
||||
opts: MuxOptions,
|
||||
streams: Vec<StreamState>,
|
||||
/// The stream whose frame period and size the main header gives (the first video stream).
|
||||
main: Option<usize>,
|
||||
/// The next byte to write, from the start of the file.
|
||||
pos: u64,
|
||||
/// Bytes of the `hdrl` list, fixed so it can be rewritten in place.
|
||||
hdrl_len: usize,
|
||||
riff: Riff,
|
||||
/// RIFFs started so far.
|
||||
riffs: u32,
|
||||
/// `idx1` of the first RIFF (16 bytes an entry), while it is open.
|
||||
idx1: Vec<[u8; 16]>,
|
||||
/// Video frames in the first RIFF (`avih` `dwTotalFrames`).
|
||||
first_frames: u32,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> AviWriter<W> {
|
||||
/// Start a file with `streams` (1 to 100) at the current position of `w`.
|
||||
pub fn new(mut w: W, streams: Vec<StreamSpec>, opts: MuxOptions) -> Result<Self> {
|
||||
if streams.is_empty() || streams.len() > MAX_STREAMS {
|
||||
return Err(invalid(format!("{} streams (1 to {MAX_STREAMS})", streams.len())));
|
||||
}
|
||||
if !(MIN_RIFF_LIMIT..=MAX_RIFF_LIMIT).contains(&opts.riff_limit) {
|
||||
return Err(invalid(format!("riff_limit {} is outside {MIN_RIFF_LIMIT}..={MAX_RIFF_LIMIT}", opts.riff_limit)));
|
||||
}
|
||||
if opts.max_riffs == 0 || opts.max_riffs > 1 << 16 {
|
||||
return Err(invalid("max_riffs must be 1 to 65536"));
|
||||
}
|
||||
for (i, s) in streams.iter().enumerate() {
|
||||
match s {
|
||||
StreamSpec::Video(v) if v.rate == 0 || v.scale == 0 => return Err(invalid(format!("stream {i}: zero frame rate"))),
|
||||
StreamSpec::Audio(a) if a.format.sample_rate == 0 => return Err(invalid(format!("stream {i}: zero sample rate"))),
|
||||
StreamSpec::Audio(a) if a.timing == AudioTiming::Bytes && (a.format.block_align == 0 || a.format.avg_bytes_per_sec == 0) => {
|
||||
return Err(invalid(format!("stream {i}: byte-timed audio needs a block align and a byte rate")));
|
||||
}
|
||||
StreamSpec::Audio(AudioSpec { timing: AudioTiming::Packets { samples_per_packet: 0 }, .. }) => {
|
||||
return Err(invalid(format!("stream {i}: zero samples a packet")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let main = streams.iter().position(|s| matches!(s, StreamSpec::Video(_)));
|
||||
let streams: Vec<StreamState> = streams
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, spec)| {
|
||||
let kind = if matches!(spec, StreamSpec::Video(_)) { b"dc" } else { b"wb" };
|
||||
StreamState {
|
||||
spec,
|
||||
id: [b'0' + (i / 10) as u8, b'0' + (i % 10) as u8, kind[0], kind[1]],
|
||||
chunks: 0,
|
||||
bytes: 0,
|
||||
largest: 0,
|
||||
entries: Vec::new(),
|
||||
duration: 0,
|
||||
supers: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let header = hdrl(&streams, main, opts.max_riffs, 0);
|
||||
if (header.len() + 24) as u64 > opts.riff_limit / 2 {
|
||||
return Err(invalid("the header list takes more than half of riff_limit"));
|
||||
}
|
||||
let base = w.stream_position()?;
|
||||
let mut head = Vec::with_capacity(header.len() + 24);
|
||||
head.extend_from_slice(b"RIFF");
|
||||
le32(&mut head, 0);
|
||||
head.extend_from_slice(b"AVI ");
|
||||
head.extend_from_slice(&header);
|
||||
let movi = head.len() as u64;
|
||||
head.extend_from_slice(b"LIST");
|
||||
le32(&mut head, 0);
|
||||
head.extend_from_slice(b"movi");
|
||||
w.write_all(&head)?;
|
||||
Ok(AviWriter {
|
||||
w,
|
||||
base,
|
||||
opts,
|
||||
streams,
|
||||
main,
|
||||
pos: head.len() as u64,
|
||||
hdrl_len: header.len(),
|
||||
riff: Riff { start: 0, movi },
|
||||
riffs: 1,
|
||||
idx1: Vec::new(),
|
||||
first_frames: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes the current RIFF would take if it were closed after one more chunk of `chunk_len`
|
||||
/// bytes for `stream`: its chunks, the standard indexes and (first RIFF) `idx1`.
|
||||
fn riff_size_with(&self, stream: usize, chunk_len: u64) -> u64 {
|
||||
let ix: u64 = self.streams.iter().enumerate().map(|(i, s)| 32 + 8 * (s.entries.len() as u64 + u64::from(i == stream))).sum();
|
||||
let idx1 = if self.riffs == 1 { 8 + 16 * (self.idx1.len() as u64 + 1) } else { 0 };
|
||||
self.pos - self.riff.start + chunk_len + ix + idx1
|
||||
}
|
||||
|
||||
/// Write one chunk of stream `stream`: a video frame (`key` for a key frame; an empty chunk
|
||||
/// repeats the previous picture) or a run of audio. Chunks go into the file in call order,
|
||||
/// so the caller interleaves the streams.
|
||||
pub fn write_chunk(&mut self, stream: usize, data: &[u8], key: bool) -> Result<()> {
|
||||
if stream >= self.streams.len() {
|
||||
return Err(invalid(format!("no stream {stream}")));
|
||||
}
|
||||
let len = data.len() as u64;
|
||||
let chunk_len = 8 + len + (len & 1);
|
||||
if self.riff_size_with(stream, chunk_len) > self.opts.riff_limit && self.streams.iter().any(|s| !s.entries.is_empty()) {
|
||||
self.next_riff()?;
|
||||
}
|
||||
if self.riff_size_with(stream, chunk_len) > self.opts.riff_limit {
|
||||
return Err(invalid(format!("a {len}-byte chunk does not fit in a RIFF of riff_limit {} bytes", self.opts.riff_limit)));
|
||||
}
|
||||
let index_base = self.riff.movi + 8;
|
||||
let s = &mut self.streams[stream];
|
||||
let at = self.pos;
|
||||
let mut head = Vec::with_capacity(8);
|
||||
head.extend_from_slice(&s.id);
|
||||
le32(&mut head, len as u32);
|
||||
self.w.write_all(&head)?;
|
||||
self.w.write_all(data)?;
|
||||
if len & 1 == 1 {
|
||||
self.w.write_all(&[0])?;
|
||||
}
|
||||
self.pos += chunk_len;
|
||||
// riff_limit ≤ 2 GiB keeps every offset in the RIFF below 2^31
|
||||
s.entries.push(Entry { offset: (at + 8 - index_base) as u32, size: len as u32, key });
|
||||
s.duration += s.units(len);
|
||||
s.chunks += 1;
|
||||
s.bytes += len;
|
||||
s.largest = s.largest.max(len as u32);
|
||||
if self.riffs == 1 {
|
||||
let mut e = [0u8; 16];
|
||||
e[..4].copy_from_slice(&s.id);
|
||||
e[4..8].copy_from_slice(&(if key { AVIIF_KEYFRAME } else { 0 }).to_le_bytes());
|
||||
e[8..12].copy_from_slice(&((at - index_base) as u32).to_le_bytes());
|
||||
e[12..].copy_from_slice(&(len as u32).to_le_bytes());
|
||||
self.idx1.push(e);
|
||||
if Some(stream) == self.main {
|
||||
self.first_frames += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch(&mut self, at: u64, bytes: &[u8]) -> Result<()> {
|
||||
self.w.seek(SeekFrom::Start(self.base + at))?;
|
||||
self.w.write_all(bytes)?;
|
||||
self.w.seek(SeekFrom::Start(self.base + self.pos))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put(&mut self, bytes: &[u8]) -> Result<()> {
|
||||
self.w.write_all(bytes)?;
|
||||
self.pos += bytes.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the current RIFF: the standard indexes at the end of its `movi`, the sizes, and
|
||||
/// (first RIFF) `idx1`.
|
||||
fn end_riff(&mut self) -> Result<()> {
|
||||
let index_base = self.riff.movi + 8;
|
||||
for i in 0..self.streams.len() {
|
||||
let s = &mut self.streams[i];
|
||||
let mut ix = Vec::with_capacity(24 + 8 * s.entries.len());
|
||||
le16(&mut ix, 2); // wLongsPerEntry
|
||||
ix.push(0); // bIndexSubType
|
||||
ix.push(1); // bIndexType: AVI_INDEX_OF_CHUNKS
|
||||
le32(&mut ix, s.entries.len() as u32);
|
||||
ix.extend_from_slice(&s.id);
|
||||
ix.extend_from_slice(&index_base.to_le_bytes());
|
||||
le32(&mut ix, 0);
|
||||
for e in s.entries.drain(..) {
|
||||
le32(&mut ix, e.offset);
|
||||
le32(&mut ix, e.size | if e.key { 0 } else { NOT_KEY });
|
||||
}
|
||||
let at = self.pos;
|
||||
let id = [b'i', b'x', s.id[0], s.id[1]];
|
||||
let c = chunk(&id, &ix);
|
||||
s.supers.push(SuperEntry { at, size: c.len() as u32, duration: sat32(s.duration) });
|
||||
s.duration = 0;
|
||||
self.put(&c)?;
|
||||
}
|
||||
let movi_size = sat32(self.pos - self.riff.movi - 8);
|
||||
self.patch(self.riff.movi + 4, &movi_size.to_le_bytes())?;
|
||||
if self.riffs == 1 {
|
||||
let idx1 = std::mem::take(&mut self.idx1).concat();
|
||||
self.put(&chunk(b"idx1", &idx1))?;
|
||||
}
|
||||
let riff_size = sat32(self.pos - self.riff.start - 8);
|
||||
self.patch(self.riff.start + 4, &riff_size.to_le_bytes())
|
||||
}
|
||||
|
||||
/// Close the current RIFF and start a `RIFF AVIX` extension.
|
||||
fn next_riff(&mut self) -> Result<()> {
|
||||
if self.riffs >= self.opts.max_riffs {
|
||||
return Err(invalid(format!("the file needs more than max_riffs ({}) RIFFs", self.opts.max_riffs)));
|
||||
}
|
||||
self.end_riff()?;
|
||||
let start = self.pos;
|
||||
let mut head = Vec::with_capacity(24);
|
||||
head.extend_from_slice(b"RIFF");
|
||||
le32(&mut head, 0);
|
||||
head.extend_from_slice(b"AVIX");
|
||||
head.extend_from_slice(b"LIST");
|
||||
le32(&mut head, 0);
|
||||
head.extend_from_slice(b"movi");
|
||||
self.put(&head)?;
|
||||
self.riff = Riff { start, movi: start + 12 };
|
||||
self.riffs += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Close the file: the last RIFF's indexes and sizes, then the header list with the final
|
||||
/// counts. Returns the writer, positioned at the end of the file.
|
||||
pub fn finish(mut self) -> Result<W> {
|
||||
self.end_riff()?;
|
||||
let header = hdrl(&self.streams, self.main, self.opts.max_riffs, self.first_frames);
|
||||
if header.len() != self.hdrl_len {
|
||||
return Err(invalid("the header list changed size"));
|
||||
}
|
||||
self.patch(12, &header)?;
|
||||
self.w.flush()?;
|
||||
Ok(self.w)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! The AVI muxer read back by this module's demuxer, through every index it writes.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Chunk `i` of a test stream: sizes vary (odd, even, empty) and the bytes name the chunk.
|
||||
fn payload(stream: usize, i: usize) -> Vec<u8> {
|
||||
let len = match i % 7 {
|
||||
0 => 1500 + i * 3,
|
||||
3 => 0,
|
||||
5 => 1,
|
||||
_ => 200 + (i * 37) % 900,
|
||||
};
|
||||
(0..len).map(|k| (k as u32 ^ (i as u32) << 3 ^ (stream as u32) << 7) as u8).collect()
|
||||
}
|
||||
|
||||
fn video() -> VideoSpec {
|
||||
let mut v = VideoSpec::new(*b"XVID", 320, 240, 30000, 1001);
|
||||
v.format.extra = vec![0, 0, 1, 0xB0, 3];
|
||||
v.name = Some("picture".into());
|
||||
v
|
||||
}
|
||||
|
||||
fn pcm() -> AudioSpec {
|
||||
let mut a = AudioSpec::pcm(48_000, 2, 16);
|
||||
a.name = Some("sound".into());
|
||||
a
|
||||
}
|
||||
|
||||
/// Each stream's chunks: bytes and key frame.
|
||||
type Written = [Vec<(Vec<u8>, bool)>; 2];
|
||||
|
||||
/// Write `frames` video frames (every 12th a key frame) each followed by an audio chunk of 4·k
|
||||
/// bytes, returning the file and what was written per stream.
|
||||
fn write(frames: usize, opts: MuxOptions) -> (Vec<u8>, Written) {
|
||||
let mut w = AviWriter::new(Cursor::new(Vec::new()), vec![StreamSpec::Video(video()), StreamSpec::Audio(pcm())], opts).unwrap();
|
||||
let mut written: Written = [Vec::new(), Vec::new()];
|
||||
for i in 0..frames {
|
||||
let v = payload(0, i);
|
||||
let key = i % 12 == 0;
|
||||
w.write_chunk(0, &v, key).unwrap();
|
||||
written[0].push((v, key));
|
||||
let a: Vec<u8> = payload(1, i).into_iter().take(4 * (1 + i % 400)).collect();
|
||||
let a = a[..a.len() / 4 * 4].to_vec();
|
||||
w.write_chunk(1, &a, true).unwrap();
|
||||
written[1].push((a, true));
|
||||
}
|
||||
(w.finish().unwrap().into_inner(), written)
|
||||
}
|
||||
|
||||
fn tables(file: &[u8], f: &AviFile) -> Written {
|
||||
std::array::from_fn(|s| (0..f.streams[s].chunks.len()).map(|i| (f.read_chunk(file, s, i).unwrap(), f.streams[s].chunks[i].key)).collect())
|
||||
}
|
||||
|
||||
/// The top-level chunks of a file: (id, form, size).
|
||||
fn riffs(file: &[u8]) -> Vec<([u8; 4], [u8; 4], u64)> {
|
||||
children(&file, 0, file.len() as u64).into_iter().map(|(id, size, data)| (id, fourcc(file, data as usize), size)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_round_trip() {
|
||||
let (file, written) = write(30, MuxOptions::default());
|
||||
let f = open(&file).unwrap();
|
||||
assert_eq!(f.index, IndexSource::OpenDml);
|
||||
assert_eq!(f.header, MainHeader { micro_sec_per_frame: 33367, flags: 0x110, total_frames: 30, streams: 2, width: 320, height: 240 });
|
||||
let (v, a) = (&f.streams[0], &f.streams[1]);
|
||||
assert_eq!((v.kind, v.handler, v.scale, v.rate, v.length, v.sample_size), (StreamKind::Video, *b"XVID", 1001, 30000, 30, 0));
|
||||
assert_eq!(v.video.as_ref(), Some(&video().format));
|
||||
assert_eq!(v.name.as_deref(), Some("picture"));
|
||||
let audio_bytes: usize = written[1].iter().map(|c| c.0.len()).sum();
|
||||
assert_eq!((a.kind, a.scale, a.rate, a.sample_size, a.length as usize), (StreamKind::Audio, 4, 192_000, 4, audio_bytes / 4));
|
||||
assert_eq!(a.audio.as_ref(), Some(&pcm().format));
|
||||
assert_eq!(a.name.as_deref(), Some("sound"));
|
||||
assert_eq!(v.codec_params().codec, crate::CodecId::Mpeg4Video);
|
||||
assert_eq!(a.codec_params().codec, crate::CodecId::Pcm);
|
||||
// one RIFF: hdrl, movi, idx1
|
||||
assert_eq!(riffs(&file).iter().map(|r| (r.0, r.1)).collect::<Vec<_>>(), [(*b"RIFF", *b"AVI ")]);
|
||||
assert_eq!(riffs(&file)[0].2 + 8, file.len() as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_index_describes_the_same_chunks() {
|
||||
let (file, written) = write(40, MuxOptions::default());
|
||||
for (opts, source) in [
|
||||
(OpenOptions::default(), IndexSource::OpenDml),
|
||||
(OpenOptions { ignore_odml: true, ignore_idx1: false }, IndexSource::Idx1),
|
||||
(OpenOptions { ignore_odml: true, ignore_idx1: true }, IndexSource::Scan),
|
||||
] {
|
||||
let f = open_with(&file, opts).unwrap();
|
||||
assert_eq!(f.index, source);
|
||||
let got = tables(&file, &f);
|
||||
assert_eq!(got[1], written[1], "{source:?}: audio");
|
||||
if source == IndexSource::Scan {
|
||||
// a scan cannot tell key frames
|
||||
assert_eq!(got[0].iter().map(|c| &c.0).collect::<Vec<_>>(), written[0].iter().map(|c| &c.0).collect::<Vec<_>>());
|
||||
} else {
|
||||
assert_eq!(got[0], written[0], "{source:?}: video");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_files_continue_in_avix_extensions() {
|
||||
let opts = MuxOptions { riff_limit: 24 << 10, max_riffs: 64 };
|
||||
let (file, written) = write(200, opts);
|
||||
let r = riffs(&file);
|
||||
assert!(r.len() > 3, "{} RIFFs", r.len());
|
||||
assert_eq!((r[0].0, r[0].1), (*b"RIFF", *b"AVI "));
|
||||
assert!(r[1..].iter().all(|x| (x.0, x.1) == (*b"RIFF", *b"AVIX")));
|
||||
assert!(r.iter().all(|x| x.2 + 8 <= opts.riff_limit), "a RIFF is over the limit: {r:?}");
|
||||
assert_eq!(r.iter().map(|x| x.2 + 8).sum::<u64>(), file.len() as u64);
|
||||
|
||||
let f = open(&file).unwrap();
|
||||
assert_eq!(f.index, IndexSource::OpenDml);
|
||||
assert_eq!(tables(&file, &f), written);
|
||||
assert_eq!(f.streams[0].length, 200);
|
||||
// avih counts the first RIFF's frames; strh the whole stream
|
||||
let first = f.streams[0].chunks.iter().filter(|c| c.offset < r[0].2).count() as u32;
|
||||
assert_eq!(f.header.total_frames, first);
|
||||
assert!(first < 200);
|
||||
// idx1 covers the first RIFF and the AVIX extensions are scanned
|
||||
let f = open_with(&file, OpenOptions { ignore_odml: true, ignore_idx1: false }).unwrap();
|
||||
assert_eq!(f.index, IndexSource::Idx1);
|
||||
assert_eq!(tables(&file, &f)[1], written[1]);
|
||||
assert_eq!(f.streams[0].chunks.len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_can_start_inside_the_output() {
|
||||
let mut out = Cursor::new(b"prefix".to_vec());
|
||||
out.set_position(6);
|
||||
let mut w = AviWriter::new(out, vec![StreamSpec::Video(video())], MuxOptions::default()).unwrap();
|
||||
w.write_chunk(0, &[1, 2, 3], true).unwrap();
|
||||
let bytes = w.finish().unwrap().into_inner();
|
||||
assert_eq!(&bytes[..6], b"prefix");
|
||||
let file = &bytes[6..];
|
||||
let f = open(&file).unwrap();
|
||||
assert_eq!(f.read_chunk(&file, 0, 0).unwrap(), [1, 2, 3]);
|
||||
assert_eq!(f.header.total_frames, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packet_timed_audio_counts_chunks() {
|
||||
let mut mp3 = AudioSpec::pcm(44_100, 2, 0);
|
||||
mp3.format = WaveFormat {
|
||||
format_tag: 0x55,
|
||||
channels: 2,
|
||||
sample_rate: 44_100,
|
||||
avg_bytes_per_sec: 16_000,
|
||||
block_align: 1,
|
||||
bits_per_sample: 0,
|
||||
extra: vec![0; 12],
|
||||
};
|
||||
mp3.timing = AudioTiming::Packets { samples_per_packet: 1152 };
|
||||
let mut w = AviWriter::new(Cursor::new(Vec::new()), vec![StreamSpec::Audio(mp3)], MuxOptions::default()).unwrap();
|
||||
for i in 0..10 {
|
||||
w.write_chunk(0, &payload(0, i), true).unwrap();
|
||||
}
|
||||
let file = w.finish().unwrap().into_inner();
|
||||
let f = open(&file).unwrap();
|
||||
let a = &f.streams[0];
|
||||
assert_eq!((a.scale, a.rate, a.sample_size, a.length), (1152, 44_100, 0, 10));
|
||||
assert_eq!(a.codec_params().codec, crate::CodecId::Mp3);
|
||||
assert_eq!(f.header.width, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codec_descriptions_pick_their_fourcc() {
|
||||
let p = crate::CodecParams::new(crate::CodecId::Mpeg4Video).with_size(640, 360).with_extradata(vec![0, 0, 1, 0xB0]);
|
||||
let v = VideoSpec::for_codec(&p, 25, 1).unwrap();
|
||||
assert_eq!((v.format.compression, v.format.width, v.format.height, &v.format.extra[..]), (*b"FMP4", 640, 360, &[0, 0, 1, 0xB0][..]));
|
||||
assert!(VideoSpec::for_codec(&crate::CodecParams::new(crate::CodecId::H264), 25, 1).is_none(), "no size");
|
||||
assert!(VideoSpec::for_codec(&crate::CodecParams::new(crate::CodecId::Aac).with_size(1, 1), 25, 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_is_an_error() {
|
||||
let new = |streams: Vec<StreamSpec>, opts| AviWriter::new(Cursor::new(Vec::new()), streams, opts).err();
|
||||
let d = MuxOptions::default();
|
||||
assert!(new(Vec::new(), d).is_some());
|
||||
assert!(new(vec![StreamSpec::Video(video()); 101], d).is_some());
|
||||
assert!(new(vec![StreamSpec::Video(video())], MuxOptions { riff_limit: 1000, ..d }).is_some());
|
||||
assert!(new(vec![StreamSpec::Video(video())], MuxOptions { riff_limit: 3 << 30, ..d }).is_some());
|
||||
assert!(new(vec![StreamSpec::Video(video())], MuxOptions { max_riffs: 0, ..d }).is_some());
|
||||
assert!(new(vec![StreamSpec::Video(VideoSpec::new(*b"XVID", 16, 16, 0, 1))], d).is_some());
|
||||
let mut a = pcm();
|
||||
a.format.block_align = 0;
|
||||
assert!(new(vec![StreamSpec::Audio(a)], d).is_some());
|
||||
let mut a = pcm();
|
||||
a.timing = AudioTiming::Packets { samples_per_packet: 0 };
|
||||
assert!(new(vec![StreamSpec::Audio(a)], d).is_some());
|
||||
// max_riffs large enough that the header list cannot fit in half a small RIFF
|
||||
assert!(new(vec![StreamSpec::Video(video())], MuxOptions { riff_limit: 16 << 10, max_riffs: 1000 }).is_some());
|
||||
|
||||
let mut w = AviWriter::new(Cursor::new(Vec::new()), vec![StreamSpec::Video(video())], MuxOptions { riff_limit: 16 << 10, max_riffs: 2 }).unwrap();
|
||||
assert!(w.write_chunk(1, &[0], true).is_err(), "no such stream");
|
||||
assert!(w.write_chunk(0, &vec![0; 20 << 10], true).is_err(), "larger than a RIFF");
|
||||
w.write_chunk(0, &vec![0; 10 << 10], true).unwrap();
|
||||
w.write_chunk(0, &vec![0; 10 << 10], true).unwrap(); // the second RIFF
|
||||
assert!(w.write_chunk(0, &vec![0; 10 << 10], true).is_err(), "a third RIFF is past max_riffs");
|
||||
// what was written still closes into a readable file
|
||||
let file = w.finish().unwrap().into_inner();
|
||||
assert_eq!(open(&file).unwrap().streams[0].chunks.len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! AVI files from [`AviWriter`] read by ffmpeg and ffprobe (skipped without them): ffmpeg makes
|
||||
//! an MPEG-4 Part 2 elementary stream and PCM, the muxer interleaves them, and ffmpeg must decode
|
||||
//! the AVI to exactly what it decodes from the sources, in one RIFF and across AVIX extensions.
|
||||
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use craft_media::avi::{AudioSpec, AviWriter, MuxOptions, StreamSpec, VideoSpec};
|
||||
|
||||
const FRAMES: usize = 50;
|
||||
/// 25 frames a second of 48 kHz stereo 16-bit PCM: bytes of audio a frame.
|
||||
const AUDIO_PER_FRAME: usize = 48_000 / 25 * 4;
|
||||
|
||||
fn tools() -> Option<(PathBuf, PathBuf)> {
|
||||
let ff = craft_media_testkit::ffmpeg_or_skip("avi muxer oracle")?;
|
||||
let probe = craft_media_testkit::ffprobe()?;
|
||||
Some((ff, probe))
|
||||
}
|
||||
|
||||
fn run(cmd: &mut Command) -> Vec<u8> {
|
||||
let out = cmd.output().expect("run tool");
|
||||
assert!(out.status.success(), "{:?}: {}", cmd, String::from_utf8_lossy(&out.stderr));
|
||||
// A plain WAVEFORMATEX has no channel mask, so ffmpeg notes the layout it assumes for any
|
||||
// stereo PCM in AVI, its own files included. Every other line is a failure.
|
||||
let diag: Vec<String> =
|
||||
String::from_utf8_lossy(&out.stderr).lines().filter(|l| !l.contains("Guessed Channel Layout: stereo")).map(str::to_owned).collect();
|
||||
assert!(diag.is_empty(), "{cmd:?} reported: {}", diag.join("\n"));
|
||||
out.stdout
|
||||
}
|
||||
|
||||
/// The sources: an MPEG-4 Part 2 elementary stream (I-VOP every 12 frames, no B-VOPs) and raw
|
||||
/// PCM, both from ffmpeg.
|
||||
fn sources(ff: &Path, dir: &Path) -> (Vec<u8>, Vec<u8>) {
|
||||
let m4v = dir.join("video.m4v");
|
||||
run(Command::new(ff)
|
||||
.args(["-v", "warning", "-y", "-f", "lavfi", "-i", "testsrc2=s=160x120:r=25"])
|
||||
.args(["-frames:v", &FRAMES.to_string(), "-c:v", "mpeg4", "-threads", "1", "-q:v", "5", "-g", "12", "-bf", "0", "-f", "m4v"])
|
||||
.arg(&m4v));
|
||||
let pcm = run(Command::new(ff)
|
||||
.args(["-v", "warning", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"])
|
||||
.args(["-t", "2", "-ac", "2", "-f", "s16le", "-"]));
|
||||
(std::fs::read(&m4v).expect("read m4v"), pcm)
|
||||
}
|
||||
|
||||
/// The VOPs of an elementary stream: each runs from its start code (the first from the start of
|
||||
/// the stream, so it carries the configuration headers) to the next. An I-VOP is a key frame.
|
||||
fn vops(es: &[u8]) -> Vec<(&[u8], bool)> {
|
||||
let starts: Vec<usize> = es.windows(4).enumerate().filter(|(_, w)| *w == [0, 0, 1, 0xB6]).map(|(i, _)| i).collect();
|
||||
let mut out = Vec::new();
|
||||
for (k, &s) in starts.iter().enumerate() {
|
||||
let from = if k == 0 { 0 } else { s };
|
||||
let to = starts.get(k + 1).copied().unwrap_or(es.len());
|
||||
out.push((&es[from..to], es.get(s + 4).is_some_and(|b| b >> 6 == 0)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mux(video: &[(&[u8], bool)], pcm: &[u8], opts: MuxOptions) -> Vec<u8> {
|
||||
let streams = vec![StreamSpec::Video(VideoSpec::new(*b"XVID", 160, 120, 25, 1)), StreamSpec::Audio(AudioSpec::pcm(48_000, 2, 16))];
|
||||
let mut w = AviWriter::new(Cursor::new(Vec::new()), streams, opts).expect("writer");
|
||||
for (i, (vop, key)) in video.iter().enumerate() {
|
||||
w.write_chunk(0, vop, *key).expect("video");
|
||||
let a = pcm.get(i * AUDIO_PER_FRAME..((i + 1) * AUDIO_PER_FRAME).min(pcm.len())).unwrap_or_default();
|
||||
w.write_chunk(1, a, true).expect("audio");
|
||||
}
|
||||
w.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn decode(ff: &Path, input: &Path, format: Option<&str>, map: &str, out: &[&str]) -> Vec<u8> {
|
||||
let mut c = Command::new(ff);
|
||||
c.args(["-v", "warning", "-nostdin"]);
|
||||
if let Some(f) = format {
|
||||
c.args(["-f", f]);
|
||||
}
|
||||
c.arg("-i").arg(input).args(["-map", map]).args(out).arg("-");
|
||||
run(&mut c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_decodes_muxed_files_like_their_sources() {
|
||||
let Some((ff, probe)) = tools() else { return };
|
||||
let dir = craft_media_testkit::private_dir("avi-mux");
|
||||
let (es, pcm) = sources(&ff, &dir);
|
||||
let video = vops(&es);
|
||||
assert_eq!(video.len(), FRAMES);
|
||||
assert!(video.iter().filter(|v| v.1).count() >= 4);
|
||||
let want_video = decode(&ff, &dir.join("video.m4v"), Some("m4v"), "0:v", &["-f", "rawvideo", "-pix_fmt", "yuv420p"]);
|
||||
|
||||
for (name, opts) in [("one_riff.avi", MuxOptions::default()), ("avix.avi", MuxOptions { riff_limit: 64 << 10, max_riffs: 64 })] {
|
||||
let file = mux(&video, &pcm, opts);
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, &file).expect("write avi");
|
||||
if name == "avix.avi" {
|
||||
assert!(file.windows(4).filter(|w| *w == b"AVIX").count() >= 3, "{name}: AVIX extensions");
|
||||
}
|
||||
let got_video = decode(&ff, &path, None, "0:v", &["-f", "rawvideo", "-pix_fmt", "yuv420p"]);
|
||||
assert!(got_video == want_video, "{name}: decoded pictures differ from the elementary stream's");
|
||||
// ffmpeg reads an interleaved file front to back; a seek goes through the index (OpenDML
|
||||
// when the file has it). 1.0 s lands after key frame 24, so frame 25 comes out first.
|
||||
for at in ["1.0", "1.6"] {
|
||||
let frame = (at.parse::<f64>().unwrap_or(0.0) * 25.0).round() as usize;
|
||||
let size = 160 * 120 * 3 / 2;
|
||||
let mut c = Command::new(&ff);
|
||||
c.args(["-v", "warning", "-nostdin", "-ss", at, "-i"]).arg(&path).args([
|
||||
"-map",
|
||||
"0:v",
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-",
|
||||
]);
|
||||
let got = run(&mut c);
|
||||
assert!(got == want_video[frame * size..(frame + 1) * size], "{name}: seeking to {at} s gave another picture than frame {frame}");
|
||||
}
|
||||
let got_audio = decode(&ff, &path, None, "0:a", &["-f", "s16le"]);
|
||||
assert!(got_audio == pcm, "{name}: decoded audio differs from the PCM ({} vs {} bytes)", got_audio.len(), pcm.len());
|
||||
|
||||
let info = String::from_utf8_lossy(&run(Command::new(&probe)
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-count_packets",
|
||||
"-show_entries",
|
||||
"stream=codec_name,codec_tag_string,width,height,avg_frame_rate,nb_read_packets,sample_rate,channels",
|
||||
"-of",
|
||||
"default=nw=1",
|
||||
])
|
||||
.arg(&path)))
|
||||
.into_owned();
|
||||
for want in [
|
||||
"codec_name=mpeg4",
|
||||
"codec_tag_string=XVID",
|
||||
"width=160",
|
||||
"height=120",
|
||||
"avg_frame_rate=25/1",
|
||||
"nb_read_packets=50",
|
||||
"codec_name=pcm_s16le",
|
||||
"sample_rate=48000",
|
||||
"channels=2",
|
||||
] {
|
||||
assert!(info.lines().any(|l| l == want), "{name}: ffprobe lacks {want}:\n{info}");
|
||||
}
|
||||
let duration = String::from_utf8_lossy(&run(Command::new(&probe)
|
||||
.args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"])
|
||||
.arg(&path)))
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.unwrap_or(0.0);
|
||||
assert!((duration - 2.0).abs() < 0.05, "{name}: duration {duration}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -58,6 +58,8 @@ rayon = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
craft-media-testkit = { workspace = true }
|
||||
# The AVI muxer, for the MPEG-4 Part 2 encoder's end-to-end test.
|
||||
craft-media = { workspace = true, features = ["avi"] }
|
||||
|
||||
[[test]]
|
||||
name = "dnx"
|
||||
|
||||
@@ -322,3 +322,37 @@ fn extreme_content_at_the_finest_quantiser() {
|
||||
assert!(d >= 45.0, "frame {i}: {d:.1} dB from the reconstruction");
|
||||
}
|
||||
}
|
||||
|
||||
/// The encoder's packets in an AVI (the track description through `VideoSpec::for_codec`) decode
|
||||
/// in ffmpeg to exactly what the raw stream decodes to. Tagged `XVID` they would not: ffmpeg then
|
||||
/// decodes with Xvid's IDCT.
|
||||
#[test]
|
||||
fn packets_play_from_an_avi_file() {
|
||||
use craft_media::avi::{AviWriter, MuxOptions, StreamSpec, VideoSpec};
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
let mut cfg = EncoderConfig::new(160, 96, 25, 1);
|
||||
cfg.keyint = 10;
|
||||
let Encoded { enc, packets, .. } = encode(cfg, 30);
|
||||
let spec = VideoSpec::for_codec(&enc.codec_params(), 25, 1).expect("an AVI FourCC for MPEG-4 Part 2");
|
||||
assert_eq!(spec.format.compression, *b"FMP4");
|
||||
let mut w = AviWriter::new(std::io::Cursor::new(Vec::new()), vec![StreamSpec::Video(spec)], MuxOptions::default()).expect("writer");
|
||||
for p in &packets {
|
||||
w.write_chunk(0, &p.data, p.keyframe).expect("chunk");
|
||||
}
|
||||
let avi = w.finish().expect("finish").into_inner();
|
||||
let dir = out_dir("avi");
|
||||
let (avi_path, es_path) = (dir.join("stream.avi"), dir.join("stream.m4v"));
|
||||
std::fs::write(&avi_path, avi).expect("write avi");
|
||||
write_stream(&es_path, &packets);
|
||||
let (_, want) = ffmpeg_decode(&ff, &es_path);
|
||||
let out = Command::new(&ff)
|
||||
.args(["-hide_banner", "-nostdin", "-v", "warning", "-i"])
|
||||
.arg(&avi_path)
|
||||
.args(["-f", "rawvideo", "-pix_fmt", "yuv420p", "-"])
|
||||
.output()
|
||||
.expect("run ffmpeg");
|
||||
let diag = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && diag.trim().is_empty(), "{diag}");
|
||||
assert_eq!(out.stdout.len(), 160 * 96 * 3 / 2 * 30);
|
||||
assert!(out.stdout == want, "the AVI decodes differently from the raw stream");
|
||||
}
|
||||
Reference in new issue
Block a user