use ez_ffmpeg::core::context::output::VSyncMethod;
use ez_ffmpeg::stream_info::{find_video_stream_info, StreamInfo};
use ez_ffmpeg::{AVRational, FfmpegContext, FfmpegScheduler, Input, Output};
use std::time::Duration;
fn tmp_path(name: &str) -> String {
let dir =
std::env::temp_dir().join(format!("ez_ffmpeg_video_sync_tests_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().into_owned()
}
fn wait_with_watchdog(
scheduler: FfmpegScheduler<ez_ffmpeg::core::scheduler::ffmpeg_scheduler::Running>,
secs: u64,
scenario: &str,
) -> ez_ffmpeg::error::Result<()> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(scheduler.wait());
});
match rx.recv_timeout(Duration::from_secs(secs)) {
Ok(result) => result,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
panic!("scenario `{scenario}` did not finish within {secs}s (hang)")
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
panic!("scenario `{scenario}`: wait() thread panicked before reporting")
}
}
}
#[test]
fn fpsmax_caps_only_framerates_above_the_cap() {
let fixture = tmp_path("fpsmax_20_fixture.mp4");
let result = wait_with_watchdog(
FfmpegContext::builder()
.input(Input::from("color=c=black:s=320x240:r=20").set_format("lavfi"))
.output(
Output::from(fixture.as_str())
.set_video_codec("mpeg4")
.set_max_video_frames(30),
)
.build()
.unwrap()
.start()
.unwrap(),
60,
"fpsmax fixture 20fps",
);
assert!(result.is_ok(), "fixture task failed: {result:?}");
let out = tmp_path("fpsmax_20_out.mp4");
let result = wait_with_watchdog(
FfmpegContext::builder()
.input(Input::from(fixture.as_str()))
.output(
Output::from(out.as_str())
.set_video_codec("mpeg4")
.set_framerate_max(AVRational { num: 30, den: 1 }),
)
.build()
.unwrap()
.start()
.unwrap(),
60,
"fpsmax below cap",
);
assert!(result.is_ok(), "fpsmax task failed: {result:?}");
match find_video_stream_info(&out)
.expect("failed to probe output")
.expect("output has no video stream")
{
StreamInfo::Video { avg_frame_rate, .. } => {
let fps = avg_frame_rate.num as f64 / avg_frame_rate.den as f64;
assert!(
(fps - 20.0).abs() < 0.5,
"a 20fps input under fpsmax 30 must stay 20fps, got {fps}"
);
}
other => panic!("expected video stream info, got {other:?}"),
}
let fixture = tmp_path("fpsmax_60_fixture.mp4");
let result = wait_with_watchdog(
FfmpegContext::builder()
.input(Input::from("color=c=black:s=320x240:r=60").set_format("lavfi"))
.output(
Output::from(fixture.as_str())
.set_video_codec("mpeg4")
.set_max_video_frames(60),
)
.build()
.unwrap()
.start()
.unwrap(),
60,
"fpsmax fixture 60fps",
);
assert!(result.is_ok(), "fixture task failed: {result:?}");
let out = tmp_path("fpsmax_60_out.mp4");
let result = wait_with_watchdog(
FfmpegContext::builder()
.input(Input::from(fixture.as_str()))
.output(
Output::from(out.as_str())
.set_video_codec("mpeg4")
.set_framerate_max(AVRational { num: 30, den: 1 }),
)
.build()
.unwrap()
.start()
.unwrap(),
60,
"fpsmax above cap",
);
assert!(result.is_ok(), "fpsmax task failed: {result:?}");
match find_video_stream_info(&out)
.expect("failed to probe output")
.expect("output has no video stream")
{
StreamInfo::Video { avg_frame_rate, .. } => {
let fps = avg_frame_rate.num as f64 / avg_frame_rate.den as f64;
assert!(
(fps - 30.0).abs() < 0.5,
"a 60fps input under fpsmax 30 must be capped to 30fps, got {fps}"
);
}
other => panic!("expected video stream info, got {other:?}"),
}
}
#[test]
fn vscfr_fills_gaps_but_keeps_initial_offset() {
let fixture = tmp_path("vscfr_fixture.mp4");
let result = wait_with_watchdog(
FfmpegContext::builder()
.input(Input::from("color=c=black:s=320x240:r=30").set_format("lavfi"))
.output(
Output::from(fixture.as_str())
.set_video_codec("mpeg4")
.set_max_video_frames(30),
)
.build()
.unwrap()
.start()
.unwrap(),
60,
"vscfr fixture",
);
assert!(result.is_ok(), "fixture task failed: {result:?}");
let out = tmp_path("vscfr_out.mp4");
let result = wait_with_watchdog(
FfmpegContext::builder()
.input(Input::from(fixture.as_str()))
.filter_desc("select='not(mod(n,2))',setpts=PTS+2/TB")
.output(
Output::from(out.as_str())
.set_video_codec("mpeg4")
.set_framerate(AVRational { num: 30, den: 1 })
.set_vsync_method(VSyncMethod::VsyncVscfr),
)
.build()
.unwrap()
.start()
.unwrap(),
60,
"vscfr conversion",
);
assert!(result.is_ok(), "vscfr task failed: {result:?}");
match find_video_stream_info(&out)
.expect("failed to probe output")
.expect("output has no video stream")
{
StreamInfo::Video {
nb_frames,
start_time,
time_base,
..
} => {
assert_eq!(
nb_frames, 29,
"vscfr must fill mid-stream gaps like CFR \
(15 kept frames + 14 duplicates, ffmpeg CLI parity)"
);
let start_secs = start_time as f64 * time_base.num as f64 / time_base.den as f64;
assert!(
(start_secs - 2.0).abs() < 0.1,
"vscfr must keep the initial 2s offset instead of \
duplicating frames back to ts 0, got start {start_secs}s"
);
}
other => panic!("expected video stream info, got {other:?}"),
}
}
#[test]
fn fpsmax_with_an_explicit_non_cfr_vsync_is_rejected() {
let out = tmp_path("fpsmax_vfr_reject.mp4");
let result = FfmpegContext::builder()
.input(Input::from("color=c=black:s=320x240:r=60").set_format("lavfi"))
.output(
Output::from(out.as_str())
.set_video_codec("mpeg4")
.set_framerate_max(AVRational { num: 30, den: 1 })
.set_vsync_method(VSyncMethod::VsyncVfr),
)
.build();
assert!(
result.is_err(),
"framerate_max with an explicit VFR vsync must be rejected"
);
}