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:
parent
1c9073de2b
commit
8780d5ffa2
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -19,3 +19,4 @@ thiserror = { workspace = true }
|
|||
rayon = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -19,3 +19,4 @@ thiserror = { workspace = true }
|
|||
rayon = { workspace = true, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,5 +14,6 @@ filmcraft-bitstream = { workspace = true }
|
|||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -12,4 +12,5 @@ workspace = true
|
|||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -10,3 +10,6 @@ rust-version.workspace = true
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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!())
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)] = &[
|
||||
|
|
|
|||
Loading…
Reference in New Issue