diff --git a/crates/media-testkit/src/lib.rs b/crates/media-testkit/src/lib.rs index 36f609f7..60c953e5 100644 --- a/crates/media-testkit/src/lib.rs +++ b/crates/media-testkit/src/lib.rs @@ -157,3 +157,163 @@ pub fn generate_and_report(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 { + 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(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(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 bool>(out: &Path, f: F) -> Option { + 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 bool>(out: &Path, required: bool, f: F) -> Option { + 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)); + }); + } +} diff --git a/crates/media/README.md b/crates/media/README.md index fcf2db62..91fc86f9 100644 --- a/crates/media/README.md +++ b/crates/media/README.md @@ -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 = " - 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.