craft-media-testkit: shared media test helpers and ffmpeg oracle discovery (#701)

* media-testkit: shared test helpers and ffmpeg oracle discovery

* media-testkit: report fixture rename failure via skip; media: unlink relative craft-video path

* media-testkit: failed fixture generator honours CRAFT_MEDIA_REQUIRE_ORACLES

generate() returned None directly when the generator reported failure, so a
required-oracle run could pass with a missing fixture. Route it through skip,
like run_generator, and cover both policies with tests.

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
el-jandFredApps authored and GitHub committed 2026-11-10 18:56:30 +01:00
1 parent 6768a9098b
commit 41ff22dcd4
2 files changed
+163 -3

No files matched your search

+160
View File
@@ -157,3 +157,163 @@ pub fn generate_and_report<T>(name: &str, outputs: &[PathBuf], generate: impl Fn
println!("FIXTURE {status} {name}");
r
}
/// Check if ffmpeg has a specific encoder compiled in.
pub fn ffmpeg_has_encoder(ff: &Path, encoder: &str) -> bool {
let Ok(output) = Command::new(ff).args(["-hide_banner", "-encoders"]).output() else {
return false;
};
if !output.status.success() {
return false;
}
parse_has_encoder(&String::from_utf8_lossy(&output.stdout), encoder)
}
fn parse_has_encoder(stdout: &str, encoder: &str) -> bool {
stdout.lines().any(|line| {
let mut parts = line.split_whitespace();
if let (Some(_flags), Some(name)) = (parts.next(), parts.next()) { name == encoder } else { false }
})
}
/// Report a skipped test that is not an oracle check (an unsupported platform or an optional
/// encoder capability). Unlike [`skip`], this never fails under `CRAFT_MEDIA_REQUIRE_ORACLES=1`.
pub fn skip_test(what: &str, reason: &str) {
eprintln!("SKIPPED ({what}): {reason}");
}
/// Find ffmpeg, or print a skip message and return None.
pub fn ffmpeg_or_skip(reason: &str) -> Option<PathBuf> {
tool("ffmpeg").or_else(|| skip(&format!("{reason}: ffmpeg not found (set {} or put ffmpeg on PATH)", env_var("ffmpeg"))))
}
/// Log progress of long tests.
pub fn set_progress(label: &str) {
eprintln!("PROGRESS: {label}");
}
/// Run closure with an execution deadline, terminating with a panic if the limit is exceeded.
#[allow(clippy::panic)]
pub fn with_deadline<F: FnOnce() + Send + 'static>(secs: u64, f: F) {
let timeout_secs = std::env::var("CRAFT_TEST_TIMEOUT_SECS").ok().and_then(|s| s.parse().ok()).unwrap_or(secs);
with_timeout(timeout_secs, f);
}
#[allow(clippy::panic)]
fn with_timeout<F: FnOnce() + Send + 'static>(timeout_secs: u64, f: F) {
let (tx, rx) = std::sync::mpsc::channel();
let handle = std::thread::spawn(move || {
f();
let _ = tx.send(());
});
match rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) {
Ok(()) => {
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
panic!("test exceeded execution deadline of {timeout_secs} seconds");
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
}
}
/// Return fixture directory for a crate/category, creating it if needed.
pub fn fixtures_dir(subdir: &str) -> PathBuf {
fixture_subdir(subdir)
}
pub mod fixtures {
use super::temp_path;
use std::path::{Path, PathBuf};
/// Atomically publish a fixture by renaming `tmp` to `path`.
pub fn publish(tmp: &Path, path: &Path) -> std::io::Result<()> {
std::fs::rename(tmp, path)
}
/// Generate fixture `out` using generator closure if it does not already exist. A generator
/// that returns `false`, or whose output cannot be moved into place, skips (see
/// [`skip`](super::skip)): a failure with `CRAFT_MEDIA_REQUIRE_ORACLES=1`, so a required
/// oracle run cannot pass with a fixture missing.
pub fn generate<F: FnOnce(&Path) -> bool>(out: &Path, f: F) -> Option<PathBuf> {
generate_with(out, super::oracles_required(), f)
}
/// [`generate`] with `required` explicit, so tests can set it without touching the process
/// environment.
pub fn generate_with<F: FnOnce(&Path) -> bool>(out: &Path, required: bool, f: F) -> Option<PathBuf> {
if out.exists() {
return Some(out.to_path_buf());
}
let tmp = temp_path(out);
let reason = if f(&tmp) {
match std::fs::rename(&tmp, out) {
Ok(()) => return Some(out.to_path_buf()),
Err(e) => format!("could not move {} into place: {e}", tmp.display()),
}
} else {
format!("fixture generator failed for {}", out.display())
};
let _ = std::fs::remove_file(&tmp);
super::skip_with(required, &reason)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failed_generator_skips_when_optional() {
let out = std::env::temp_dir().join(format!("craft-media-testkit-gen-opt-{}", std::process::id()));
let _ = std::fs::remove_file(&out);
assert!(fixtures::generate_with(&out, false, |_| false).is_none());
assert!(!out.exists());
}
#[test]
#[should_panic(expected = "forbids skipping oracle tests")]
fn failed_generator_fails_when_required() {
let out = std::env::temp_dir().join(format!("craft-media-testkit-gen-req-{}", std::process::id()));
let _ = std::fs::remove_file(&out);
let _ = fixtures::generate_with(&out, true, |_| false);
}
#[test]
fn parse_has_encoder_exact_match() {
let sample = "\
Encoders:
V..... = Video
------
V....D libvpx-vp9 libvpx VP9 (codec vp9)
V....D libvpx libvpx VP8 (codec vp8)
A....D flac FLAC (Free Lossless Audio Codec)
";
assert!(parse_has_encoder(sample, "libvpx"));
assert!(parse_has_encoder(sample, "libvpx-vp9"));
assert!(parse_has_encoder(sample, "flac"));
assert!(!parse_has_encoder(sample, "vp8"));
assert!(!parse_has_encoder(sample, "libvp"));
}
#[test]
fn with_deadline_completes() {
with_deadline(5, || {
// fast operation
});
}
#[test]
#[should_panic(expected = "test exceeded execution deadline")]
fn with_deadline_times_out() {
with_timeout(1, || {
std::thread::sleep(std::time::Duration::from_secs(3));
});
}
}
+3 -3
View File
@@ -6,7 +6,7 @@ demuxer, written from the public specifications. No `unsafe`, no panics on malfo
for `wasm32-unknown-unknown`. Dependencies: `thiserror` (for `isobmff`), `serde` (optional).
Codecs are in separate crates that depend on this one: [`craft-audio`](../audio/README.md) for
audio; video codecs will go in a separate crate. What both need lives here: the containers, the
audio; `craft-video` for video. What both need lives here: the containers, the
bit I/O, one `ByteSource` trait, and the [shared identifiers](#containers-codecs-and-codec-parameters)
that codec crates use to describe a file and configure a decoder.
@@ -39,8 +39,8 @@ craft-media = { git = "https://github.com/storytold/craft-libs", rev = "<commit>
- In scope: bit-level reading and writing, and container demuxing and muxing (boxes, sample
tables, sample entries and codec configuration records) for audio and video tracks.
- Out of scope: codecs. Audio codecs are in `craft-audio`; video codecs (H.264, HEVC, ProRes and
others) stay in the apps until a video crate exists. Containers only FilmCraft reads (MXF,
- Out of scope: codecs. Audio codecs are in `craft-audio`; video codecs (H.264, HEVC, ProRes,
DNx, VP8, VP9, AV1) are in `craft-video`. Containers only FilmCraft reads (MXF,
MPEG-TS/PS, AVI) are not here yet.
- Anything both the audio and the video codec crates need belongs here, so the two never depend
on each other.