From 006b50d2ae65f8525e933e82fad1a1fe4e89391f Mon Sep 17 00:00:00 2001 From: Aleksander Helgaker Date: Mon, 2 Nov 2026 14:18:36 +0100 Subject: [PATCH] craft-media: shared codec identifiers, one ByteSource, the testkit (1/10) (#439) * craft-media: shared codec identifiers, one ByteSource, the testkit The types a codec crate needs to describe a file and configure a decoder, ahead of craft-audio: - `Container`, `CodecId`, `PcmFormat` and `CodecParams` (`id.rs`), always built, with optional serde. - `codec_params()` on `isobmff::Track` and `matroska::Track`, both returning `CodecParams` in the same form; `extradata` follows Matroska's `CodecPrivate`. - One `ByteSource` trait (`source.rs`), with `ReadSeekSource`; `isobmff::ByteSource` and `matroska::ByteSource` are that trait. - craft-media-testkit (`publish = false`): the ffmpeg oracle helpers from craft-media's tests, so the codec crates share one copy. The existing tests prove the refactor; the new types have their own. Co-Authored-By: Claude Opus 5.5 * craft-media: APV extradata is the bare record; reseek after a poisoned lock An MP4 apvC track gave CodecParams.extradata with the 4-byte FullBox header in front, where Matroska's V_APV CodecPrivate is the bare APVDecoderConfigurationRecord. ApvConfig::record_bytes serializes the record alone and extradata uses it. ReadSeekSource kept its tracked position after recovering a poisoned lock or a failed seek, so a reader moved by a panic was read from the wrong offset with no error. Both now force a seek on the next read. Co-Authored-By: Claude Opus 5.5 * craft-media: MP4 codec params per sample description Track::codec_params always described the first stsd entry, while each sample selects its own through description_index. It now describes the first sample's entry; codec_params_for gives the parameters of any entry, and has_mixed_descriptions says whether one set covers every sample. Co-Authored-By: Claude Opus 5.5 * craft-media: test file-backed ByteSources; a File read past i64::MAX is an EOF A File and a ReadSeekSource over a file now have the buffer's test: the same bytes at the same offsets, and UnexpectedEof past the end. The test found that a File read at an offset past i64::MAX failed with the OS's InvalidInput instead of the UnexpectedEof the trait promises; it now reports the end of the source like the other implementations. Co-Authored-By: Claude Opus 5.5 * craft-media: Matroska codec params match MP4 for ALAC, DNx and Opus Matroska's codec_params gave Unknown for A_ALAC and for DNxHD/DNxHR stored as V_MS/VFW/FOURCC (AVdn, AVdh), where MP4 gives Alac and Dnxhd; ALAC's extradata is the CodecPrivate, the ALACSpecificConfig MP4's alac box holds. Opus now reports 48 kHz from Matroska as from MP4, whatever rate the muxer declared. ReadSeekSource clears the poison when it recovers the lock, so one panicked read costs one recovery instead of a seek on every later read. Co-Authored-By: Claude Opus 5.5 * craft-media: MP4 reports the MPEG audio layer the first frame names An MP4 track with MPEG audio reported CodecId::Mp3 whatever the layer, since mp4a + 0x69/0x6B names none (ffmpeg stores MP2 that way), while Matroska reports Mp1/Mp2/Mp3 from its CodecID. open() now reads the frame header of each such entry's first sample and codec_params() reports the layer it names; without a valid header, QuickTime's .mp2 gives Mp2 and anything else Mp3 as before. CodecConfig::Mp3 is unchanged. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/media.yml | 6 +- Cargo.lock | 6 + Cargo.toml | 1 + crates/media-testkit/Cargo.toml | 12 + crates/media-testkit/src/lib.rs | 159 ++++++++++++ crates/media/Cargo.toml | 7 + crates/media/README.md | 75 ++++-- crates/media/src/id.rs | 376 ++++++++++++++++++++++++++++ crates/media/src/isobmff/codec.rs | 125 ++++++++- crates/media/src/isobmff/demux.rs | 135 +++++++++- crates/media/src/isobmff/mod.rs | 3 +- crates/media/src/isobmff/source.rs | 123 --------- crates/media/src/lib.rs | 12 +- crates/media/src/matroska/demux.rs | 2 +- crates/media/src/matroska/mod.rs | 3 +- crates/media/src/matroska/source.rs | 174 ------------- crates/media/src/matroska/track.rs | 83 ++++++ crates/media/src/source.rs | 269 ++++++++++++++++++++ crates/media/tests/common/mod.rs | 150 +---------- 19 files changed, 1248 insertions(+), 473 deletions(-) create mode 100644 crates/media-testkit/Cargo.toml create mode 100644 crates/media-testkit/src/lib.rs create mode 100644 crates/media/src/id.rs delete mode 100644 crates/media/src/isobmff/source.rs delete mode 100644 crates/media/src/matroska/source.rs create mode 100644 crates/media/src/source.rs 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"];