use ez_ffmpeg::{FfmpegContext, Input, Output};
fn tmp_path(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("ez_ffmpeg_build_probe_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().into_owned()
}
fn graph_path(path: &str) -> String {
path.replace('\\', "/").replace(':', "\\\\:")
}
fn video_input() -> Input {
Input::from("color=c=red:s=64x64:r=15:d=0.2").set_format("lavfi")
}
fn audio_input() -> Input {
Input::from("sine=frequency=440:duration=0.2").set_format("lavfi")
}
fn run_to_result(ctx: FfmpegContext, secs: u64) -> ez_ffmpeg::error::Result<()> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(ctx.start().and_then(|scheduler| scheduler.wait()));
});
match rx.recv_timeout(std::time::Duration::from_secs(secs)) {
Ok(result) => result,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
panic!("job did not finish within {secs}s (hang)")
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
panic!("wait() thread panicked before reporting")
}
}
}
#[test]
fn missing_movie_source_builds_and_fails_at_run_time() {
let missing = tmp_path("no_such_movie.mkv");
let _ = std::fs::remove_file(&missing);
assert!(!std::path::Path::new(&missing).exists());
let ctx = FfmpegContext::builder()
.input(video_input())
.filter_desc(format!(
"movie={}[wm];[0:v][wm]overlay[out]",
graph_path(&missing)
))
.output(Output::from(tmp_path("movie_out.mp4").as_str()).set_video_codec("mpeg4"))
.build()
.expect("build() must not open the movie source");
let result = run_to_result(ctx, 30);
assert!(
matches!(result, Err(ez_ffmpeg::error::Error::FilterGraph(_))),
"a missing movie source must fail the RUNNING job with a FilterGraph error: {result:?}"
);
}
#[test]
fn build_does_not_create_or_truncate_a_filter_destination_file() {
let dest = tmp_path("metadata_dest.txt");
let sentinel = b"PRE-EXISTING CONTENT that build() must leave intact".to_vec();
std::fs::write(&dest, &sentinel).unwrap();
let ctx = FfmpegContext::builder()
.input(video_input())
.filter_desc(format!(
"[0:v]metadata=mode=print:file={}[out]",
graph_path(&dest)
))
.output(Output::from(tmp_path("metadata_out.mp4").as_str()).set_video_codec("mpeg4"))
.build()
.expect("valid config must build");
let after = std::fs::read(&dest).expect("destination must still exist after build()");
assert_eq!(
after, sentinel,
"build() truncated a filter's destination file; filter init must not run at build time"
);
drop(ctx);
let after_drop = std::fs::read(&dest).expect("destination must still exist after drop");
assert_eq!(
after_drop, sentinel,
"dropping an unstarted context truncated a filter's destination file"
);
}
#[test]
fn unknown_filter_name_still_fails_build() {
let err = FfmpegContext::builder()
.input(video_input())
.filter_desc("nosuchfilter_ez_probe")
.output(Output::from(tmp_path("unknown_filter.mp4").as_str()))
.build()
.err();
assert!(
matches!(err, Some(ez_ffmpeg::error::Error::FilterGraphParse(_))),
"an unknown filter name must fail build(): {err:?}"
);
}
#[test]
fn malformed_filter_desc_still_fails_build() {
let err = FfmpegContext::builder()
.input(video_input())
.filter_desc("scale=320:240[unclosed")
.output(Output::from(tmp_path("malformed.mp4").as_str()))
.build()
.err();
assert!(
matches!(err, Some(ez_ffmpeg::error::Error::FilterGraphParse(_))),
"malformed filter_desc syntax must fail build(): {err:?}"
);
}
#[test]
fn unknown_filter_option_still_fails_build() {
let err = FfmpegContext::builder()
.input(video_input())
.filter_desc("scale=no_such_option=1")
.output(Output::from(tmp_path("badopt.mp4").as_str()))
.build()
.err();
assert!(
matches!(err, Some(ez_ffmpeg::error::Error::FilterGraphParse(_))),
"an unknown filter option must fail build(): {err:?}"
);
}
#[test]
fn dangling_input_label_still_fails_build() {
let err = FfmpegContext::builder()
.input(video_input())
.filter_desc("[nolabel]scale=64:64[out]")
.output(Output::from(tmp_path("dangling.mp4").as_str()))
.build()
.err();
assert!(
err.is_some(),
"a dangling input link label must fail build()"
);
}
#[test]
fn too_many_input_labels_still_fail_build() {
let err = FfmpegContext::builder()
.input(video_input())
.filter_desc("[0:v][1:v]hflip[out]")
.output(Output::from(tmp_path("arity.mp4").as_str()))
.build()
.err();
assert!(
matches!(err, Some(ez_ffmpeg::error::Error::FilterGraphParse(_))),
"more labels than pads must fail build(): {err:?}"
);
}
#[test]
fn existing_movie_source_still_builds_and_runs() {
let fixture = tmp_path("movie_fixture.mp4");
run_to_result(
FfmpegContext::builder()
.input(Input::from("color=c=blue:s=32x32:r=15:d=0.2").set_format("lavfi"))
.output(Output::from(fixture.as_str()).set_video_codec("mpeg4"))
.build()
.expect("fixture build"),
30,
)
.expect("fixture job");
let ctx = FfmpegContext::builder()
.input(video_input())
.filter_desc(format!(
"movie={}[wm];[0:v][wm]overlay[out]",
graph_path(&fixture)
))
.output(Output::from(tmp_path("movie_ok_out.mp4").as_str()).set_video_codec("mpeg4"))
.build()
.expect("movie graph with an existing source must build");
run_to_result(ctx, 30).expect("movie graph with an existing source must run to completion");
}
#[test]
fn split_overlay_graph_still_builds_and_runs() {
let ctx = FfmpegContext::builder()
.input(video_input())
.filter_desc("[0:v]split[a][b];[a]hflip[x];[x][b]overlay[out]")
.output(Output::from(tmp_path("split_overlay.mp4").as_str()).set_video_codec("mpeg4"))
.build()
.expect("split/overlay graph must build");
run_to_result(ctx, 30).expect("split/overlay graph must run to completion");
}
#[test]
fn amix_graph_still_builds_and_runs() {
let ctx = FfmpegContext::builder()
.input(audio_input())
.input(audio_input())
.filter_desc("[0:a][1:a]amix=inputs=2[out]")
.output(Output::from(tmp_path("amix.wav").as_str()).set_audio_codec("pcm_s16le"))
.build()
.expect("amix graph must build");
run_to_result(ctx, 30).expect("amix graph must run to completion");
}