Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/media.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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-prepress = { path = "crates/prepress" }
craft-print = { path = "crates/print" }
Expand Down
12 changes: 12 additions & 0 deletions crates/media-testkit/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
159 changes: 159 additions & 0 deletions crates/media-testkit/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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 `<workspace>/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<PathBuf> {
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::<Vec<_>>()).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<PathBuf> {
static FFMPEG: OnceLock<Option<PathBuf>> = OnceLock::new();
static FFPROBE: OnceLock<Option<PathBuf>> = 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<T>(reason: &str) -> Option<T> {
skip_with(oracles_required(), reason)
}

#[allow(clippy::panic)]
fn skip_with<T>(required: bool, reason: &str) -> Option<T> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
tool_or_skip("ffmpeg")
}

/// ffprobe, or `None` after printing `SKIPPED` (a failure with `CRAFT_MEDIA_REQUIRE_ORACLES=1`).
pub fn ffprobe() -> Option<PathBuf> {
tool_or_skip("ffprobe")
}

/// `$CRAFT_MEDIA_FIXTURES_DIR/<name>` or `<workspace>/target/fixtures/<name>`, 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 <made|cached|skipped> <name>`.
pub fn generate_and_report<T>(name: &str, outputs: &[PathBuf], generate: impl FnOnce() -> Option<T>) -> Option<T> {
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
}
7 changes: 7 additions & 0 deletions crates/media/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ license.workspace = true
rust-version.workspace = true
repository.workspace = true

[package.metadata.docs.rs]
all-features = true

[lints]
workspace = true

Expand All @@ -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 }

Expand Down
75 changes: 61 additions & 14 deletions crates/media/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 = "<commit>", features = ["matroska"] }
Expand All @@ -30,9 +36,12 @@ craft-media = { git = "https://github.com/storytold/craft-libs", rev = "<commit>
## 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

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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)` |
Expand Down Expand Up @@ -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<u8>`, `&T`, `Arc<T>`, `Box<T>` and `std::fs::File` (not on wasm). `ReadSeekSource` adapts any
`Read + Seek`; it is `Send + Sync` when the reader is `Send`. Implement the trait once for a
custom source (a web `Blob`, a network range reader) to use every container with it.

## Tests

- Unit tests cover the bit reader and writer, the box reader (64-bit, size-0, `uuid`, truncated and
Expand All @@ -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`.
Loading
Loading