diff --git a/.github/workflows/media.yml b/.github/workflows/media.yml index 5d519b0a..940431f2 100644 --- a/.github/workflows/media.yml +++ b/.github/workflows/media.yml @@ -3,9 +3,9 @@ name: Shared media on: push: branches: [main] - paths: ["crates/media/**", "Cargo.toml", "Cargo.lock", ".github/workflows/media.yml"] + paths: ["crates/media/**", "crates/media-testkit/**", "Cargo.toml", "Cargo.lock", ".github/workflows/media.yml"] pull_request: - paths: ["crates/media/**", "Cargo.toml", "Cargo.lock", ".github/workflows/media.yml"] + paths: ["crates/media/**", "crates/media-testkit/**", "Cargo.toml", "Cargo.lock", ".github/workflows/media.yml"] workflow_dispatch: permissions: @@ -37,6 +37,8 @@ jobs: - run: cargo clippy -p craft-media --all-targets --locked -- -D warnings - run: cargo clippy -p craft-media --all-targets --locked --no-default-features --features matroska -- -D warnings - run: cargo clippy -p craft-media --all-targets --locked --all-features -- -D warnings + - run: cargo clippy -p craft-media-testkit --all-targets --locked -- -D warnings + - run: cargo clippy -p craft-media --all-targets --locked --no-default-features --features serde -- -D warnings - run: cargo check -p craft-media --all-features --locked --target wasm32-unknown-unknown test: timeout-minutes: 15 diff --git a/Cargo.lock b/Cargo.lock index 4920c11a..06d32761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -923,11 +923,17 @@ dependencies = [ name = "craft-media" version = "0.1.0" dependencies = [ + "craft-media-testkit", "proptest", + "serde", "serde_json", "thiserror 2.0.21", ] +[[package]] +name = "craft-media-testkit" +version = "0.1.0" + [[package]] name = "craft-menu" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 37d6c0ee..bc32f74d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ craft-i18n = { path = "crates/i18n" } craft-jpeg = { path = "crates/jpeg" } craft-keymap = { path = "crates/keymap" } craft-media = { path = "crates/media" } +craft-media-testkit = { path = "crates/media-testkit" } craft-menu = { path = "crates/menu" } craft-pcraft-format = { path = "crates/pcraft-format" } craft-prepress = { path = "crates/prepress" } diff --git a/crates/media-testkit/Cargo.toml b/crates/media-testkit/Cargo.toml new file mode 100644 index 00000000..827d2ae6 --- /dev/null +++ b/crates/media-testkit/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "craft-media-testkit" +description = "Oracle-test support (ffmpeg discovery, fixture locations) for the craft-libs media crates. Test support only." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true +publish = false + +[lints] +workspace = true diff --git a/crates/media-testkit/src/lib.rs b/crates/media-testkit/src/lib.rs new file mode 100644 index 00000000..36f609f7 --- /dev/null +++ b/crates/media-testkit/src/lib.rs @@ -0,0 +1,159 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Oracle-test support for the craft-libs media crates (craft-media, craft-audio, and the video +//! crate to come): ffmpeg/ffprobe discovery, the skip-or-fail rule, and fixture locations. +//! ffmpeg is used only as an external fixture generator and oracle (never linked). Test support +//! only: unpublished, and used as a dev-dependency. +//! +//! Tool search order: `CRAFT_MEDIA_FFMPEG` / `CRAFT_MEDIA_FFPROBE` (an explicit path; a wrong +//! path fails the test), then `PATH`, then well-known install directories. A missing tool, a +//! failed fixture generator or a failed ffprobe run prints `SKIPPED` and the test passes, unless +//! `CRAFT_MEDIA_REQUIRE_ORACLES=1` is set: then each of these fails the test. +//! Fixtures are generated into `$CRAFT_MEDIA_FIXTURES_DIR` or `/target/fixtures`, one +//! directory per crate or format, and never committed. +//! +//! Failing loudly is what a test helper is for: a wrong `CRAFT_MEDIA_*` setting or an unwritable +//! fixture directory panics, which fails the test that asked. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const REQUIRE_ENV: &str = "CRAFT_MEDIA_REQUIRE_ORACLES"; + +const KNOWN_DIRS: &[&str] = &[ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/opt/local/bin", + "/snap/bin", + r"C:\ffmpeg\bin", + r"C:\Program Files\ffmpeg\bin", + r"C:\ProgramData\chocolatey\bin", +]; + +fn env_var(name: &str) -> String { + format!("CRAFT_MEDIA_{}", name.to_ascii_uppercase()) +} + +#[allow(clippy::panic)] +fn find_tool(name: &str) -> Option { + if let Some(e) = std::env::var_os(env_var(name)).filter(|e| !e.is_empty()) { + let p = PathBuf::from(e); + assert!(p.is_file(), "{} is set but `{}` is not a file", env_var(name), p.display()); + return Some(p); + } + let names = if cfg!(windows) { [format!("{name}.exe"), name.to_string()] } else { [name.to_string(), format!("{name}.exe")] }; + let path_dirs = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::>()).unwrap_or_default(); + let dirs = path_dirs.into_iter().chain(KNOWN_DIRS.iter().map(PathBuf::from)); + for dir in dirs { + for n in &names { + let p = dir.join(n); + if p.is_file() { + return Some(p); + } + } + } + None +} + +/// Find a tool silently (cached for the process for ffmpeg and ffprobe). +pub fn tool(name: &str) -> Option { + static FFMPEG: OnceLock> = OnceLock::new(); + static FFPROBE: OnceLock> = OnceLock::new(); + match name { + "ffmpeg" => FFMPEG.get_or_init(|| find_tool("ffmpeg")).clone(), + "ffprobe" => FFPROBE.get_or_init(|| find_tool("ffprobe")).clone(), + _ => find_tool(name), + } +} + +pub fn oracles_required() -> bool { + std::env::var(REQUIRE_ENV).is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) +} + +/// Give up on an oracle check: print `SKIPPED` and return `None`, or fail when oracles are +/// required. +pub fn skip(reason: &str) -> Option { + skip_with(oracles_required(), reason) +} + +#[allow(clippy::panic)] +fn skip_with(required: bool, reason: &str) -> Option { + assert!(!required, "oracle: {reason}, and {REQUIRE_ENV}=1 forbids skipping oracle tests"); + eprintln!("SKIPPED (oracle): {reason}"); + None +} + +/// Run a fixture generator writing `tmp`, then rename it to `out`. A generator that cannot start +/// or fails skips (see [`skip`]); `required` is explicit so tests can set it without touching +/// the process environment. +pub fn run_generator(cmd: &mut Command, tmp: &Path, out: &Path, required: bool) -> Option { + let reason = match cmd.output() { + Ok(o) if o.status.success() => match std::fs::rename(tmp, out) { + Ok(()) => return Some(out.to_path_buf()), + Err(e) => format!("could not move {} into place: {e}", tmp.display()), + }, + Ok(o) => format!("generator failed for {} ({}): {}", out.display(), o.status, String::from_utf8_lossy(&o.stderr).trim()), + Err(e) => format!("could not start generator for {}: {e}", out.display()), + }; + let _ = std::fs::remove_file(tmp); + skip_with(required, &format!("{reason} (encoder unavailable?)")) +} + +fn tool_or_skip(name: &str) -> Option { + tool(name).or_else(|| skip(&format!("{name} not found (set {} or put {name} on PATH)", env_var(name)))) +} + +/// ffmpeg, or `None` after printing `SKIPPED` (a failure with `CRAFT_MEDIA_REQUIRE_ORACLES=1`). +pub fn ffmpeg() -> Option { + tool_or_skip("ffmpeg") +} + +/// ffprobe, or `None` after printing `SKIPPED` (a failure with `CRAFT_MEDIA_REQUIRE_ORACLES=1`). +pub fn ffprobe() -> Option { + tool_or_skip("ffprobe") +} + +/// `$CRAFT_MEDIA_FIXTURES_DIR/` or `/target/fixtures/`, created if missing. +#[allow(clippy::unwrap_used)] +pub fn fixture_subdir(name: &str) -> PathBuf { + let d = match std::env::var_os("CRAFT_MEDIA_FIXTURES_DIR").filter(|v| !v.is_empty()) { + Some(d) => PathBuf::from(d), + None => { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + let root = here.parent().and_then(Path::parent).unwrap_or(here); + root.join("target").join("fixtures") + } + } + .join(name); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A temporary sibling of `out` that no other thread or process will pick; the extension is kept +/// because ffmpeg chooses the muxer from it. +pub fn temp_path(out: &Path) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let tid = format!("{:?}", std::thread::current().id()).replace(|c: char| !c.is_ascii_alphanumeric(), ""); + let n = N.fetch_add(1, Ordering::Relaxed); + let stem = out.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + let name = match out.extension() { + Some(ext) => format!("{stem}.tmp-{}-{tid}-{n}.{}", std::process::id(), ext.to_string_lossy()), + None => format!("{stem}.tmp-{}-{tid}-{n}", std::process::id()), + }; + out.with_file_name(name) +} + +/// Run a fixture generator and print `FIXTURE `. +pub fn generate_and_report(name: &str, outputs: &[PathBuf], generate: impl FnOnce() -> Option) -> Option { + let existed = !outputs.is_empty() && outputs.iter().all(|p| std::fs::metadata(p).is_ok_and(|m| m.len() > 0)); + let r = generate(); + let status = match (&r, existed) { + (None, _) => "skipped", + (Some(_), true) => "cached", + (Some(_), false) => "made", + }; + println!("FIXTURE {status} {name}"); + r +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index dd1db2c6..0d5cbf35 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true rust-version.workspace = true repository.workspace = true +[package.metadata.docs.rs] +all-features = true + [lints] workspace = true @@ -15,11 +18,15 @@ default = ["isobmff"] bitstream = [] isobmff = ["bitstream", "dep:thiserror"] matroska = [] +# Serialize and Deserialize for `Container`, `CodecId`, `CodecParams` and `PcmFormat`. +serde = ["dep:serde"] [dependencies] thiserror = { workspace = true, optional = true } +serde = { workspace = true, optional = true } [dev-dependencies] +craft-media-testkit = { workspace = true } proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/media/README.md b/crates/media/README.md index a408f04d..bee14288 100644 --- a/crates/media/README.md +++ b/crates/media/README.md @@ -1,9 +1,13 @@ # craft-media -Media containers and bit-level I/O for the Craft apps: an MSB-first bit reader and writer, and -clean-room ISO BMFF (MP4), QuickTime (MOV) and Matroska (MKV, WebM) demuxers and muxers written -from the public specifications. No `unsafe`, no panics on malformed input, and it builds for -`wasm32-unknown-unknown`. The only dependency is `thiserror` (for `isobmff`). +Media containers and bit-level I/O for the Craft apps: an MSB-first bit reader and writer, +clean-room ISO BMFF (MP4), QuickTime (MOV) and Matroska (MKV, WebM) demuxers and muxers, written from the public specifications. No `unsafe`, no panics on malformed input, builds +for `wasm32-unknown-unknown`. Dependencies: `thiserror` (for `isobmff`), `serde` (optional). + +Codecs go in separate crates that depend on this one: `craft-audio` for audio (in a follow-up), +and a video codec crate later. What both need lives here: the containers, the +bit I/O, one `ByteSource` trait, and the [shared identifiers](#containers-codecs-and-codec-parameters) +that codec crates use to describe a file and configure a decoder. ## Features @@ -12,10 +16,12 @@ from the public specifications. No `unsafe`, no panics on malformed input, and i | `bitstream` | `craft_media::bitstream` | none | | `isobmff` | `craft_media::isobmff` | `bitstream`, `thiserror` | | `matroska` | `craft_media::matroska` | none | +| (none) | `Container`, `CodecId`, `CodecParams`, `PcmFormat`, `xiph_lace`, `ByteSource`, `ReadSeekSource` | none | +| `serde` | `Serialize`/`Deserialize` on `Container`, `CodecId`, `CodecParams` and `PcmFormat` | `serde` | `default = ["isobmff"]`, so `bitstream` and `isobmff` build. `matroska` is opt-in: it is about as much code as `isobmff`, and most users of MP4/MOV (LightCraft, FigCraft, SoundCraft, EffectCraft) -do not read Matroska. The features are independent; `matroska` does not need the other two. +do not read Matroska. The features are independent. ```toml craft-media = { git = "https://github.com/storytold/craft-libs", rev = "", features = ["matroska"] } @@ -30,9 +36,12 @@ craft-media = { git = "https://github.com/storytold/craft-libs", rev = " ## Boundaries - In scope: bit-level reading and writing, and container demuxing and muxing (boxes, sample - tables, sample entries and codec configuration records). -- Out of scope for now: codecs (H.264, HEVC, ProRes, AAC and others). They stay in the apps. - Ogg and other containers can join later as their own features. + 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 and + others) stay in the apps until a video crate exists. Containers only FilmCraft reads (MXF, + MPEG-TS/PS, AVI) are not here yet. +- Anything both the audio and the video codec crates need belongs here, so the two never depend + on each other. ## Origin and API @@ -98,9 +107,14 @@ Other differences: accepted any entry. - `isobmff::Mp4Writer::write_sample` returns an error when a track would pass 2³² − 1 samples (PCM: frames), the most `stsz` can count; FilmCraft clamped the count. -- `matroska::ByteSource` stays a separate trait from `isobmff::ByteSource`, with the same shape. - FilmCraft implements both for one type, which a single shared trait would reject as conflicting - impls. +- `isobmff::ByteSource` and `matroska::ByteSource` are one trait, + `craft_media::ByteSource` (with `ReadSeekSource`, which was Matroska's). A type FilmCraft + implements both traits for keeps one of its two impls. +- `isobmff::Track` and `matroska::Track` have `codec_params()`, both returning a + `CodecParams` (`CodecId::Unknown` for a codec without an identifier). The MP4 one describes + the first sample's sample description; `codec_params_for(description_index)` gives any other, + and `has_mixed_descriptions()` says whether the samples use more than one. + `isobmff::CodecConfig` has `codec_id()` and `extradata()`, and `OpusConfig::opus_head()`. ## `bitstream` @@ -170,7 +184,7 @@ Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are suppor | `jpeg mjpa mjpb` | `Jpeg { fourcc }` | | `AVdn AVdh` | `Dnx { fourcc }` | | `mp4a` + `esds` (in `wave` too) | `Aac(AacConfig)`: ASC bytes + decoded object type/rate/channels; `Mp3` for OTI 0x69/0x6B | -| `.mp3`/`.mp2` | `Mp3`: MPEG-1/2 audio of any layer (each frame header names it) | +| `.mp3`/`.mp2` | `Mp3`: MPEG-1/2 audio of any layer (each frame header names it; `Track::codec_params()` reports `Mp1`, `Mp2` or `Mp3` from the first frame) | | PCM fourccs | `Pcm(PcmConfig)`: bits, float, endianness (`enda`, lpcm flags, `pcmC`), channels, rate | | `alac` | `Alac { cookie }` | | `Opus` + `dOps` | `Opus(OpusConfig)` | @@ -383,6 +397,38 @@ first frame (AV1 from `rav1e`) creates the writer after that frame. from codec parsers). - Muxer: no CRC-32, Chapters, Tags, Attachments, content encodings or block additions. +## Containers, codecs and codec parameters + +Always built, with no dependencies, so codec crates share them: + +- `Container`: the file format (`Wav`, `Aiff`, `Flac`, `Mp4`, `QuickTime`, `Matroska`, `WebM`, + `Ogg`, `Caf`, `Adts`, `MpegAudio`, `Ac3`), with `name()`. +- `CodecId`: audio and video codecs (`Aac`, `HeAac`, `Opus`, `Eac3`, `Speex`, …, `H264`, `Hevc`, + `Av1`, `ProRes`, `Dnxhd`, `Apv`, `Theora`, …, `Unknown`), with `name()`, `is_audio()`, + `is_video()`. +- `PcmFormat`: the layout of uncompressed samples, built with `PcmFormat::int(bits, signed, + big_endian)` or `PcmFormat::float(bits, big_endian)`. +- `CodecParams`: what a decoder needs about a track, whatever its container: `codec`, + `extradata`, `tag` (the container's four-character code, such as a ProRes profile), the audio + fields `sample_rate`, `channels` and `pcm` (`PcmFormat`), and the video fields `width` and + `height`. Built with `CodecParams::new(codec)` and the `with_*` methods, or by the demuxers' + `codec_params()`, which every container returns in the same form. `extradata` follows + Matroska's `CodecPrivate`, as ffmpeg's extradata does: the `AudioSpecificConfig` for AAC, the + `OpusHead` for Opus (MP4's `dOps` is converted), Xiph-laced headers for Vorbis (`xiph_lace`, + `None` for more than 256 packets), `fLaC` + metadata blocks for FLAC, the + `avcC`/`hvcC`/`av1C`/`apvC` record for video. + +All are `#[non_exhaustive]`: new formats and fields are minor changes. + +## `ByteSource` + +`craft_media::ByteSource` (`len`, `read_at(offset, buf)`) is what every demuxer reads from. It is +always built, so a crate that uses only `bitstream` can name it too. `isobmff::ByteSource` and +`matroska::ByteSource` are the same trait. It is implemented for `[u8]`, +`Vec`, `&T`, `Arc`, `Box` and `std::fs::File` (not on wasm). `ReadSeekSource` adapts any +`Read + Seek`; it is `Send + Sync` when the reader is `Send`. Implement the trait once for a +custom source (a web `Blob`, a network range reader) to use every container with it. + ## Tests - Unit tests cover the bit reader and writer, the box reader (64-bit, size-0, `uuid`, truncated and @@ -406,9 +452,10 @@ first frame (AV1 from `rav1e`) creates the writer after that frame. `fuzz.rs` also walks every box of a mutated file as a container and checks that each payload range stays inside its parent. -ffmpeg and ffprobe are test oracles only, never linked. They are looked up in +ffmpeg and ffprobe are test oracles only, never linked. The helpers are in `craft-media-testkit` +(an unpublished crate, shared with craft-audio and the video crate to come). They are looked up in `$CRAFT_MEDIA_FFMPEG` / `$CRAFT_MEDIA_FFPROBE`, then `PATH`, then common install directories. When a tool is missing, the oracle tests print `SKIPPED` and pass; `CRAFT_MEDIA_REQUIRE_ORACLES=1` turns a skip into a failure. CI (`.github/workflows/media.yml`) sets it and installs Ubuntu's ffmpeg. -Generate every fixture ahead of time with +craft-audio's oracle tests use the same variables. Generate every fixture ahead of time with `cargo test -p craft-media --all-features -- --ignored generate_fixtures`. diff --git a/crates/media/src/id.rs b/crates/media/src/id.rs new file mode 100644 index 00000000..6ee5f9fd --- /dev/null +++ b/crates/media/src/id.rs @@ -0,0 +1,376 @@ +//! Container and codec identifiers and codec parameters shared by the codec crates (craft-audio, +//! and the video crate to come), so a file's container and codec are named the same way +//! everywhere, and a codec is configured the same way whatever container its track came from. +//! +//! Both enums are `#[non_exhaustive]`: new formats are added as minor changes, so a `match` on them +//! needs a `_` arm. + +#![warn(missing_docs)] + +/// The file or stream format that holds the codec data. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Container { + /// RIFF WAVE, including BWF and RF64/BW64. + Wav, + /// AIFF and AIFF-C. + Aiff, + /// A native FLAC stream (`fLaC`). + Flac, + /// ISO base media file format: MP4, M4A, M4B. + Mp4, + /// QuickTime (MOV). + QuickTime, + /// Matroska (MKV, MKA). + Matroska, + /// WebM (the Matroska subset). + WebM, + /// Ogg. + Ogg, + /// Apple Core Audio Format. + Caf, + /// A raw ADTS AAC stream. + Adts, + /// A raw MPEG audio stream (MP1, MP2 or MP3 frames, optionally behind ID3v2 tags). + MpegAudio, + /// A raw AC-3 or E-AC-3 stream. + Ac3, +} + +impl Container { + /// A short display name: `"WAV"`, `"MP4"`, `"Matroska"`. + pub fn name(self) -> &'static str { + match self { + Container::Wav => "WAV", + Container::Aiff => "AIFF", + Container::Flac => "FLAC", + Container::Mp4 => "MP4", + Container::QuickTime => "QuickTime", + Container::Matroska => "Matroska", + Container::WebM => "WebM", + Container::Ogg => "Ogg", + Container::Caf => "CAF", + Container::Adts => "ADTS", + Container::MpegAudio => "MPEG audio", + Container::Ac3 => "AC-3", + } + } +} + +/// A codec, audio or video. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum CodecId { + /// Uncompressed PCM, integer or float. + Pcm, + /// ADPCM (IMA, Microsoft, G.722, G.726). + Adpcm, + /// G.711 A-law. + ALaw, + /// G.711 µ-law. + MuLaw, + /// FLAC. + Flac, + /// Apple Lossless. + Alac, + /// AAC (LC). Containers report every AAC stream as `Aac`; a decoder that finds spectral band + /// replication reports [`CodecId::HeAac`]. + Aac, + /// HE-AAC (AAC with spectral band replication, v1 or v2). + HeAac, + /// MPEG-1/2 Audio Layer I. + Mp1, + /// MPEG-1/2 Audio Layer II. + Mp2, + /// MPEG-1/2 Audio Layer III. + Mp3, + /// AC-3 (Dolby Digital). + Ac3, + /// E-AC-3 (Dolby Digital Plus). + Eac3, + /// Opus. + Opus, + /// Vorbis. + Vorbis, + /// Speex. + Speex, + /// H.264 / AVC. + H264, + /// H.265 / HEVC. + Hevc, + /// AV1. + Av1, + /// VP8. + Vp8, + /// VP9. + Vp9, + /// Apple ProRes. + ProRes, + /// Motion JPEG. + Mjpeg, + /// MPEG-2 video. + Mpeg2Video, + /// MPEG-4 Part 2 video. + Mpeg4Video, + /// Theora. + Theora, + /// Avid DNxHD / DNxHR. + Dnxhd, + /// APV (Advanced Professional Video). + Apv, + /// A codec this crate has no identifier for. + Unknown, +} + +impl CodecId { + /// A short display name: `"AAC"`, `"HE-AAC"`, `"E-AC-3"`, `"H.264"`. + pub fn name(self) -> &'static str { + match self { + CodecId::Pcm => "PCM", + CodecId::Adpcm => "ADPCM", + CodecId::ALaw => "A-law", + CodecId::MuLaw => "µ-law", + CodecId::Flac => "FLAC", + CodecId::Alac => "ALAC", + CodecId::Aac => "AAC", + CodecId::HeAac => "HE-AAC", + CodecId::Mp1 => "MP1", + CodecId::Mp2 => "MP2", + CodecId::Mp3 => "MP3", + CodecId::Ac3 => "AC-3", + CodecId::Eac3 => "E-AC-3", + CodecId::Opus => "Opus", + CodecId::Vorbis => "Vorbis", + CodecId::Speex => "Speex", + CodecId::H264 => "H.264", + CodecId::Hevc => "HEVC", + CodecId::Av1 => "AV1", + CodecId::Vp8 => "VP8", + CodecId::Vp9 => "VP9", + CodecId::ProRes => "ProRes", + CodecId::Mjpeg => "Motion JPEG", + CodecId::Mpeg2Video => "MPEG-2", + CodecId::Mpeg4Video => "MPEG-4", + CodecId::Theora => "Theora", + CodecId::Dnxhd => "DNxHD", + CodecId::Apv => "APV", + CodecId::Unknown => "unknown", + } + } + + /// Whether this is an audio codec. + pub fn is_audio(self) -> bool { + matches!( + self, + CodecId::Pcm + | CodecId::Adpcm + | CodecId::ALaw + | CodecId::MuLaw + | CodecId::Flac + | CodecId::Alac + | CodecId::Aac + | CodecId::HeAac + | CodecId::Mp1 + | CodecId::Mp2 + | CodecId::Mp3 + | CodecId::Ac3 + | CodecId::Eac3 + | CodecId::Opus + | CodecId::Vorbis + | CodecId::Speex + ) + } + + /// Whether this is a video codec. + pub fn is_video(self) -> bool { + matches!( + self, + CodecId::H264 + | CodecId::Hevc + | CodecId::Av1 + | CodecId::Vp8 + | CodecId::Vp9 + | CodecId::ProRes + | CodecId::Mjpeg + | CodecId::Mpeg2Video + | CodecId::Mpeg4Video + | CodecId::Theora + | CodecId::Dnxhd + | CodecId::Apv + ) + } +} + +/// The layout of uncompressed PCM samples. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PcmFormat { + /// Bits per sample (8, 16, 24, 32 for integers; 32 or 64 for floats). + pub bits: u16, + /// IEEE float samples. + pub float: bool, + /// Big-endian byte order. + pub big_endian: bool, + /// Signed integers (8-bit WAV and `raw ` QuickTime PCM are unsigned). + pub signed: bool, +} + +impl PcmFormat { + /// Integer samples of `bits` bits. + pub fn int(bits: u16, signed: bool, big_endian: bool) -> PcmFormat { + PcmFormat { bits, float: false, big_endian, signed } + } + + /// IEEE float samples of `bits` bits (32 or 64). + pub fn float(bits: u16, big_endian: bool) -> PcmFormat { + PcmFormat { bits, float: true, big_endian, signed: true } + } +} + +/// What a decoder needs to know about a track, whatever container it came from. +/// +/// `extradata` is the codec configuration in Matroska's `CodecPrivate` form, which ffmpeg's +/// extradata also follows: +/// +/// | Codec | `extradata` | +/// |---|---| +/// | AAC | `AudioSpecificConfig` | +/// | Opus | the `OpusHead` identification header (RFC 7845 §5.1) | +/// | Vorbis | the three headers, Xiph-laced | +/// | FLAC | `fLaC` followed by the metadata blocks (STREAMINFO first) | +/// | ALAC | the magic cookie (`ALACSpecificConfig`) | +/// | AC-3 / E-AC-3 | the MP4 `dac3` / `dec3` payload, or empty | +/// | MP1/MP2/MP3, PCM | empty | +/// | H.264 / HEVC / AV1 | the `avcC` / `hvcC` / `av1C` record | +/// | VP9 | the MP4 `vpcC` payload, or Matroska's codec feature metadata | +/// | APV | the `apvC` record | +/// +/// `sample_rate` and `channels` are what the container declares (for HE-AAC with implicit +/// signalling, usually the core rate), except that Opus is always 48 kHz, the rate it decodes at; +/// `width` and `height` are the coded picture size of a video +/// track. `tag` is the container's four-character code for the codec when it has one (the MP4 +/// sample entry format, Matroska's ProRes or VfW FourCC), which is how a ProRes profile (`apcn`, +/// `ap4h`, …) or a DNx variant is told apart. +/// +/// `#[non_exhaustive]`: build it with [`CodecParams::new`] and the `with_*` methods. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CodecParams { + /// The codec. + pub codec: CodecId, + /// The codec configuration (see the table above). + pub extradata: Vec, + /// The container's four-character code for the codec, if it has one. + pub tag: Option<[u8; 4]>, + /// Audio: samples per second. + pub sample_rate: Option, + /// Audio: channel count. + pub channels: Option, + /// Audio: the sample layout of uncompressed PCM. + pub pcm: Option, + /// Video: coded picture width in pixels. + pub width: Option, + /// Video: coded picture height in pixels. + pub height: Option, +} + +impl CodecParams { + /// Parameters for `codec`, with nothing else known yet. + pub fn new(codec: CodecId) -> CodecParams { + CodecParams { codec, extradata: Vec::new(), tag: None, sample_rate: None, channels: None, pcm: None, width: None, height: None } + } + + /// Sets `tag`. + pub fn with_tag(mut self, tag: [u8; 4]) -> CodecParams { + self.tag = Some(tag); + self + } + + /// The coded picture size; a zero dimension leaves both unknown. + pub fn with_size(mut self, width: u32, height: u32) -> CodecParams { + (self.width, self.height) = if width > 0 && height > 0 { (Some(width), Some(height)) } else { (None, None) }; + self + } + + /// Sets `extradata`. + pub fn with_extradata(mut self, extradata: impl Into>) -> CodecParams { + self.extradata = extradata.into(); + self + } + + /// Sets `sample_rate`; 0 leaves it unknown. + pub fn with_sample_rate(mut self, sample_rate: u32) -> CodecParams { + self.sample_rate = (sample_rate > 0).then_some(sample_rate); + self + } + + /// Sets `channels`; 0 leaves it unknown. + pub fn with_channels(mut self, channels: u16) -> CodecParams { + self.channels = (channels > 0).then_some(channels); + self + } + + /// Sets `pcm`. + pub fn with_pcm(mut self, pcm: PcmFormat) -> CodecParams { + self.pcm = Some(pcm); + self + } +} + +/// Xiph lacing of `packets` (Matroska `CodecPrivate` for Vorbis): the count less one, each +/// length but the last as a run of 255s and a remainder, then the packets. `None` for no packets +/// or more than 256, which the one-byte count cannot hold. +pub fn xiph_lace(packets: &[Vec]) -> Option> { + let mut out = vec![u8::try_from(packets.len().checked_sub(1)?).ok()?]; + for p in packets.iter().take(packets.len().saturating_sub(1)) { + let mut n = p.len(); + while n >= 255 { + out.push(255); + n -= 255; + } + out.push(n as u8); + } + for p in packets { + out.extend_from_slice(p); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audio_and_video_are_disjoint() { + for c in [CodecId::Aac, CodecId::Opus, CodecId::Pcm, CodecId::Eac3, CodecId::Speex] { + assert!(c.is_audio() && !c.is_video(), "{c:?}"); + } + for c in [CodecId::H264, CodecId::Av1, CodecId::ProRes, CodecId::Theora, CodecId::Dnxhd, CodecId::Apv] { + assert!(c.is_video() && !c.is_audio(), "{c:?}"); + } + assert!(!CodecId::Unknown.is_audio() && !CodecId::Unknown.is_video()); + } + + #[test] + fn params_builder() { + let p = CodecParams::new(CodecId::Aac).with_extradata([0x12, 0x10]).with_sample_rate(48_000).with_channels(2).with_sample_rate(0); + assert_eq!((p.codec, p.extradata.as_slice(), p.sample_rate, p.channels), (CodecId::Aac, &[0x12, 0x10][..], None, Some(2))); + let v = CodecParams::new(CodecId::ProRes).with_tag(*b"apcn").with_size(1920, 1080); + assert_eq!((v.tag, v.width, v.height), (Some(*b"apcn"), Some(1920), Some(1080))); + assert_eq!(v.with_size(0, 1080).width, None); + } + + #[test] + fn xiph_lacing() { + let laced = xiph_lace(&[vec![1; 30], vec![2; 300], vec![3; 5]]).unwrap(); + assert_eq!(&laced[..4], &[2, 30, 255, 45]); + assert_eq!(laced.len(), 4 + 335); + assert_eq!(xiph_lace(&[]), None); + assert_eq!(xiph_lace(&vec![vec![0]; 257]), None); + assert!(xiph_lace(&vec![vec![0]; 256]).is_some()); + } +} diff --git a/crates/media/src/isobmff/codec.rs b/crates/media/src/isobmff/codec.rs index b98fec6c..2a9f3fa9 100644 --- a/crates/media/src/isobmff/codec.rs +++ b/crates/media/src/isobmff/codec.rs @@ -1,5 +1,6 @@ //! Sample entries (`stsd` children) and codec configuration records: parsing and serialization. +use crate::CodecId; use crate::bitstream::BitReader; use crate::isobmff::bytes::{BoxBuf, Cur, FourCc, boxes}; use crate::isobmff::error::{Error, Result}; @@ -182,7 +183,8 @@ pub enum CodecConfig { Apv(ApvConfig), Aac(AacConfig), /// MPEG-1/2 audio (esds object type 0x69/0x6B, or QuickTime `.mp3` / `.mp2`). Usually layer - /// III, but these name no layer: ffmpeg stores MP2 as `mp4a` + 0x6B. Each frame header has it. + /// III, but these name no layer: ffmpeg stores MP2 as `mp4a` + 0x6B. Each frame header has it, + /// and [`crate::isobmff::Track::codec_params`] reports the layer the first frame names. Mp3, Pcm(PcmConfig), /// ALAC magic cookie (ALACSpecificConfig, 24 bytes, optionally followed by a channel layout). @@ -208,6 +210,51 @@ pub enum CodecConfig { } impl CodecConfig { + /// The codec this configuration is for. `Mp3` covers every MPEG audio layer: these entries + /// name none, and each frame header has it (ffmpeg stores MP2 as `mp4a` + 0x6B); + /// [`crate::isobmff::Track::codec_params`] reads it from the first frame. + pub fn codec_id(&self) -> CodecId { + match self { + CodecConfig::Avc(_) => CodecId::H264, + CodecConfig::Hevc(_) => CodecId::Hevc, + CodecConfig::Av1(_) => CodecId::Av1, + CodecConfig::Vp9(_) => CodecId::Vp9, + CodecConfig::ProRes { .. } => CodecId::ProRes, + CodecConfig::Jpeg { .. } => CodecId::Mjpeg, + CodecConfig::Aac(_) => CodecId::Aac, + CodecConfig::Mp3 => CodecId::Mp3, + CodecConfig::Pcm(_) => CodecId::Pcm, + CodecConfig::Alac { .. } => CodecId::Alac, + CodecConfig::Opus(_) => CodecId::Opus, + CodecConfig::Flac(_) => CodecId::Flac, + CodecConfig::Ac3 { .. } => CodecId::Ac3, + CodecConfig::Eac3 { .. } => CodecId::Eac3, + CodecConfig::Dnx { .. } => CodecId::Dnxhd, + CodecConfig::Apv(_) => CodecId::Apv, + CodecConfig::Timecode(_) | CodecConfig::Unknown { .. } => CodecId::Unknown, + } + } + + /// The codec configuration in Matroska's `CodecPrivate` form (see [`crate::CodecParams`]): + /// the `OpusHead` built from `dOps`, `fLaC` + the `dfLa` metadata blocks, the other records + /// as stored. + pub fn extradata(&self) -> Vec { + match self { + CodecConfig::Avc(c) => c.to_bytes(), + CodecConfig::Hevc(c) => c.to_bytes(), + CodecConfig::Av1(c) => c.to_bytes(), + CodecConfig::Vp9(c) => c.to_bytes(), + CodecConfig::Apv(c) => c.record_bytes(), + CodecConfig::Aac(a) => a.asc.clone(), + CodecConfig::Alac { cookie } => cookie.clone(), + CodecConfig::Opus(o) => o.opus_head(), + CodecConfig::Flac(f) => [b"fLaC".as_slice(), &f.metadata_blocks].concat(), + CodecConfig::Ac3 { dac3 } => dac3.clone(), + CodecConfig::Eac3 { dec3 } => dec3.clone(), + _ => Vec::new(), + } + } + /// A short human-readable codec name. pub fn name(&self) -> &'static str { match self { @@ -633,6 +680,23 @@ pub struct OpusConfig { } impl OpusConfig { + /// The Ogg Opus identification header (RFC 7845 §5.1) this `dOps` box describes: the same + /// fields, little-endian, behind the `OpusHead` magic. + pub fn opus_head(&self) -> Vec { + let mut h = Vec::with_capacity(19 + self.channel_mapping.len()); + h.extend_from_slice(b"OpusHead"); + h.push(1); + h.push(self.output_channels); + h.extend_from_slice(&self.pre_skip.to_le_bytes()); + h.extend_from_slice(&self.input_sample_rate.to_le_bytes()); + h.extend_from_slice(&self.output_gain.to_le_bytes()); + h.push(self.channel_mapping_family); + if self.channel_mapping_family != 0 { + h.extend_from_slice(&self.channel_mapping); + } + h + } + pub fn parse(data: &[u8]) -> Result { let mut c = Cur::labeled(data, "dOps"); let _version = c.u8()?; @@ -808,12 +872,15 @@ impl ApvConfig { /// Serialize the full `apvC` box payload (4-byte `FullBox` header + `APVDecoderConfigurationRecord`). pub fn to_bytes(&self) -> Vec { - let mut b = BoxBuf::new(); - b.u32(0); // FullBox version = 0, flags = 0 + [&[0u8; 4][..], &self.record_bytes()].concat() // FullBox version = 0, flags = 0 + } + + /// Serialize the bare `APVDecoderConfigurationRecord` (Matroska's `CodecPrivate` for `V_APV`). + pub fn record_bytes(&self) -> Vec { if !self.raw_record.is_empty() { - b.bytes(&self.raw_record); - return b.buf; + return self.raw_record.clone(); } + let mut b = BoxBuf::new(); b.u8(1); // configurationVersion = 1 b.u8(1); // number_of_configuration_entry = 1 b.u8(1); // pbu_type = 1 (primary frame) @@ -1840,3 +1907,51 @@ mod tests { assert_eq!(parse_asc(&[]), None); } } + +#[cfg(test)] +mod params_tests { + use super::*; + + /// `dOps` is big-endian; the `OpusHead` it becomes is little-endian, with the mapping table + /// only for families other than 0. + #[test] + fn dops_becomes_an_opus_head() { + let o = OpusConfig { + output_channels: 6, + pre_skip: 312, + input_sample_rate: 48_000, + output_gain: -256, + channel_mapping_family: 1, + channel_mapping: vec![4, 2, 0, 4, 1, 2, 3, 5], + }; + let h = o.opus_head(); + assert_eq!(&h[..8], b"OpusHead"); + assert_eq!(&h[8..19], &[1, 6, 0x38, 0x01, 0x80, 0xBB, 0, 0, 0x00, 0xFF, 1]); + assert_eq!(&h[19..], &o.channel_mapping[..]); + let stereo = OpusConfig { output_channels: 2, channel_mapping_family: 0, channel_mapping: vec![9, 9], ..o }; + assert_eq!(stereo.opus_head().len(), 19); + assert_eq!(CodecConfig::Opus(stereo).codec_id(), CodecId::Opus); + } + + #[test] + fn apv_extradata_is_the_bare_record() { + let cfg = ApvConfig { + profile_idc: 33, + level_idc: 60, + frame_width: 1920, + frame_height: 1080, + chroma_format_idc: 2, + bit_depth_minus8: 2, + ..Default::default() + }; + let record = cfg.record_bytes(); + assert_eq!(record[0], 1, "configurationVersion"); + assert_eq!(cfg.to_bytes(), [&[0u8; 4][..], &record].concat()); + // Matroska's V_APV CodecPrivate carries the record as is; an MP4 track must match it + let parsed = ApvConfig::parse(&cfg.to_bytes()).unwrap(); + assert_eq!(parsed.raw_record, record); + assert_eq!(CodecConfig::Apv(parsed.clone()).extradata(), record); + assert_eq!(parsed.to_bytes(), cfg.to_bytes()); + assert_eq!(CodecConfig::Apv(cfg).extradata(), record); + } +} diff --git a/crates/media/src/isobmff/demux.rs b/crates/media/src/isobmff/demux.rs index 8f7c413d..3c7a1c88 100644 --- a/crates/media/src/isobmff/demux.rs +++ b/crates/media/src/isobmff/demux.rs @@ -3,7 +3,7 @@ use crate::isobmff::bytes::{BoxHeader, Cur, FourCc, boxes, find}; use crate::isobmff::codec::{CodecConfig, SampleEntry, parse_sample_entry}; use crate::isobmff::error::{Error, Result, invalid}; -use crate::isobmff::source::ByteSource; +use crate::source::ByteSource; use std::collections::HashMap; /// Hard cap on the sample count of the whole file, all tracks and fragments (after PCM chunk merging). @@ -140,6 +140,9 @@ pub struct Track { pub pcm_chunked: bool, sync_index: Vec, pts_order: Vec, + /// Per entry: the MPEG audio codec its first sample's frame header names, for the + /// [`CodecConfig::Mp3`] entries, which name no layer themselves. + mpeg_audio: Vec>, } impl Track { @@ -147,6 +150,52 @@ impl Track { pub fn codec(&self) -> Option<&CodecConfig> { self.entries.first().map(|e| &e.codec) } + + /// What a decoder needs for this track, in the container-neutral form the codec crates take: + /// [`Track::codec_params_for`] the first sample's description (the first entry for a track + /// without samples, or when that index names no entry). A track whose samples use more than + /// one description ([`Track::has_mixed_descriptions`]) needs `codec_params_for` per sample. + pub fn codec_params(&self) -> crate::CodecParams { + let first = self.samples.first().map(|s| s.description_index); + self.codec_params_for(first.filter(|&i| i >= 1 && i as usize <= self.entries.len()).unwrap_or(1)) + } + + /// What a decoder needs for the samples whose [`Sample::description_index`] is + /// `description_index` (1-based, into [`Track::entries`]); [`crate::CodecId::Unknown`] when + /// there is no such entry. + pub fn codec_params_for(&self, description_index: u32) -> crate::CodecParams { + let Some(entry) = description_index.checked_sub(1).and_then(|i| self.entries.get(i as usize)) else { + return crate::CodecParams::new(crate::CodecId::Unknown); + }; + let codec = &entry.codec; + let id = self.mpeg_audio.get(description_index as usize - 1).copied().flatten().unwrap_or(codec.codec_id()); + let mut p = crate::CodecParams::new(id).with_extradata(codec.extradata()).with_tag(entry.format.0); + if let Some(v) = &entry.video { + p = p.with_size(u32::from(v.width), u32::from(v.height)); + } + if let Some(a) = &entry.audio { + p = p.with_sample_rate(a.sample_rate.round() as u32).with_channels(u16::try_from(a.channels).unwrap_or(0)); + } + match codec { + CodecConfig::Pcm(c) => { + p = p.with_pcm(if c.float { + crate::PcmFormat::float(c.bits, c.big_endian) + } else { + crate::PcmFormat::int(c.bits, c.signed, c.big_endian) + }); + p = p.with_sample_rate(c.sample_rate.round() as u32).with_channels(u16::try_from(c.channels).unwrap_or(0)); + } + CodecConfig::Opus(o) => p = p.with_sample_rate(48_000).with_channels(u16::from(o.output_channels)), + _ => {} + } + p + } + + /// True when the samples use more than one sample description, so no single + /// [`Track::codec_params`] describes them all. + pub fn has_mixed_descriptions(&self) -> bool { + self.samples.windows(2).any(|w| w[0].description_index != w[1].description_index) + } pub fn video(&self) -> Option<&crate::isobmff::codec::VideoParams> { self.entries.first().and_then(|e| e.video.as_ref()) } @@ -402,10 +451,39 @@ pub fn open(src: S) -> Result { tc.start_frame = Some(u32::from_be_bytes(b)); } } + t.mpeg_audio = mpeg_audio_layers(t, &src); } Ok(file) } +/// For each [`CodecConfig::Mp3`] entry, the layer its first sample's frame header names (these +/// entries name none: ffmpeg stores MP2 as `mp4a` + 0x6B), else what the sample entry says +/// (QuickTime `.mp2`, `.mp3`). +fn mpeg_audio_layers(t: &Track, src: &S) -> Vec> { + use crate::CodecId; + let from_header = |index: u32| { + let s = t.samples.iter().find(|s| s.description_index == index && s.size >= 4)?; + let mut b = [0u8; 4]; + src.read_at(s.offset, &mut b).ok()?; + if b[0] != 0xFF || b[1] & 0xE0 != 0xE0 { + return None; + } + match (b[1] >> 1) & 3 { + 3 => Some(CodecId::Mp1), + 2 => Some(CodecId::Mp2), + 1 => Some(CodecId::Mp3), + _ => None, + } + }; + (1..) + .zip(&t.entries) + .map(|(index, e)| match e.codec { + CodecConfig::Mp3 => from_header(index).or(Some(if e.format == b".mp2" { CodecId::Mp2 } else { CodecId::Mp3 })), + _ => None, + }) + .collect() +} + #[derive(Clone, Copy, Debug, Default)] struct Trex { description_index: u32, @@ -457,6 +535,7 @@ fn parse_trak(p: &[u8], file: &Mp4File, file_len: u64, budget: &mut SampleBudget pcm_chunked: false, sync_index: Vec::new(), pts_order: Vec::new(), + mpeg_audio: Vec::new(), }; let mut mdia = None; for b in boxes(p) { @@ -1082,6 +1161,60 @@ mod tests { assert_eq!(c.next_value(), 0); } + #[test] + fn codec_params_follow_the_sample_description() { + use crate::isobmff::codec::SampleEntry; + use crate::isobmff::mux::{Brand, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; + let mut w = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap(); + let a = w.add_track(TrackConfig::new(SampleEntry::aac(vec![0x12, 0x10], 2, 48_000), 48_000)).unwrap(); + for _ in 0..2 { + w.write_sample(a, WriteSample { data: &[0; 8], duration: 1024, composition_offset: 0, is_sync: true }).unwrap(); + } + let out = w.finish().unwrap().into_inner(); + let mut t = open(out.as_slice()).unwrap().tracks.remove(0); + assert!(!t.has_mixed_descriptions()); + assert_eq!(t.codec_params(), t.codec_params_for(1)); + // entry 1 unused, every sample on entry 2 (a 44.1 kHz mono AudioSpecificConfig) + t.entries.push(SampleEntry::aac(vec![0x12, 0x08], 1, 44_100)); + t.samples.iter_mut().for_each(|s| s.description_index = 2); + let p = t.codec_params(); + assert_eq!((p.extradata.as_slice(), p.sample_rate, p.channels), (&[0x12, 0x08][..], Some(44_100), Some(1))); + assert_eq!(t.codec_params_for(1).extradata, [0x12, 0x10]); + assert_eq!(t.codec_params_for(0).codec, crate::CodecId::Unknown); + assert_eq!(t.codec_params_for(3).codec, crate::CodecId::Unknown); + assert!(!t.has_mixed_descriptions()); + t.samples[1].description_index = 1; + assert!(t.has_mixed_descriptions()); + t.samples[0].description_index = 0; // malformed: falls back to the first entry + assert_eq!(t.codec_params().extradata, [0x12, 0x10]); + } + + #[test] + fn mpeg_audio_layer_comes_from_the_first_frame() { + use crate::CodecId; + use crate::isobmff::codec::SampleEntry; + use crate::isobmff::mux::{Brand, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; + // MPEG-1 frame headers: layer I, II, III; then no frame header at all + let cases: [(&[u8; 4], Brand, [u8; 2], CodecId); 5] = [ + (b"mp4a", Brand::Mp4, [0xFF, 0xFF], CodecId::Mp1), + (b"mp4a", Brand::Mp4, [0xFF, 0xFD], CodecId::Mp2), + (b"mp4a", Brand::Mp4, [0xFF, 0xFB], CodecId::Mp3), + (b"mp4a", Brand::Mp4, [0, 0], CodecId::Mp3), + (b".mp2", Brand::Mov, [0, 0], CodecId::Mp2), + ]; + for (format, brand, header, want) in cases { + let mut w = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(brand)).unwrap(); + let entry = SampleEntry::audio(FourCc(*format), CodecConfig::Mp3, 2, 48_000.0, 16); + let a = w.add_track(TrackConfig::new(entry, 48_000)).unwrap(); + let frame = [&header[..], &[0x94, 0x00, 0, 0, 0, 0]].concat(); + w.write_sample(a, WriteSample { data: &frame, duration: 1152, composition_offset: 0, is_sync: true }).unwrap(); + let out = w.finish().unwrap().into_inner(); + let t = &open(out.as_slice()).unwrap().tracks[0]; + assert_eq!(t.codec(), Some(&CodecConfig::Mp3)); + assert_eq!(t.codec_params().codec, want, "{format:?} {header:02X?}"); + } + } + #[test] fn rejects_no_moov_and_cmov() { let mut b = crate::isobmff::bytes::BoxBuf::new(); diff --git a/crates/media/src/isobmff/mod.rs b/crates/media/src/isobmff/mod.rs index b9627878..a971f144 100644 --- a/crates/media/src/isobmff/mod.rs +++ b/crates/media/src/isobmff/mod.rs @@ -16,8 +16,8 @@ mod codec; mod demux; mod error; mod mux; -mod source; +pub use crate::source::{ByteSource, ReadSeekSource}; pub use bytes::FourCc; pub use codec::{ AacConfig, ApvConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, @@ -26,4 +26,3 @@ pub use codec::{ pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open}; pub use error::{Error, Result}; pub use mux::{Brand, FragmentedWriter, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; -pub use source::ByteSource; diff --git a/crates/media/src/isobmff/source.rs b/crates/media/src/isobmff/source.rs deleted file mode 100644 index 0dad82b2..00000000 --- a/crates/media/src/isobmff/source.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Random-access byte sources for the demuxer. - -use std::io; -use std::sync::Arc; - -/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). -/// -/// Implementations must be able to serve reads at arbitrary offsets through `&self`. -pub trait ByteSource { - /// Total length in bytes. - fn len(&self) -> u64; - /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - /// True if the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -fn eof() -> io::Error { - io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") -} - -impl ByteSource for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let start = usize::try_from(offset).map_err(|_| eof())?; - let end = start.checked_add(buf.len()).ok_or_else(eof)?; - let src = self.get(start..end).ok_or_else(eof)?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ByteSource for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ByteSource for &T { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Arc { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Box { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl ByteSource for std::fs::File { - fn len(&self) -> u64 { - self.metadata().map(|m| m.len()).unwrap_or(0) - } - #[cfg(unix)] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) - } - #[cfg(windows)] - fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { - use std::os::windows::fs::FileExt; - while !buf.is_empty() { - let n = self.seek_read(buf, offset)?; - if n == 0 { - return Err(eof()); - } - offset += n as u64; - buf = &mut buf[n..]; - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - use std::io::{Read, Seek, SeekFrom}; - let mut f = self; - f.seek(SeekFrom::Start(offset))?; - f.read_exact(buf) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_reads() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(1, &mut b).unwrap(); - assert_eq!(b, [2, 3]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let a: Arc<[u8]> = Arc::from(v.clone()); - a.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - assert_eq!(ByteSource::len(&a), 4); - let s: &[u8] = &v; - assert_eq!(ByteSource::len(&s), 4); - } -} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 86cf06b6..5dfe2e7e 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -1,18 +1,26 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -//! Media containers and bit-level I/O for the Craft apps. +//! Media containers and bit-level I/O for the Craft apps. Codecs are in other crates +//! (`craft-audio`; video later), which depend on this one. //! //! - `bitstream` (feature `bitstream`): MSB-first bit reader and writer, Exp-Golomb codes, //! emulation-prevention escaping and NAL unit splitting. //! - `isobmff` (feature `isobmff`, enables `bitstream`): a clean-room ISO BMFF (MP4) and //! QuickTime (MOV) demuxer and muxer, and a box reader for other ISO BMFF formats. //! - `matroska` (feature `matroska`): a clean-room Matroska (MKV) and WebM demuxer and muxer. +//! - [`Container`] and [`CodecId`] (no feature): the identifiers the codec crates report a file's +//! container and codec with. //! //! `bitstream` and `isobmff` are on by default; `matroska` is opt-in. A crate that needs only bit -//! I/O uses `default-features = false, features = ["bitstream"]`. Codecs stay in the apps. +//! I/O uses `default-features = false, features = ["bitstream"]`. #[cfg(feature = "bitstream")] pub mod bitstream; +mod id; #[cfg(feature = "isobmff")] pub mod isobmff; #[cfg(feature = "matroska")] pub mod matroska; +pub mod source; + +pub use id::{CodecId, CodecParams, Container, PcmFormat, xiph_lace}; +pub use source::{ByteSource, ReadSeekSource}; diff --git a/crates/media/src/matroska/demux.rs b/crates/media/src/matroska/demux.rs index 253f613c..1d22705f 100644 --- a/crates/media/src/matroska/demux.rs +++ b/crates/media/src/matroska/demux.rs @@ -14,8 +14,8 @@ use crate::matroska::meta::{ Attachment, ClusterInfo, CuePoint, Edition, SeekEntry, SegmentInfo, Tag, parse_chapters, parse_cues, parse_info, parse_seekhead, parse_tags, parse_tracks, }; -use crate::matroska::source::{ByteSource, ReadSeekSource}; use crate::matroska::track::{Sample, Track, TrackKind, gcd}; +use crate::source::{ByteSource, ReadSeekSource}; const WIN: usize = 64 * 1024; /// Upper bound for level-1 metadata elements read into memory (Info, Tracks, Cues, Tags…). diff --git a/crates/media/src/matroska/mod.rs b/crates/media/src/matroska/mod.rs index 515e99e3..c0d7dae2 100644 --- a/crates/media/src/matroska/mod.rs +++ b/crates/media/src/matroska/mod.rs @@ -17,11 +17,11 @@ mod error; mod ids; mod meta; mod mux; -mod source; #[cfg(test)] mod synthetic_tests; mod track; +pub use crate::source::{ByteSource, ReadSeekSource}; pub use codec::Codec; pub use demux::{Demuxer, Keyframe, MkvFile, OpenOptions, Packet, SeekPoint, open, open_with}; pub use error::{Error, Result}; @@ -29,7 +29,6 @@ pub use meta::{ Attachment, Chapter, ChapterDisplay, ClusterInfo, CuePoint, CuePosition, Edition, SeekEntry, SegmentInfo, SimpleTag, Tag, TagTargets, }; pub use mux::{LacingMode, MkvWriter, MuxOptions, TrackSpec}; -pub use source::{ByteSource, ReadSeekSource}; pub use track::{AudioInfo, Color, ContentEncoding, MasteringMetadata, Projection, Sample, Track, TrackKind, VideoInfo}; /// Element IDs used by the demuxer (marker bits included), for callers inspecting raw elements. diff --git a/crates/media/src/matroska/source.rs b/crates/media/src/matroska/source.rs deleted file mode 100644 index 267c4271..00000000 --- a/crates/media/src/matroska/source.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Random-access byte sources for the demuxer. - -use std::cell::RefCell; -use std::io::{self, Read, Seek, SeekFrom}; -use std::sync::Arc; - -/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). -/// -/// Same shape as `craft_media::isobmff::ByteSource`, so one adapter type can implement both. They -/// are separate traits so that a type can implement both without conflicting impls. -pub trait ByteSource { - /// Total length in bytes. - fn len(&self) -> u64; - /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; - /// True if the source holds no bytes. - fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -fn eof() -> io::Error { - io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") -} - -impl ByteSource for [u8] { - fn len(&self) -> u64 { - <[u8]>::len(self) as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let start = usize::try_from(offset).map_err(|_| eof())?; - let end = start.checked_add(buf.len()).ok_or_else(eof)?; - let src = self.get(start..end).ok_or_else(eof)?; - buf.copy_from_slice(src); - Ok(()) - } -} - -impl ByteSource for Vec { - fn len(&self) -> u64 { - self.as_slice().len() as u64 - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - self.as_slice().read_at(offset, buf) - } -} - -impl ByteSource for &T { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Arc { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -impl ByteSource for Box { - fn len(&self) -> u64 { - (**self).len() - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - (**self).read_at(offset, buf) - } -} - -#[cfg(not(target_arch = "wasm32"))] -impl ByteSource for std::fs::File { - fn len(&self) -> u64 { - self.metadata().map(|m| m.len()).unwrap_or(0) - } - #[cfg(unix)] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) - } - #[cfg(windows)] - fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { - use std::os::windows::fs::FileExt; - while !buf.is_empty() { - let n = self.seek_read(buf, offset)?; - if n == 0 { - return Err(eof()); - } - offset += n as u64; - buf = &mut buf[n..]; - } - Ok(()) - } - #[cfg(not(any(unix, windows)))] - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - let mut f = self; - f.seek(SeekFrom::Start(offset))?; - f.read_exact(buf) - } -} - -/// Adapts any `Read + Seek` (e.g. `BufReader`, `Cursor>`) into a [`ByteSource`]. -/// -/// The length is taken once at construction; the stream position is tracked so sequential reads -/// do not issue redundant seeks. -pub struct ReadSeekSource { - inner: RefCell<(R, u64)>, - len: u64, -} - -impl ReadSeekSource { - /// Wrap a reader; seeks to the end once to learn the length. - pub fn new(mut r: R) -> io::Result { - let len = r.seek(SeekFrom::End(0))?; - Ok(Self { inner: RefCell::new((r, len)), len }) - } - /// Unwrap the reader. - pub fn into_inner(self) -> R { - self.inner.into_inner().0 - } -} - -impl ByteSource for ReadSeekSource { - fn len(&self) -> u64 { - self.len - } - fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { - if offset.checked_add(buf.len() as u64).is_none_or(|e| e > self.len) { - return Err(eof()); - } - let mut g = self.inner.borrow_mut(); - let (r, pos) = &mut *g; - if *pos != offset { - r.seek(SeekFrom::Start(offset))?; - *pos = offset; - } - let res = r.read_exact(buf); - match res { - Ok(()) => { - *pos = offset + buf.len() as u64; - Ok(()) - } - Err(e) => { - *pos = u64::MAX; // unknown; force a seek next time - Err(e) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn slice_and_reader_sources() { - let v = vec![1u8, 2, 3, 4]; - let mut b = [0u8; 2]; - v.read_at(1, &mut b).unwrap(); - assert_eq!(b, [2, 3]); - assert!(v.read_at(3, &mut b).is_err()); - assert!(v.read_at(u64::MAX, &mut b).is_err()); - let r = ReadSeekSource::new(io::Cursor::new(v.clone())).unwrap(); - assert_eq!(r.len(), 4); - r.read_at(2, &mut b).unwrap(); - assert_eq!(b, [3, 4]); - r.read_at(0, &mut b).unwrap(); - assert_eq!(b, [1, 2]); - assert!(r.read_at(3, &mut b).is_err()); - } -} diff --git a/crates/media/src/matroska/track.rs b/crates/media/src/matroska/track.rs index 9a5dfd7a..4f1db134 100644 --- a/crates/media/src/matroska/track.rs +++ b/crates/media/src/matroska/track.rs @@ -234,6 +234,56 @@ pub struct Track { } impl Track { + /// What a decoder needs for this track, in the container-neutral form the codec crates take + /// (`CodecPrivate` is already in that form). + pub fn codec_params(&self) -> crate::CodecParams { + use crate::CodecId as Id; + let (id, extradata) = match &self.codec { + Codec::Avc { avcc } => (Id::H264, avcc.clone()), + Codec::Hevc { hvcc } => (Id::Hevc, hvcc.clone()), + Codec::Vp8 => (Id::Vp8, Vec::new()), + Codec::Vp9 { private } => (Id::Vp9, private.clone()), + Codec::Av1 { av1c } => (Id::Av1, av1c.clone()), + Codec::ProRes { .. } => (Id::ProRes, Vec::new()), + Codec::Apv { apvc } => (Id::Apv, apvc.clone()), + Codec::Mjpeg => (Id::Mjpeg, Vec::new()), + Codec::Aac { asc } => (Id::Aac, asc.clone()), + Codec::Opus { head } => (Id::Opus, head.clone()), + Codec::Vorbis { headers } => (Id::Vorbis, crate::xiph_lace(headers).unwrap_or_default()), + Codec::Flac { private } => (Id::Flac, private.clone()), + Codec::Pcm { .. } => (Id::Pcm, Vec::new()), + Codec::Ac3 => (Id::Ac3, Vec::new()), + Codec::Eac3 => (Id::Eac3, Vec::new()), + Codec::Mp3 => (Id::Mp3, Vec::new()), + Codec::Mp2 => (Id::Mp2, Vec::new()), + Codec::Mp1 => (Id::Mp1, Vec::new()), + // the ALACSpecificConfig, as MP4's `alac` box holds it after its FullBox header + Codec::Other(id) if id == "A_ALAC" => (Id::Alac, self.codec_private.clone()), + Codec::VfwFourcc { fourcc: [b'A', b'V', b'd', b'n' | b'h'], .. } => (Id::Dnxhd, Vec::new()), + _ => (Id::Unknown, Vec::new()), + }; + let mut p = crate::CodecParams::new(id).with_extradata(extradata); + match &self.codec { + Codec::ProRes { fourcc: Some(tag) } | Codec::VfwFourcc { fourcc: tag, .. } => p = p.with_tag(*tag), + _ => {} + } + if let Some(v) = &self.video { + p = p.with_size(v.pixel_width, v.pixel_height); + } + if let Some(a) = &self.audio { + p = p.with_sample_rate(a.sampling_frequency.round() as u32).with_channels(u16::try_from(a.channels).unwrap_or(0)); + } + if let Codec::Opus { .. } = self.codec { + // Opus always decodes at 48 kHz; a muxer may have declared the input rate instead + p = p.with_sample_rate(48_000); + } + if let Codec::Pcm { float, big_endian, bits } = self.codec { + // 8-bit integer PCM is unsigned in Matroska, as in WAV. + p = p.with_pcm(if float { crate::PcmFormat::float(bits, big_endian) } else { crate::PcmFormat::int(bits, bits > 8, big_endian) }); + } + p + } + /// Bytes prepended to every frame by header-stripping compression, if any. pub fn stripped_header(&self) -> Option<&[u8]> { self.encodings.iter().find_map(|e| match e { @@ -381,3 +431,36 @@ mod aspect_overflow_tests { assert_eq!(video.pixel_aspect(), (3, 4)); } } + +#[cfg(test)] +mod codec_params_tests { + use crate::CodecId; + use crate::matroska::mux::{MkvWriter, MuxOptions, TrackSpec}; + use crate::matroska::{Demuxer, TrackKind}; + + #[test] + fn alac_dnx_and_opus_match_mp4() { + let cookie: Vec = (0..24).collect(); + let mut alac = TrackSpec::new(TrackKind::Audio, "A_ALAC"); + alac.codec_private = cookie.clone(); + alac.audio = Some((44_100.0, 2, Some(16))); + let mut bih = vec![0u8; 40]; + bih[16..20].copy_from_slice(b"AVdn"); + let mut dnx = TrackSpec::new(TrackKind::Video, "V_MS/VFW/FOURCC"); + dnx.codec_private = bih; + dnx.video_size = Some((1920, 1080)); + let mut opus = TrackSpec::new(TrackKind::Audio, "A_OPUS"); + opus.codec_private = [b"OpusHead".as_slice(), &[1, 2, 0x38, 0x01, 0x44, 0xAC, 0, 0, 0, 0, 0]].concat(); + opus.audio = Some((44_100.0, 2, None)); // the input rate, as some muxers write it + let mut m = MkvWriter::new(std::io::Cursor::new(Vec::new()), vec![alac, dnx, opus], MuxOptions::default()).unwrap(); + for t in 0..3 { + m.write_frame(t, 0, true, &[0; 4], None).unwrap(); + } + let bytes = m.finish().unwrap().into_inner(); + let d = Demuxer::from_slice(&bytes).unwrap(); + let p: Vec<_> = d.tracks().iter().map(|t| t.codec_params()).collect(); + assert_eq!((p[0].codec, p[0].extradata.as_slice()), (CodecId::Alac, cookie.as_slice())); + assert_eq!((p[1].codec, p[1].tag), (CodecId::Dnxhd, Some(*b"AVdn"))); + assert_eq!((p[2].codec, p[2].sample_rate, p[2].channels), (CodecId::Opus, Some(48_000), Some(2))); + } +} diff --git a/crates/media/src/source.rs b/crates/media/src/source.rs new file mode 100644 index 00000000..44fde153 --- /dev/null +++ b/crates/media/src/source.rs @@ -0,0 +1,269 @@ +//! Random-access byte sources: what the demuxers read from. One trait for every container, so a +//! file, buffer or custom source implements it once. + +#![warn(missing_docs)] + +use std::io::{self, Read, Seek, SeekFrom}; +use std::sync::{Arc, Mutex}; + +/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). +/// +/// Implementations serve reads at arbitrary offsets through `&self`. `isobmff::ByteSource` and +/// `matroska::ByteSource` are this trait. +pub trait ByteSource { + /// Total length in bytes. + fn len(&self) -> u64; + /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; + /// True if the source holds no bytes. + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +fn eof() -> io::Error { + io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") +} + +impl ByteSource for [u8] { + fn len(&self) -> u64 { + <[u8]>::len(self) as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + let start = usize::try_from(offset).map_err(|_| eof())?; + let end = start.checked_add(buf.len()).ok_or_else(eof)?; + let src = self.get(start..end).ok_or_else(eof)?; + buf.copy_from_slice(src); + Ok(()) + } +} + +impl ByteSource for Vec { + fn len(&self) -> u64 { + self.as_slice().len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.as_slice().read_at(offset, buf) + } +} + +impl ByteSource for &T { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} + +impl ByteSource for Arc { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} + +impl ByteSource for Box { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} + +/// Past the largest offset a file can have (`i64::MAX`), where the OS would fail with +/// `InvalidInput` instead of reporting the end of the file. +#[cfg(not(target_arch = "wasm32"))] +fn past_file_offsets(offset: u64, len: usize) -> bool { + offset.checked_add(len as u64).is_none_or(|e| e > i64::MAX as u64) +} + +/// A file read in place. `len` asks the file system each time, and a failure there reads as an +/// empty file; [`ReadSeekSource`] takes the length once and reports a failure when it is made. +#[cfg(not(target_arch = "wasm32"))] +impl ByteSource for std::fs::File { + fn len(&self) -> u64 { + self.metadata().map(|m| m.len()).unwrap_or(0) + } + #[cfg(unix)] + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + if past_file_offsets(offset, buf.len()) { + return Err(eof()); + } + std::os::unix::fs::FileExt::read_exact_at(self, buf, offset) + } + #[cfg(windows)] + fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> { + use std::os::windows::fs::FileExt; + if past_file_offsets(offset, buf.len()) { + return Err(eof()); + } + while !buf.is_empty() { + let n = self.seek_read(buf, offset)?; + if n == 0 { + return Err(eof()); + } + offset += n as u64; + buf = &mut buf[n..]; + } + Ok(()) + } + #[cfg(not(any(unix, windows)))] + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + if past_file_offsets(offset, buf.len()) { + return Err(eof()); + } + let mut f = self; + f.seek(SeekFrom::Start(offset))?; + f.read_exact(buf) + } +} + +/// Adapts any `Read + Seek` (e.g. `BufReader`, `Cursor>`) into a [`ByteSource`]. +/// +/// The length is taken once at construction; the stream position is tracked so sequential reads +/// do not issue redundant seeks. The reader sits behind a mutex, so the source is `Send + Sync` +/// whenever `R` is `Send` and can be shared across threads. +pub struct ReadSeekSource { + inner: Mutex<(R, u64)>, + len: u64, +} + +impl ReadSeekSource { + /// Wrap a reader; seeks to the end once to learn the length. + pub fn new(mut r: R) -> io::Result { + let len = r.seek(SeekFrom::End(0))?; + Ok(Self { inner: Mutex::new((r, len)), len }) + } + /// Unwrap the reader. + pub fn into_inner(self) -> R { + // A poisoned lock only means a read panicked in another thread; the reader is still usable. + self.inner.into_inner().unwrap_or_else(|e| e.into_inner()).0 + } +} + +impl ByteSource for ReadSeekSource { + fn len(&self) -> u64 { + self.len + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + if offset.checked_add(buf.len() as u64).is_none_or(|e| e > self.len) { + return Err(eof()); + } + let mut g = self.inner.lock().unwrap_or_else(|e| { + // a read panicked in another thread and may have moved the reader; force a seek + let mut g = e.into_inner(); + g.1 = u64::MAX; + self.inner.clear_poison(); + g + }); + let (r, pos) = &mut *g; + if *pos != offset { + *pos = u64::MAX; // unknown until the seek succeeds + r.seek(SeekFrom::Start(offset))?; + *pos = offset; + } + let res = r.read_exact(buf); + match res { + Ok(()) => { + *pos = offset + buf.len() as u64; + Ok(()) + } + Err(e) => { + *pos = u64::MAX; // unknown; force a seek next time + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slice_and_reader_sources() { + let v = vec![1u8, 2, 3, 4]; + let mut b = [0u8; 2]; + v.read_at(1, &mut b).unwrap(); + assert_eq!(b, [2, 3]); + assert!(v.read_at(3, &mut b).is_err()); + assert!(v.read_at(u64::MAX, &mut b).is_err()); + let r = ReadSeekSource::new(io::Cursor::new(v.clone())).unwrap(); + assert_eq!(r.len(), 4); + r.read_at(2, &mut b).unwrap(); + assert_eq!(b, [3, 4]); + r.read_at(0, &mut b).unwrap(); + assert_eq!(b, [1, 2]); + assert!(r.read_at(3, &mut b).is_err()); + let a: Arc<[u8]> = Arc::from(v.clone()); + a.read_at(2, &mut b).unwrap(); + assert_eq!(b, [3, 4]); + assert_eq!(ByteSource::len(&a), 4); + let s: &[u8] = &v; + assert_eq!(ByteSource::len(&s), 4); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn file_sources_read_like_a_buffer() { + let v: Vec = (0..=255).collect(); + let path = std::env::temp_dir().join(format!("craft-media-source-{}.bin", std::process::id())); + std::fs::write(&path, &v).unwrap(); + let f = std::fs::File::open(&path).unwrap(); + let r = ReadSeekSource::new(std::fs::File::open(&path).unwrap()).unwrap(); + let sources: [&dyn ByteSource; 3] = [&v, &f, &r]; + for s in sources { + assert_eq!(s.len(), 256); + // out of order, so ReadSeekSource has to seek back + for (offset, n) in [(0, 0), (10, 16), (0, 256), (200, 56), (255, 1), (3, 7)] { + let mut b = vec![0u8; n]; + s.read_at(offset as u64, &mut b).unwrap(); + assert_eq!(b, v[offset..offset + n], "offset {offset}, {n} bytes"); + } + for (offset, n) in [(200, 57), (256, 1), (300, 1), (u64::MAX, 1), (u64::MAX - 1, 4)] { + let err = s.read_at(offset, &mut vec![0u8; n]).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof, "offset {offset}, {n} bytes"); + } + } + drop((f, r)); // Windows deletes no open file + std::fs::remove_file(&path).ok(); + } + + #[test] + fn read_seek_source_seeks_after_a_panicked_read() { + struct PanicOnce(io::Cursor>, bool); + impl Read for PanicOnce { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let n = self.0.read(&mut buf[..1])?; + if std::mem::take(&mut self.1) { + panic!("read failed halfway"); + } + Ok(n) + } + } + impl Seek for PanicOnce { + fn seek(&mut self, to: SeekFrom) -> io::Result { + self.0.seek(to) + } + } + let r = ReadSeekSource::new(PanicOnce(io::Cursor::new(vec![1, 2, 3, 4]), false)).unwrap(); + let mut b = [0u8; 2]; + r.read_at(0, &mut b).unwrap(); + r.inner.lock().unwrap().0.1 = true; + // the reader moves one byte, then panics with the tracked position at 2 + assert!(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| r.read_at(2, &mut b))).is_err()); + r.read_at(2, &mut b).unwrap(); + assert_eq!(b, [3, 4]); + assert!(!r.inner.is_poisoned(), "recovered once, not on every read"); + } + + #[test] + fn read_seek_source_is_send_and_sync() { + fn shareable(_: &T) {} + shareable(&ReadSeekSource::new(io::Cursor::new(vec![0u8; 4])).unwrap()); + } +} diff --git a/crates/media/tests/common/mod.rs b/crates/media/tests/common/mod.rs index e0a2a3c7..d4aa5e9b 100644 --- a/crates/media/tests/common/mod.rs +++ b/crates/media/tests/common/mod.rs @@ -1,167 +1,23 @@ -//! Shared helpers for oracle tests: ffmpeg/ffprobe discovery and fixture generation. -//! ffmpeg is used only as an external fixture generator and oracle (never linked). -//! -//! Tool search order: `CRAFT_MEDIA_FFMPEG` / `CRAFT_MEDIA_FFPROBE` (an explicit path; a wrong -//! path fails the test), then `PATH`, then well-known install directories. A missing tool, a -//! failed fixture generator or a failed ffprobe run prints `SKIPPED` and the test passes, unless -//! `CRAFT_MEDIA_REQUIRE_ORACLES=1` is set: then each of these fails the test. -//! Fixtures are generated into `$CRAFT_MEDIA_FIXTURES_DIR` or `/target/fixtures`, one -//! directory per container (`isobmff`, `matroska`), and never committed. +//! craft-media's oracle-test helpers: the generic ones from `craft-media-testkit`, and the MP4 +//! fixtures and ffprobe queries the container tests share. #![allow(dead_code)] +pub use craft_media_testkit::*; use serde_json::Value; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::OnceLock; -use std::sync::atomic::{AtomicUsize, Ordering}; - -const REQUIRE_ENV: &str = "CRAFT_MEDIA_REQUIRE_ORACLES"; - -const KNOWN_DIRS: &[&str] = &[ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/opt/local/bin", - "/snap/bin", - r"C:\ffmpeg\bin", - r"C:\Program Files\ffmpeg\bin", - r"C:\ProgramData\chocolatey\bin", -]; - -fn env_var(name: &str) -> String { - format!("CRAFT_MEDIA_{}", name.to_ascii_uppercase()) -} - -fn find_tool(name: &str) -> Option { - if let Some(e) = std::env::var_os(env_var(name)).filter(|e| !e.is_empty()) { - let p = PathBuf::from(e); - assert!(p.is_file(), "{} is set but `{}` is not a file", env_var(name), p.display()); - return Some(p); - } - let names = if cfg!(windows) { [format!("{name}.exe"), name.to_string()] } else { [name.to_string(), format!("{name}.exe")] }; - let path_dirs = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::>()).unwrap_or_default(); - let dirs = path_dirs.into_iter().chain(KNOWN_DIRS.iter().map(PathBuf::from)); - for dir in dirs { - for n in &names { - let p = dir.join(n); - if p.is_file() { - return Some(p); - } - } - } - None -} - -/// Find a tool silently (cached for the process for ffmpeg and ffprobe). -pub fn tool(name: &str) -> Option { - static FFMPEG: OnceLock> = OnceLock::new(); - static FFPROBE: OnceLock> = OnceLock::new(); - match name { - "ffmpeg" => FFMPEG.get_or_init(|| find_tool("ffmpeg")).clone(), - "ffprobe" => FFPROBE.get_or_init(|| find_tool("ffprobe")).clone(), - _ => find_tool(name), - } -} - -pub fn oracles_required() -> bool { - std::env::var(REQUIRE_ENV).is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) -} - -/// Give up on an oracle check: print `SKIPPED` and return `None`, or fail when oracles are -/// required. -pub fn skip(reason: &str) -> Option { - skip_with(oracles_required(), reason) -} - -fn skip_with(required: bool, reason: &str) -> Option { - assert!(!required, "craft-media oracle: {reason}, and {REQUIRE_ENV}=1 forbids skipping oracle tests"); - eprintln!("SKIPPED (craft-media oracle): {reason}"); - None -} - -/// Run a fixture generator writing `tmp`, then rename it to `out`. A generator that cannot start -/// or fails skips (see [`skip`]); `required` is explicit so tests can set it without touching -/// the process environment. -pub fn run_generator(cmd: &mut Command, tmp: &Path, out: &Path, required: bool) -> Option { - let reason = match cmd.output() { - Ok(o) if o.status.success() => match std::fs::rename(tmp, out) { - Ok(()) => return Some(out.to_path_buf()), - Err(e) => format!("could not move {} into place: {e}", tmp.display()), - }, - Ok(o) => format!("generator failed for {} ({}): {}", out.display(), o.status, String::from_utf8_lossy(&o.stderr).trim()), - Err(e) => format!("could not start generator for {}: {e}", out.display()), - }; - let _ = std::fs::remove_file(tmp); - skip_with(required, &format!("{reason} (encoder unavailable?)")) -} - -fn tool_or_skip(name: &str) -> Option { - tool(name).or_else(|| skip(&format!("{name} not found (set {} or put {name} on PATH)", env_var(name)))) -} - -/// ffmpeg, or `None` after printing `SKIPPED` (a failure with `CRAFT_MEDIA_REQUIRE_ORACLES=1`). -pub fn ffmpeg() -> Option { - tool_or_skip("ffmpeg") -} - -/// ffprobe, or `None` after printing `SKIPPED` (a failure with `CRAFT_MEDIA_REQUIRE_ORACLES=1`). -pub fn ffprobe() -> Option { - tool_or_skip("ffprobe") -} /// Fixtures of the `isobmff` tests. pub fn fixture_dir() -> PathBuf { fixture_subdir("isobmff") } -/// `$CRAFT_MEDIA_FIXTURES_DIR/` or `/target/fixtures/`, created if missing. -pub fn fixture_subdir(name: &str) -> PathBuf { - let d = match std::env::var_os("CRAFT_MEDIA_FIXTURES_DIR").filter(|v| !v.is_empty()) { - Some(d) => PathBuf::from(d), - None => { - let here = Path::new(env!("CARGO_MANIFEST_DIR")); - let root = here.parent().and_then(Path::parent).unwrap_or(here); - root.join("target").join("fixtures") - } - } - .join(name); - std::fs::create_dir_all(&d).unwrap(); - d -} - pub fn out_dir() -> PathBuf { let d = fixture_dir().join("out"); std::fs::create_dir_all(&d).unwrap(); d } -/// A temporary sibling of `out` that no other thread or process will pick; the extension is kept -/// because ffmpeg chooses the muxer from it. -pub fn temp_path(out: &Path) -> PathBuf { - static N: AtomicUsize = AtomicUsize::new(0); - let tid = format!("{:?}", std::thread::current().id()).replace(|c: char| !c.is_ascii_alphanumeric(), ""); - let n = N.fetch_add(1, Ordering::Relaxed); - let stem = out.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); - let name = match out.extension() { - Some(ext) => format!("{stem}.tmp-{}-{tid}-{n}.{}", std::process::id(), ext.to_string_lossy()), - None => format!("{stem}.tmp-{}-{tid}-{n}", std::process::id()), - }; - out.with_file_name(name) -} - -/// Run a fixture generator and print `FIXTURE `. -pub fn generate_and_report(name: &str, outputs: &[PathBuf], generate: impl FnOnce() -> Option) -> Option { - let existed = !outputs.is_empty() && outputs.iter().all(|p| std::fs::metadata(p).is_ok_and(|m| m.len() > 0)); - let r = generate(); - let status = match (&r, existed) { - (None, _) => "skipped", - (Some(_), true) => "cached", - (Some(_), false) => "made", - }; - println!("FIXTURE {status} {name}"); - r -} - const V: &[&str] = &["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25"]; const A: &[&str] = &["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000"];