T1: shared ffmpeg/ffprobe discovery in filmcraft-testkit

New dev-dependency-only crate crates/testkit (L0, no FilmCraft deps):
FILMCRAFT_FFMPEG/FILMCRAFT_FFPROBE, then PATH (incl. .exe), then known
dirs; SKIPPED lines and FILMCRAFT_REQUIRE_ORACLES=1 to make skips fail;
require_ffmpeg!/require_ffprobe!/require_oracles!; fixtures_dir() under
<workspace>/target/fixtures with per-thread temp names and atomic
generation. Oracle helpers in aac, opus, h264, hevc, h264enc, isobmff,
matroska, prores and export migrated (codecs left on its local lookup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 00:10:59 -04:00
parent 1c9073de2b
commit 8780d5ffa2
29 changed files with 462 additions and 163 deletions

18
Cargo.lock generated
View File

@ -1304,6 +1304,7 @@ name = "filmcraft-aac"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@ -1423,6 +1424,7 @@ dependencies = [
"filmcraft-project",
"filmcraft-prores",
"filmcraft-render",
"filmcraft-testkit",
"filmcraft-time",
"image",
"rayon",
@ -1469,6 +1471,7 @@ name = "filmcraft-h264"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@ -1478,6 +1481,7 @@ name = "filmcraft-h264enc"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@ -1487,6 +1491,7 @@ name = "filmcraft-hevc"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@ -1512,6 +1517,7 @@ name = "filmcraft-isobmff"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"proptest",
"serde_json",
"thiserror 2.0.21",
@ -1521,6 +1527,7 @@ dependencies = [
name = "filmcraft-matroska"
version = "0.1.0"
dependencies = [
"filmcraft-testkit",
"serde_json",
]
@ -1540,6 +1547,9 @@ dependencies = [
[[package]]
name = "filmcraft-opus"
version = "0.1.0"
dependencies = [
"filmcraft-testkit",
]
[[package]]
name = "filmcraft-project"
@ -1560,6 +1570,7 @@ name = "filmcraft-prores"
version = "0.1.0"
dependencies = [
"filmcraft-bitstream",
"filmcraft-testkit",
"rayon",
"thiserror 2.0.21",
]
@ -1579,6 +1590,13 @@ dependencies = [
"rayon",
]
[[package]]
name = "filmcraft-testkit"
version = "0.1.0"
dependencies = [
"png",
]
[[package]]
name = "filmcraft-time"
version = "0.1.0"

View File

@ -37,6 +37,7 @@ filmcraft-export = { path = "crates/export" }
filmcraft-engine = { path = "crates/engine" }
filmcraft-ui-egui = { path = "crates/ui-egui" }
filmcraft-automation = { path = "crates/automation" }
filmcraft-testkit = { path = "crates/testkit" }
# foundation
thiserror = "2"

View File

@ -17,3 +17,6 @@ rayon = ["dep:rayon"]
filmcraft-bitstream = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }

View File

@ -5,19 +5,13 @@ use std::process::Command;
use filmcraft_aac::{Decoder, Encoder, EncoderConfig, EncoderStats, split_adts};
/// ffmpeg (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`.
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(PathBuf::from(p));
}
}
None
filmcraft_testkit::ffmpeg_or_skip("aac oracle")
}
pub fn fixtures() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/aac");
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir("aac")
}
pub struct Rng(pub u64);

View File

@ -26,4 +26,5 @@ rayon = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif"] }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
filmcraft-codecs = { workspace = true }

View File

@ -65,7 +65,7 @@ fn mjpeg_mov_roundtrip() {
let a = src.audio(0, 24_000, 48_000).unwrap();
let pk = a.peaks()[0];
assert!((pk - 0.5).abs() < 0.05, "tone at -6 dB: {pk}");
if let Some(ffprobe) = ["/opt/homebrew/bin/ffprobe", "/usr/bin/ffprobe"].into_iter().find(|p| std::path::Path::new(p).exists()) {
if let Some(ffprobe) = filmcraft_testkit::ffprobe_or_skip("export mjpeg frame count") {
let out = std::process::Command::new(ffprobe)
.args(["-v", "error", "-count_frames", "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", &path])
.output()

View File

@ -19,3 +19,4 @@ thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }

View File

@ -437,17 +437,12 @@ type Planes3 = (Vec<u8>, Vec<u8>, Vec<u8>);
/// Validate the hand-built stream and its expected output with ffmpeg when available.
fn cross_check_ffmpeg(stream: &[u8], expected: &[Planes3], name: &str) {
let ff = std::path::Path::new("/opt/homebrew/bin/ffmpeg");
if !ff.exists() {
eprintln!("ffmpeg not found; skipping oracle check of {name}");
return;
}
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/h264");
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join(format!("{name}.{}.h264", std::process::id()));
let dst = dir.join(format!("{name}.{}.yuv", std::process::id()));
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip(name) else { return };
let dir = filmcraft_testkit::fixtures_dir("h264");
let src = filmcraft_testkit::temp_path(&dir.join(format!("{name}.h264")));
let dst = filmcraft_testkit::temp_path(&dir.join(format!("{name}.yuv")));
std::fs::write(&src, stream).unwrap();
let ok = std::process::Command::new(ff)
let ok = std::process::Command::new(&ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
.arg(&src)
.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p"])

View File

@ -307,19 +307,13 @@ pub fn fixture(name: &str) -> &'static Fixture {
FIXTURES.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("unknown fixture {name}"))
}
/// ffmpeg (see `filmcraft_testkit::oracle`).
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(PathBuf::from(p));
}
}
None
filmcraft_testkit::ffmpeg()
}
pub fn fixtures_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/h264");
std::fs::create_dir_all(&d).expect("create fixtures dir");
d
filmcraft_testkit::fixtures_dir("h264")
}
fn run(cmd: &mut Command) -> bool {
@ -339,15 +333,12 @@ fn run(cmd: &mut Command) -> bool {
/// Generate (if needed) the fixture stream and its ffmpeg reference decode.
/// Returns None (with a message) when ffmpeg is unavailable.
pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
let Some(ff) = ffmpeg() else {
eprintln!("SKIP {}: ffmpeg not found", f.name);
return None;
};
let ff = filmcraft_testkit::ffmpeg_or_skip(f.name)?;
let dir = fixtures_dir();
let h264 = dir.join(format!("{}.h264", f.name));
let yuv = dir.join(format!("{}.yuv", f.name));
if !h264.exists() {
let tmp = dir.join(format!("{}.tmp.{}.h264", f.name, std::process::id()));
let tmp = filmcraft_testkit::temp_path(&h264);
let mut vf = format!("{}=size={}x{}:rate=25,format=yuv420p", f.source, f.width, f.height);
if !f.filter.is_empty() {
vf.push(',');
@ -364,13 +355,13 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
if !run(&mut c) {
// Platform encoders (VideoToolbox) are optional; libx264 fixtures must generate.
assert!(f.args.contains(&"-c:v"), "fixture generation failed for {}", f.name);
eprintln!("SKIP {}: encoder unavailable", f.name);
eprintln!("SKIPPED ({}): encoder unavailable", f.name);
return None;
}
std::fs::rename(&tmp, &h264).unwrap();
}
if !yuv.exists() {
let tmp = dir.join(format!("{}.tmp.{}.yuv", f.name, std::process::id()));
let tmp = filmcraft_testkit::temp_path(&yuv);
let mut c = Command::new(&ff);
c.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]).arg(&h264);
c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", "yuv420p"]).arg(&tmp);

View File

@ -17,3 +17,6 @@ threads = ["dep:rayon"]
filmcraft-bitstream = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }

View File

@ -95,29 +95,19 @@ pub fn synth(w: usize, h: usize, t: usize) -> Yuv {
Yuv { w, h, y, u, v }
}
/// ffmpeg (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`.
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(p.into());
}
}
None
filmcraft_testkit::ffmpeg_or_skip("h264enc oracle")
}
/// ffprobe (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`.
pub fn ffprobe() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffprobe", "/usr/local/bin/ffprobe", "/usr/bin/ffprobe"] {
if Path::new(p).exists() {
return Some(p.into());
}
}
None
filmcraft_testkit::ffprobe_or_skip("h264enc oracle")
}
/// Per-test output directory under `<workspace>/target/fixtures/h264enc/`.
pub fn out_dir(name: &str) -> PathBuf {
let base = std::env::var("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target"));
let d = base.join("fixtures").join("h264enc").join(name);
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir(&format!("h264enc/{name}"))
}
/// Decode an Annex-B file with ffmpeg; returns (stderr, raw yuv420p bytes).

View File

@ -19,3 +19,4 @@ thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }

View File

@ -546,14 +546,10 @@ fn as_u8(p: &crate::Plane) -> &[u8] {
/// Compare with ffmpeg's decode of the same stream (skipped when ffmpeg is absent).
fn check_ffmpeg(name: &str, stream: &[u8], pics: &[crate::Picture]) {
let Some(ff) = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].iter().find(|p| std::path::Path::new(p).exists()) else {
eprintln!("SKIP ffmpeg check for {name}");
return;
};
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/hevc");
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join(format!("synth_{name}.hevc"));
let yuv = dir.join(format!("synth_{name}.yuv"));
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip(name) else { return };
let dir = filmcraft_testkit::fixtures_dir("hevc");
let src = filmcraft_testkit::temp_path(&dir.join(format!("synth_{name}.hevc")));
let yuv = filmcraft_testkit::temp_path(&dir.join(format!("synth_{name}.yuv")));
std::fs::write(&src, stream).unwrap();
let st = std::process::Command::new(ff)
.args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
@ -577,6 +573,7 @@ fn check_ffmpeg(name: &str, stream: &[u8], pics: &[crate::Picture]) {
}
}
let _ = std::fs::remove_file(&yuv);
let _ = std::fs::remove_file(&src);
}
fn run(name: &str, tiles: Tiles, pcm_lf_disabled: bool, segments: Vec<Segment>) {

View File

@ -85,19 +85,13 @@ pub fn fixture(name: &str) -> &'static Fixture {
FIXTURES.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("unknown fixture {name}"))
}
/// ffmpeg (see `filmcraft_testkit::oracle`).
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(PathBuf::from(p));
}
}
None
filmcraft_testkit::ffmpeg()
}
pub fn fixtures_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/hevc");
std::fs::create_dir_all(&d).expect("create fixtures dir");
d
filmcraft_testkit::fixtures_dir("hevc")
}
fn run(cmd: &mut Command) -> bool {
@ -121,15 +115,12 @@ pub fn pix_fmt(bd: u32) -> &'static str {
/// Generate (if needed) the fixture stream and its ffmpeg reference decode.
/// Returns None (with a message) when ffmpeg or the encoder is unavailable.
pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
let Some(ff) = ffmpeg() else {
eprintln!("SKIP {}: ffmpeg not found", f.name);
return None;
};
let ff = filmcraft_testkit::ffmpeg_or_skip(f.name)?;
let dir = fixtures_dir();
let hevc = dir.join(format!("{}.hevc", f.name));
let yuv = dir.join(format!("{}.yuv", f.name));
if !hevc.exists() {
let tmp = dir.join(format!("{}.tmp.{}.hevc", f.name, unique()));
let tmp = filmcraft_testkit::temp_path(&hevc);
let mut vf = format!("{}=size={}x{}:rate=25,format={}", f.source, f.width, f.height, pix_fmt(f.bit_depth));
if !f.filter.is_empty() {
vf.push(',');
@ -148,13 +139,13 @@ pub fn ensure(f: &Fixture) -> Option<(PathBuf, PathBuf)> {
// Platform encoders (VideoToolbox) are optional; libx265 fixtures must generate.
assert!(f.args.contains(&"-c:v"), "fixture generation failed for {}", f.name);
let _ = std::fs::remove_file(&tmp);
eprintln!("SKIP {}: encoder unavailable", f.name);
eprintln!("SKIPPED ({}): encoder unavailable", f.name);
return None;
}
std::fs::rename(&tmp, &hevc).unwrap();
}
if !yuv.exists() {
let tmp = dir.join(format!("{}.tmp.{}.yuv", f.name, unique()));
let tmp = filmcraft_testkit::temp_path(&yuv);
let mut c = Command::new(&ff);
c.args(["-hide_banner", "-loglevel", "error", "-y", "-i"]).arg(&hevc);
c.args(["-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt(f.bit_depth)]).arg(&tmp);
@ -316,9 +307,3 @@ pub fn check_pts(pics: &[Picture]) -> Result<(), String> {
}
Ok(())
}
/// Unique temp-file suffix: process id plus a per-process counter (tests run on parallel threads).
fn unique() -> String {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
format!("{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
}

View File

@ -14,5 +14,6 @@ filmcraft-bitstream = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }
proptest = { workspace = true }
serde_json = { workspace = true }

View File

@ -6,37 +6,27 @@ use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Find a tool silently (see `filmcraft_testkit::oracle`).
pub fn tool(name: &str) -> Option<PathBuf> {
for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
let p = Path::new(dir).join(name);
if p.exists() {
return Some(p);
}
match name {
"ffmpeg" => filmcraft_testkit::ffmpeg(),
"ffprobe" => filmcraft_testkit::ffprobe(),
_ => filmcraft_testkit::oracle::find_tool(name),
}
let ok = Command::new(name).arg("-version").output().map(|o| o.status.success()).unwrap_or(false);
ok.then(|| PathBuf::from(name))
}
/// ffmpeg, or `None` after printing `SKIPPED` (a failure with `FILMCRAFT_REQUIRE_ORACLES=1`).
pub fn ffmpeg() -> Option<PathBuf> {
let f = tool("ffmpeg");
if f.is_none() {
eprintln!("ffmpeg not found; skipping oracle test");
}
f
filmcraft_testkit::ffmpeg_or_skip("isobmff oracle")
}
/// ffprobe, or `None` after printing `SKIPPED` (a failure with `FILMCRAFT_REQUIRE_ORACLES=1`).
pub fn ffprobe() -> Option<PathBuf> {
let f = tool("ffprobe");
if f.is_none() {
eprintln!("ffprobe not found; skipping oracle test");
}
f
filmcraft_testkit::ffprobe_or_skip("isobmff oracle")
}
pub fn fixture_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/isobmff");
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir("isobmff")
}
pub fn out_dir() -> PathBuf {
@ -120,10 +110,7 @@ pub fn fixture_specs() -> Vec<(&'static str, Vec<&'static str>)> {
}
fn run_ffmpeg(ff: &Path, args: &[&str], out: &Path) -> bool {
static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let ext = out.extension().unwrap().to_str().unwrap();
let tmp = out.with_extension(format!("tmp{}_{n}.{ext}", std::process::id()));
let tmp = filmcraft_testkit::temp_path(out);
let st = Command::new(ff).args(["-v", "error", "-y", "-nostdin"]).args(args).arg(&tmp).output();
match st {
Ok(o) if o.status.success() => std::fs::rename(&tmp, out).is_ok(),
@ -139,7 +126,7 @@ fn run_ffmpeg(ff: &Path, args: &[&str], out: &Path) -> bool {
/// Path to a fixture, generating it with ffmpeg if needed. `None` if ffmpeg is missing or the
/// encoder isn't available.
pub fn fixture(name: &str) -> Option<PathBuf> {
let ff = tool("ffmpeg")?;
let ff = ffmpeg()?;
let path = fixture_dir().join(name);
if path.exists() {
return Some(path);

View File

@ -12,4 +12,5 @@ workspace = true
[dependencies]
[dev-dependencies]
filmcraft-testkit = { workspace = true }
serde_json = { workspace = true }

View File

@ -6,32 +6,13 @@ use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
pub fn tool(name: &str) -> Option<PathBuf> {
for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
let p = Path::new(dir).join(name);
if p.exists() {
return Some(p);
}
}
let ok = Command::new(name).arg("-version").output().map(|o| o.status.success()).unwrap_or(false);
ok.then(|| PathBuf::from(name))
}
/// (ffmpeg, ffprobe), or `None` (test skipped) if either is missing.
/// (ffmpeg, ffprobe), or `None` after printing `SKIPPED` (see `filmcraft_testkit::oracle`).
pub fn tools() -> Option<(PathBuf, PathBuf)> {
match (tool("ffmpeg"), tool("ffprobe")) {
(Some(a), Some(b)) => Some((a, b)),
_ => {
eprintln!("ffmpeg/ffprobe not found; skipping oracle test");
None
}
}
Some((filmcraft_testkit::ffmpeg_or_skip("matroska oracle")?, filmcraft_testkit::ffprobe_or_skip("matroska oracle")?))
}
pub fn fixture_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/matroska");
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir("matroska")
}
const V: &[&str] = &["-f", "lavfi", "-i", "testsrc2=size=160x120:rate=25"];
@ -93,8 +74,7 @@ pub fn fixture(ffmpeg: &Path, name: &str) -> Option<PathBuf> {
return Some(out);
}
write_subs(&dir);
let tid = format!("{:?}", std::thread::current().id()).replace(|c: char| !c.is_ascii_alphanumeric(), "");
let tmp = dir.join(format!("{name}.{}.{tid}.tmp", std::process::id()));
let tmp = filmcraft_testkit::temp_path(&out);
let ok = match spec(name) {
Some(args) => {
let mut c = Command::new(ffmpeg);

View File

@ -10,3 +10,6 @@ rust-version.workspace = true
workspace = true
[dependencies]
[dev-dependencies]
filmcraft-testkit = { workspace = true }

View File

@ -1,6 +1,6 @@
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::path::PathBuf;
/// Plain SNR (dB) of `test` against `reference` over the common length.
pub fn snr_db(reference: &[f32], test: &[f32]) -> f64 {
@ -138,19 +138,13 @@ pub fn opus_quality(reference: &[f32], test: &[f32], channels: usize, rate: u32)
100.0 * (1.0 - 0.5 * (1.0 + err).ln() / 1.13f64.ln())
}
/// ffmpeg (see `filmcraft_testkit::oracle`); `None` after printing `SKIPPED`.
pub fn ffmpeg() -> Option<PathBuf> {
for p in ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"] {
if Path::new(p).exists() {
return Some(PathBuf::from(p));
}
}
None
filmcraft_testkit::ffmpeg_or_skip("opus oracle")
}
pub fn fixtures() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/opus/oracle");
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir("opus/oracle")
}
/// Minimal Ogg demuxer: returns (packets, last granule position).

View File

@ -17,3 +17,6 @@ threads = ["dep:rayon"]
filmcraft-bitstream = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
[dev-dependencies]
filmcraft-testkit = { workspace = true }

View File

@ -6,24 +6,13 @@ use filmcraft_prores::{ChromaFormat, Frame};
use std::path::{Path, PathBuf};
use std::process::Command;
/// ffmpeg, or `None` after printing `SKIPPED` (see `filmcraft_testkit::oracle`).
pub fn ffmpeg() -> Option<PathBuf> {
for dir in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
let p = Path::new(dir).join("ffmpeg");
if p.exists() {
return Some(p);
}
}
let ok = Command::new("ffmpeg").arg("-version").output().map(|o| o.status.success()).unwrap_or(false);
if !ok {
eprintln!("ffmpeg not found; skipping oracle test");
}
ok.then(|| PathBuf::from("ffmpeg"))
filmcraft_testkit::ffmpeg_or_skip("prores oracle")
}
pub fn fixture_dir() -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/prores");
std::fs::create_dir_all(&d).unwrap();
d
filmcraft_testkit::fixtures_dir("prores")
}
fn run(ff: &Path, args: &[&str]) {
@ -51,7 +40,7 @@ impl Spec {
pub fn make(ff: &Path, spec: &Spec) -> PathBuf {
let path = fixture_dir().join(format!("{}.mov", spec.name));
if !path.exists() {
let tmp = fixture_dir().join(format!("{}.tmp.mov", spec.name));
let tmp = filmcraft_testkit::temp_path(&path);
let frames = spec.frames.to_string();
let mut args = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str()];
args.extend(spec.enc.iter().copied());

16
crates/testkit/Cargo.toml Normal file
View File

@ -0,0 +1,16 @@
[package]
name = "filmcraft-testkit"
description = "Test-only helpers: ffmpeg/ffprobe oracle discovery, fixture directories, golden-image comparison"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
publish = false
[lints]
workspace = true
# Used only as a dev-dependency. No FilmCraft dependencies, so any crate (including L0 codecs) can
# use it without creating dependency cycles.
[dependencies]
png = "0.18"

View File

@ -0,0 +1,102 @@
//! Fixture locations and race-free generation.
//!
//! Fixtures live in `<workspace>/target/fixtures/<crate>/` whatever `CARGO_TARGET_DIR` is, so
//! parallel agents with private target dirs share one generated set. `FILMCRAFT_FIXTURES_DIR`
//! overrides the root. Media is never committed.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
/// The workspace root (the directory holding the top-level `Cargo.toml`).
pub fn workspace_root() -> PathBuf {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
let root = here.parent().and_then(Path::parent).unwrap_or(here);
root.canonicalize().unwrap_or_else(|_| root.to_path_buf())
}
/// Root of all generated fixtures: `$FILMCRAFT_FIXTURES_DIR` or `<workspace>/target/fixtures`.
pub fn fixtures_root() -> PathBuf {
match std::env::var_os("FILMCRAFT_FIXTURES_DIR").filter(|v| !v.is_empty()) {
Some(d) => PathBuf::from(d),
None => workspace_root().join("target").join("fixtures"),
}
}
/// `<fixtures root>/<sub>` (created). `sub` may contain `/` (e.g. `"opus/oracle"`).
pub fn fixtures_dir(sub: &str) -> PathBuf {
let mut d = fixtures_root();
for part in sub.split('/').filter(|p| !p.is_empty()) {
d.push(part);
}
std::fs::create_dir_all(&d).unwrap_or_else(|e| panic!("create {}: {e}", d.display()));
d
}
/// A temporary sibling of `out` that no other thread or process will pick: the stem gets
/// `.tmp-<pid>-<thread>-<n>` and the extension is kept (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)
}
/// Return `out` if it already exists (non-empty); otherwise run `make(tmp)` on a [`temp_path`]
/// and atomically rename the result into place. Concurrent generators race harmlessly.
/// `None` when `make` reports failure (the temporary file is removed).
pub fn generate(out: &Path, make: impl FnOnce(&Path) -> bool) -> Option<PathBuf> {
if std::fs::metadata(out).is_ok_and(|m| m.len() > 0) {
return Some(out.to_path_buf());
}
if let Some(dir) = out.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = temp_path(out);
if !make(&tmp) || !tmp.exists() {
let _ = std::fs::remove_file(&tmp);
return None;
}
std::fs::rename(&tmp, out).ok()?;
Some(out.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_is_the_workspace() {
assert!(workspace_root().join("Cargo.toml").exists());
assert!(workspace_root().join("crates").join("testkit").exists());
}
#[test]
fn temp_paths_are_unique_and_keep_the_extension() {
let out = Path::new("/x/clip.mp4");
let a = temp_path(out);
let b = temp_path(out);
assert_ne!(a, b);
assert_eq!(a.extension().unwrap(), "mp4");
let other = std::thread::spawn(move || temp_path(Path::new("/x/clip.mp4"))).join().unwrap();
assert_ne!(a, other);
}
#[test]
fn generate_is_cached_and_atomic() {
let dir = std::env::temp_dir().join(format!("filmcraft-testkit-gen-{}", std::process::id()));
let out = dir.join("f.bin");
let _ = std::fs::remove_file(&out);
assert!(generate(&out, |_| false).is_none());
assert!(!out.exists());
let p = generate(&out, |t| std::fs::write(t, b"abc").is_ok()).unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"abc");
// cached: the generator is not called again
let p2 = generate(&out, |_| panic!("regenerated")).unwrap();
assert_eq!(p, p2);
}
}

59
crates/testkit/src/lib.rs Normal file
View File

@ -0,0 +1,59 @@
//! Test-only helpers shared by every FilmCraft crate (used as a dev-dependency only).
//!
//! - [`oracle`]: find `ffmpeg` / `ffprobe` (env vars, `PATH`, well-known dirs) and skip — or, with
//! `FILMCRAFT_REQUIRE_ORACLES=1`, fail — when they are missing ([`require_ffmpeg!`] & co.).
//! - [`fixtures`]: the shared fixture directory (`<workspace>/target/fixtures/<crate>`) and
//! collision-free temporary names for concurrent generators.
//!
//! ffmpeg/ffprobe are external test oracles and fixture generators only; they are never linked
//! or shipped (AGENTS.md §2).
pub mod fixtures;
pub mod oracle;
pub use fixtures::{fixtures_dir, temp_path, workspace_root};
pub use oracle::{ffmpeg, ffmpeg_or_skip, ffprobe, ffprobe_or_skip, oracles_required, skip};
/// `let ff = require_ffmpeg!();` — the ffmpeg path, or return from the test after printing
/// `SKIPPED: … ffmpeg not found` (a hard failure with `FILMCRAFT_REQUIRE_ORACLES=1`).
/// `require_ffmpeg!(value)` returns `value` instead of `()`.
#[macro_export]
macro_rules! require_ffmpeg {
() => {
match $crate::ffmpeg_or_skip(concat!(module_path!(), ":", line!())) {
Some(p) => p,
None => return,
}
};
($ret:expr) => {
match $crate::ffmpeg_or_skip(concat!(module_path!(), ":", line!())) {
Some(p) => p,
None => return $ret,
}
};
}
/// Like [`require_ffmpeg!`] for ffprobe.
#[macro_export]
macro_rules! require_ffprobe {
() => {
match $crate::ffprobe_or_skip(concat!(module_path!(), ":", line!())) {
Some(p) => p,
None => return,
}
};
($ret:expr) => {
match $crate::ffprobe_or_skip(concat!(module_path!(), ":", line!())) {
Some(p) => p,
None => return $ret,
}
};
}
/// `let (ffmpeg, ffprobe) = require_oracles!();` — both tools or skip.
#[macro_export]
macro_rules! require_oracles {
() => {
($crate::require_ffmpeg!(), $crate::require_ffprobe!())
};
}

View File

@ -0,0 +1,170 @@
//! Discovery of the external oracle tools (ffmpeg, ffprobe).
//!
//! Search order for a tool `T` (`ffmpeg` or `ffprobe`):
//! 1. `FILMCRAFT_FFMPEG` / `FILMCRAFT_FFPROBE`: an explicit path (a set-but-wrong path is an error,
//! not a silent skip);
//! 2. every directory on `PATH` (`T`, and `T.exe` on Windows);
//! 3. well-known install locations (`/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`,
//! `C:\ffmpeg\bin`, …).
//!
//! When a tool is missing, tests print `SKIPPED: …` and pass — unless
//! `FILMCRAFT_REQUIRE_ORACLES=1` is set (CI), which turns every skip into a failure.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Environment variable that makes missing oracles a hard failure.
pub const REQUIRE_ENV: &str = "FILMCRAFT_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 exe_names(name: &str) -> Vec<String> {
if cfg!(windows) { vec![format!("{name}.exe"), name.to_string()] } else { vec![name.to_string(), format!("{name}.exe")] }
}
fn is_file(p: &Path) -> bool {
p.is_file()
}
/// Find `name` given an explicit override, a `PATH`-style list and extra directories.
/// (Pure function of its inputs; [`find_tool`] supplies the process environment.)
pub fn find_tool_in(name: &str, explicit: Option<&std::ffi::OsStr>, path: Option<&std::ffi::OsStr>, extra: &[&str]) -> Result<Option<PathBuf>, String> {
if let Some(e) = explicit.filter(|e| !e.is_empty()) {
let p = PathBuf::from(e);
return if is_file(&p) { Ok(Some(p)) } else { Err(format!("{} is set but `{}` is not a file", env_var(name), p.display())) };
}
let names = exe_names(name);
if let Some(path) = path {
for dir in std::env::split_paths(path) {
for n in &names {
let p = dir.join(n);
if is_file(&p) {
return Ok(Some(p));
}
}
}
}
for dir in extra {
for n in &names {
let p = Path::new(dir).join(n);
if is_file(&p) {
return Ok(Some(p));
}
}
}
Ok(None)
}
fn env_var(name: &str) -> String {
format!("FILMCRAFT_{}", name.to_ascii_uppercase())
}
/// Find a tool using the process environment (uncached).
pub fn find_tool(name: &str) -> Option<PathBuf> {
let explicit = std::env::var_os(env_var(name));
let path = std::env::var_os("PATH");
match find_tool_in(name, explicit.as_deref(), path.as_deref(), KNOWN_DIRS) {
Ok(p) => p,
Err(e) => panic!("{e}"),
}
}
/// Path of ffmpeg, if available (cached for the process).
pub fn ffmpeg() -> Option<PathBuf> {
static F: OnceLock<Option<PathBuf>> = OnceLock::new();
F.get_or_init(|| find_tool("ffmpeg")).clone()
}
/// Path of ffprobe, if available (cached for the process).
pub fn ffprobe() -> Option<PathBuf> {
static F: OnceLock<Option<PathBuf>> = OnceLock::new();
F.get_or_init(|| find_tool("ffprobe")).clone()
}
/// Whether `FILMCRAFT_REQUIRE_ORACLES` is set to a true value.
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"))
}
/// Report a skipped test: prints `SKIPPED (<context>): <reason>`. With
/// `FILMCRAFT_REQUIRE_ORACLES=1` it panics instead, so CI cannot pass by skipping.
pub fn skip(context: &str, reason: &str) {
if oracles_required() {
panic!("{context}: {reason}, and {REQUIRE_ENV}=1 forbids skipping oracle tests");
}
eprintln!("SKIPPED ({context}): {reason}");
}
fn tool_or_skip(name: &str, found: Option<PathBuf>, context: &str) -> Option<PathBuf> {
if found.is_none() {
skip(context, &format!("{name} not found (set {} or put {name} on PATH)", env_var(name)));
}
found
}
/// ffmpeg, or `None` after reporting a skip (see [`skip`]).
pub fn ffmpeg_or_skip(context: &str) -> Option<PathBuf> {
tool_or_skip("ffmpeg", ffmpeg(), context)
}
/// ffprobe, or `None` after reporting a skip (see [`skip`]).
pub fn ffprobe_or_skip(context: &str) -> Option<PathBuf> {
tool_or_skip("ffprobe", ffprobe(), context)
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
fn tmpdir(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("filmcraft-testkit-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn explicit_path_wins_and_bad_path_errors() {
let d = tmpdir("explicit");
let f = d.join("my-ffmpeg");
std::fs::write(&f, b"").unwrap();
let got = find_tool_in("ffmpeg", Some(f.as_os_str()), None, &[]).unwrap();
assert_eq!(got, Some(f.clone()));
let missing = d.join("nope");
assert!(find_tool_in("ffmpeg", Some(missing.as_os_str()), None, &[]).is_err());
// empty value = unset
assert_eq!(find_tool_in("ffmpeg", Some(OsString::new().as_os_str()), None, &[]).unwrap(), None);
}
#[test]
fn path_search_finds_plain_and_exe_names() {
let a = tmpdir("path-a");
let b = tmpdir("path-b");
std::fs::write(b.join("ffprobe.exe"), b"").unwrap();
let path = std::env::join_paths([&a, &b]).unwrap();
let got = find_tool_in("ffprobe", None, Some(&path), &[]).unwrap();
assert_eq!(got, Some(b.join("ffprobe.exe")));
std::fs::write(a.join("ffprobe"), b"").unwrap();
let got = find_tool_in("ffprobe", None, Some(&path), &[]).unwrap();
assert!(got.is_some_and(|p| p.starts_with(&a)), "earlier PATH entry wins");
}
#[test]
fn known_dirs_are_the_fallback() {
let d = tmpdir("known");
std::fs::write(d.join("ffmpeg"), b"").unwrap();
let ds = d.to_string_lossy().to_string();
let got = find_tool_in("ffmpeg", None, Some(OsString::new().as_os_str()), &[ds.as_str()]).unwrap();
assert_eq!(got, Some(d.join("ffmpeg")));
assert_eq!(find_tool_in("ffmpeg-does-not-exist", None, None, &[ds.as_str()]).unwrap(), None);
}
}

View File

@ -26,7 +26,7 @@ Design principles:
L3 render · gpu · export
L2 edit · codecs · interchange
L1 frame · media · project · audio-dsp
L0 foundation: time · geom · color · bitstream
L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only)
codecs/containers: isobmff · matroska · h264 · h264enc · hevc · prores · aac
```
@ -45,6 +45,7 @@ and `filmcraft-cli`.
| `hevc` | L0 | H.265 Main/Main 10 decoder |
| `prores` | L0 | ProRes decoder and encoder |
| `aac` | L0 | AAC-LC decoder and encoder |
| `testkit` | L0 | test-only helpers, used only as a dev-dependency: ffmpeg/ffprobe discovery, fixture dirs, golden images ([testing.md](testing.md)) |
| `frame` | L1 | `VideoFrame` (planar YUV / RGBA8 / linear RGBA f32, colour metadata), `AudioBuffer` |
| `media` | L1 | `MediaSource` trait, probing/openers, frame cache, generators, stills, WAV |
| `project` | L1 | document model, effect definitions, keyframes |
@ -72,7 +73,7 @@ are exempt):
| Every crate has a layer | A new crate fails the check until it is added to `LAYERS`. |
| Only downward edges | A crate may not depend on a crate in a higher layer. |
| Same-layer edges are listed | From L1 up, a same-layer edge must be in `SAME_LAYER`: `media→frame`, `project→media`, `project→frame`, `gpu→render`, `export→render`, `cli→filmcraft`, plus a few reserved for planned crates. |
| L0 codecs stay standalone | L0 crates other than `time`, `geom`, `color`, `bitstream` may depend on no workspace crate except `filmcraft-bitstream`. External crates such as `thiserror` and `rayon` are allowed. |
| L0 codecs stay standalone | L0 crates other than `time`, `geom`, `color`, `bitstream`, `testkit` may depend on no workspace crate except `filmcraft-bitstream`. External crates such as `thiserror` and `rayon` are allowed. |
| No UI/OS crates below L5 | `egui`, `eframe`, `egui-wgpu`, `winit`, `rfd`, `cpal`, `muda` are allowed only in L5 and L6. |
`cargo xtask wasm` runs `cargo check --target wasm32-unknown-unknown` on every L0–L4 crate, so

View File

@ -27,10 +27,22 @@ Commit `*.proptest-regressions` files so failing cases are re-run.
ffmpeg and ffprobe are **external processes** used to generate fixtures and to check results. They
are never linked or shipped ([AGENTS.md](../AGENTS.md) §2).
- Each crate's `tests/common/mod.rs` finds the tools in `/opt/homebrew/bin`, `/usr/local/bin` or
`/usr/bin`. If they are missing, the test prints a message and returns (it passes).
- Fixtures are generated on first use into `<repo>/target/fixtures/<crate>/` and reused afterwards.
Delete that directory to regenerate them. Never commit media.
- The tools are found by `filmcraft-testkit` (a dev-dependency-only crate, `crates/testkit`), in
this order: `FILMCRAFT_FFMPEG` / `FILMCRAFT_FFPROBE` (explicit paths; a wrong path is an error),
then every directory on `PATH` (`ffmpeg` and `ffmpeg.exe`), then well-known install directories
(`/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `C:\ffmpeg\bin`, …).
- If a tool is missing the test prints `SKIPPED (<test>): ffmpeg not found …` and passes. Set
**`FILMCRAFT_REQUIRE_ORACLES=1`** (CI) to make every such skip a failure. In new tests use
`let ff = filmcraft_testkit::require_ffmpeg!();` (also `require_ffprobe!`, `require_oracles!`).
- Fixtures are generated on first use into `<workspace>/target/fixtures/<crate>/`
(`filmcraft_testkit::fixtures_dir`; independent of `CARGO_TARGET_DIR`, so agents with private
target dirs share them; `FILMCRAFT_FIXTURES_DIR` overrides the root) and reused afterwards.
Generators write to a per-process/thread temporary name (`testkit::temp_path`) and rename it into
place, so concurrent tests never see half-written files. Delete the directory to regenerate.
Never commit media.
- Every crate's `tests/common/mod.rs` delegates discovery and fixture paths to testkit, except
`crates/codecs/src/tests.rs`, which still has its own lookup (the crate was being edited
concurrently; migrate it when convenient).
- Fixture sources are synthetic: `testsrc2`, `mandelbrot`, SMPTE bars, noise and fades, sine tones.
H.264 and HEVC fixtures need ffmpeg built with libx264 and libx265. VideoToolbox fixtures are
generated only on macOS.

View File

@ -13,6 +13,7 @@ use serde_json::Value;
/// (crate name without the `filmcraft-` prefix, layer).
const LAYERS: &[(&str, u8)] = &[
("testkit", 0),
("time", 0),
("geom", 0),
("color", 0),
@ -55,7 +56,7 @@ const LAYERS: &[(&str, u8)] = &[
];
/// L0 crates that are not codecs/containers (no `bitstream`-only restriction).
const L0_FOUNDATION: &[&str] = &["time", "geom", "color", "bitstream"];
const L0_FOUNDATION: &[&str] = &["time", "geom", "color", "bitstream", "testkit"];
/// Allowed same-layer edges (from, to).
const SAME_LAYER: &[(&str, &str)] = &[