From 8780d5ffa26ae1fac7bf34c17b7b6365d56b41c7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:10:59 -0400 Subject: [PATCH] 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 /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 --- Cargo.lock | 18 +++ Cargo.toml | 1 + crates/aac/Cargo.toml | 3 + crates/aac/tests/common/mod.rs | 12 +- crates/export/Cargo.toml | 1 + crates/export/src/tests.rs | 2 +- crates/h264/Cargo.toml | 1 + crates/h264/src/synth_tests.rs | 15 +-- crates/h264/tests/common/mod.rs | 23 ++-- crates/h264enc/Cargo.toml | 3 + crates/h264enc/tests/common/mod.rs | 22 +--- crates/hevc/Cargo.toml | 1 + crates/hevc/src/synth_tests.rs | 13 +-- crates/hevc/tests/common/mod.rs | 29 ++--- crates/isobmff/Cargo.toml | 1 + crates/isobmff/tests/common/mod.rs | 37 ++---- crates/matroska/Cargo.toml | 1 + crates/matroska/tests/common/mod.rs | 28 +---- crates/opus/Cargo.toml | 3 + crates/opus/tests/common/mod.rs | 14 +-- crates/prores/Cargo.toml | 3 + crates/prores/tests/common/mod.rs | 19 +--- crates/testkit/Cargo.toml | 16 +++ crates/testkit/src/fixtures.rs | 102 +++++++++++++++++ crates/testkit/src/lib.rs | 59 ++++++++++ crates/testkit/src/oracle.rs | 170 ++++++++++++++++++++++++++++ docs/architecture.md | 5 +- docs/testing.md | 20 +++- xtask/src/main.rs | 3 +- 29 files changed, 462 insertions(+), 163 deletions(-) create mode 100644 crates/testkit/Cargo.toml create mode 100644 crates/testkit/src/fixtures.rs create mode 100644 crates/testkit/src/lib.rs create mode 100644 crates/testkit/src/oracle.rs diff --git a/Cargo.lock b/Cargo.lock index f1044aa2..64ec283d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 589027ad..f6fa0028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/aac/Cargo.toml b/crates/aac/Cargo.toml index 1f1203da..1bc26426 100644 --- a/crates/aac/Cargo.toml +++ b/crates/aac/Cargo.toml @@ -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 } diff --git a/crates/aac/tests/common/mod.rs b/crates/aac/tests/common/mod.rs index 7ffc04c2..eb055172 100644 --- a/crates/aac/tests/common/mod.rs +++ b/crates/aac/tests/common/mod.rs @@ -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 { - 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); diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index 998b0829..23e6e0d3 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -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 } diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 7747a75b..8772f473 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -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() diff --git a/crates/h264/Cargo.toml b/crates/h264/Cargo.toml index c33f682c..f2a1eb8f 100644 --- a/crates/h264/Cargo.toml +++ b/crates/h264/Cargo.toml @@ -19,3 +19,4 @@ thiserror = { workspace = true } rayon = { workspace = true, optional = true } [dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/h264/src/synth_tests.rs b/crates/h264/src/synth_tests.rs index dacde84e..69c88739 100644 --- a/crates/h264/src/synth_tests.rs +++ b/crates/h264/src/synth_tests.rs @@ -437,17 +437,12 @@ type Planes3 = (Vec, Vec, Vec); /// 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"]) diff --git a/crates/h264/tests/common/mod.rs b/crates/h264/tests/common/mod.rs index dc726300..78466ff5 100644 --- a/crates/h264/tests/common/mod.rs +++ b/crates/h264/tests/common/mod.rs @@ -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 { - 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); diff --git a/crates/h264enc/Cargo.toml b/crates/h264enc/Cargo.toml index 988eed7f..8385ba91 100644 --- a/crates/h264enc/Cargo.toml +++ b/crates/h264enc/Cargo.toml @@ -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 } diff --git a/crates/h264enc/tests/common/mod.rs b/crates/h264enc/tests/common/mod.rs index 18ddc360..409175fe 100644 --- a/crates/h264enc/tests/common/mod.rs +++ b/crates/h264enc/tests/common/mod.rs @@ -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 { - 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 { - 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 `/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). diff --git a/crates/hevc/Cargo.toml b/crates/hevc/Cargo.toml index 7e963a21..b401435c 100644 --- a/crates/hevc/Cargo.toml +++ b/crates/hevc/Cargo.toml @@ -19,3 +19,4 @@ thiserror = { workspace = true } rayon = { workspace = true, optional = true } [dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/hevc/src/synth_tests.rs b/crates/hevc/src/synth_tests.rs index 291a2ad0..44b8a828 100644 --- a/crates/hevc/src/synth_tests.rs +++ b/crates/hevc/src/synth_tests.rs @@ -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) { diff --git a/crates/hevc/tests/common/mod.rs b/crates/hevc/tests/common/mod.rs index 2da4dabe..56d36829 100644 --- a/crates/hevc/tests/common/mod.rs +++ b/crates/hevc/tests/common/mod.rs @@ -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 { - 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)) -} diff --git a/crates/isobmff/Cargo.toml b/crates/isobmff/Cargo.toml index 620051ad..c9b848fa 100644 --- a/crates/isobmff/Cargo.toml +++ b/crates/isobmff/Cargo.toml @@ -14,5 +14,6 @@ filmcraft-bitstream = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +filmcraft-testkit = { workspace = true } proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/isobmff/tests/common/mod.rs b/crates/isobmff/tests/common/mod.rs index 209f148e..895c217d 100644 --- a/crates/isobmff/tests/common/mod.rs +++ b/crates/isobmff/tests/common/mod.rs @@ -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 { - 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 { - 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 { - 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 { - let ff = tool("ffmpeg")?; + let ff = ffmpeg()?; let path = fixture_dir().join(name); if path.exists() { return Some(path); diff --git a/crates/matroska/Cargo.toml b/crates/matroska/Cargo.toml index 9b74dad0..9f64162f 100644 --- a/crates/matroska/Cargo.toml +++ b/crates/matroska/Cargo.toml @@ -12,4 +12,5 @@ workspace = true [dependencies] [dev-dependencies] +filmcraft-testkit = { workspace = true } serde_json = { workspace = true } diff --git a/crates/matroska/tests/common/mod.rs b/crates/matroska/tests/common/mod.rs index 2e0fd872..1ef7830f 100644 --- a/crates/matroska/tests/common/mod.rs +++ b/crates/matroska/tests/common/mod.rs @@ -6,32 +6,13 @@ use serde_json::Value; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -pub fn tool(name: &str) -> Option { - 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 { 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); diff --git a/crates/opus/Cargo.toml b/crates/opus/Cargo.toml index 463a9408..1ae2c825 100644 --- a/crates/opus/Cargo.toml +++ b/crates/opus/Cargo.toml @@ -10,3 +10,6 @@ rust-version.workspace = true workspace = true [dependencies] + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/opus/tests/common/mod.rs b/crates/opus/tests/common/mod.rs index 712edd62..6c6ec275 100644 --- a/crates/opus/tests/common/mod.rs +++ b/crates/opus/tests/common/mod.rs @@ -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 { - 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). diff --git a/crates/prores/Cargo.toml b/crates/prores/Cargo.toml index aa3f2f11..67c2002d 100644 --- a/crates/prores/Cargo.toml +++ b/crates/prores/Cargo.toml @@ -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 } diff --git a/crates/prores/tests/common/mod.rs b/crates/prores/tests/common/mod.rs index d567dac6..9b3192aa 100644 --- a/crates/prores/tests/common/mod.rs +++ b/crates/prores/tests/common/mod.rs @@ -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 { - 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()); diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 00000000..eca8a6c8 --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -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" diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs new file mode 100644 index 00000000..f6c2f781 --- /dev/null +++ b/crates/testkit/src/fixtures.rs @@ -0,0 +1,102 @@ +//! Fixture locations and race-free generation. +//! +//! Fixtures live in `/target/fixtures//` 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 `/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"), + } +} + +/// `/` (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---` 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 { + 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); + } +} diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 00000000..f617e922 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -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 (`/target/fixtures/`) 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!()) + }; +} diff --git a/crates/testkit/src/oracle.rs b/crates/testkit/src/oracle.rs new file mode 100644 index 00000000..8dbdbd3f --- /dev/null +++ b/crates/testkit/src/oracle.rs @@ -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 { + 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, 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 { + 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 { + static F: OnceLock> = OnceLock::new(); + F.get_or_init(|| find_tool("ffmpeg")).clone() +} + +/// Path of ffprobe, if available (cached for the process). +pub fn ffprobe() -> Option { + static F: OnceLock> = 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 (): `. 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, context: &str) -> Option { + 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 { + tool_or_skip("ffmpeg", ffmpeg(), context) +} + +/// ffprobe, or `None` after reporting a skip (see [`skip`]). +pub fn ffprobe_or_skip(context: &str) -> Option { + 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); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index f8c6b731..5a488be5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/testing.md b/docs/testing.md index dc1327df..c2300a15 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 `/target/fixtures//` 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 (): 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 `/target/fixtures//` + (`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. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index cec79fad..85c085b2 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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)] = &[