diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index cdbf28349..4943b1771 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -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"] diff --git a/crates/media/README.md b/crates/media/README.md index 387ab8cdd..8eca6ecb5 100644 --- a/crates/media/README.md +++ b/crates/media/README.md @@ -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 = " - 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` diff --git a/crates/media/src/avi/mod.rs b/crates/media/src/avi/mod.rs index 592139cef..eaaa1bbe2 100644 --- a/crates/media/src/avi/mod.rs +++ b/crates/media/src/avi/mod.rs @@ -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; diff --git a/crates/media/src/avi/mux.rs b/crates/media/src/avi/mux.rs new file mode 100644 index 000000000..a6e03e174 --- /dev/null +++ b/crates/media/src/avi/mux.rs @@ -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, +} + +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 { + 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, +} + +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, + /// Stream time of the current RIFF's chunks. + duration: u64, + supers: Vec, +} + +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, x: u16) { + v.extend_from_slice(&x.to_le_bytes()); +} + +fn le32(v: &mut Vec, 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 { + 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 { + chunk(b"LIST", &[ltype.as_slice(), body].concat()) +} + +fn invalid(msg: impl Into) -> 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, max_riffs: u32, first_frames: u32) -> Vec { + 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: W, + /// Where the file starts in `w`. + base: u64, + opts: MuxOptions, + streams: Vec, + /// The stream whose frame period and size the main header gives (the first video stream). + main: Option, + /// 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 AviWriter { + /// Start a file with `streams` (1 to 100) at the current position of `w`. + pub fn new(mut w: W, streams: Vec, opts: MuxOptions) -> Result { + 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 = 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 { + 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) + } +} diff --git a/crates/media/src/avi/mux_tests.rs b/crates/media/src/avi/mux_tests.rs new file mode 100644 index 000000000..96f77a04c --- /dev/null +++ b/crates/media/src/avi/mux_tests.rs @@ -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 { + 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, 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, 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 = 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::>(), [(*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::>(), written[0].iter().map(|c| &c.0).collect::>()); + } 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::(), 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, 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); +} diff --git a/crates/media/tests/oracle_avi_mux.rs b/crates/media/tests/oracle_avi_mux.rs new file mode 100644 index 000000000..f78acc92d --- /dev/null +++ b/crates/media/tests/oracle_avi_mux.rs @@ -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 { + 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::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, Vec) { + 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 = 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 { + 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 { + 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::().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::() + .unwrap_or(0.0); + assert!((duration - 2.0).abs() < 0.05, "{name}: duration {duration}"); + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/video/Cargo.toml b/crates/video/Cargo.toml index 0e31f8e26..ea8d4773d 100644 --- a/crates/video/Cargo.toml +++ b/crates/video/Cargo.toml @@ -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" diff --git a/crates/video/tests/mpeg4_encode/main.rs b/crates/video/tests/mpeg4_encode/main.rs index 0b53c5caf..8185acbc0 100644 --- a/crates/video/tests/mpeg4_encode/main.rs +++ b/crates/video/tests/mpeg4_encode/main.rs @@ -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"); +}