use ez_ffmpeg::error::{Error, MuxingOperationError};
use ez_ffmpeg::stream_info::{find_audio_stream_info, find_video_stream_info, StreamInfo};
use ez_ffmpeg::{FfmpegContext, FfmpegScheduler, Input, Output};
use std::time::Duration;
fn tmp_path(name: &str) -> String {
let dir = std::env::temp_dir().join(format!(
"ez_ffmpeg_tail_frame_tests_{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().into_owned()
}
fn lavfi_video() -> Input {
Input::from("color=c=black:s=320x240:r=30").set_format("lavfi")
}
fn lavfi_audio() -> Input {
Input::from("sine=frequency=440:sample_rate=44100").set_format("lavfi")
}
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")
}
}
}
fn video_nb_frames(path: &str) -> i64 {
match find_video_stream_info(path)
.expect("failed to probe output")
.expect("output has no video stream")
{
StreamInfo::Video { nb_frames, .. } => nb_frames,
other => panic!("expected video stream info, got {other:?}"),
}
}
#[test]
fn max_frames_keeps_encoder_buffered_tail() {
let out = tmp_path("max_frames_bframes.mp4");
let scheduler = FfmpegContext::builder()
.input(lavfi_video())
.output(
Output::from(out.as_str())
.set_video_codec("mpeg4")
.set_video_codec_opt("bf", "2")
.set_max_video_frames(10),
)
.build()
.unwrap()
.start()
.unwrap();
let result = wait_with_watchdog(scheduler, 60, "max_frames with B-frames");
assert!(result.is_ok(), "max_frames task failed: {result:?}");
let frames = video_nb_frames(&out);
assert_eq!(
frames, 10,
"output must contain exactly max_video_frames frames; \
a smaller count means the encoder's buffered tail was dropped"
);
}
#[test]
fn recording_time_keeps_encoder_buffered_tail() {
let out = tmp_path("recording_time_bframes.mp4");
let scheduler = FfmpegContext::builder()
.input(lavfi_video())
.output(
Output::from(out.as_str())
.set_video_codec("mpeg4")
.set_video_codec_opt("bf", "2")
.set_recording_time_us(500_000),
)
.build()
.unwrap()
.start()
.unwrap();
let result = wait_with_watchdog(scheduler, 60, "recording_time with B-frames");
assert!(result.is_ok(), "recording_time task failed: {result:?}");
let frames = video_nb_frames(&out);
assert_eq!(
frames, 15,
"output must contain every frame below the recording_time limit; \
a smaller count means the encoder's buffered tail was dropped"
);
}
#[test]
fn deferred_write_header_failure_returns_err() {
let out = tmp_path("write_header_fail.mp4");
let scheduler = FfmpegContext::builder()
.input(lavfi_video())
.output(Output::from(out.as_str()).set_video_codec("rawvideo"))
.build()
.unwrap()
.start()
.unwrap();
let result = wait_with_watchdog(scheduler, 60, "write_header failure");
match result {
Err(Error::Muxing(MuxingOperationError::WriteHeader(_))) => {}
other => panic!(
"write_header failure must surface as Err(Muxing(WriteHeader)), got {other:?}"
),
}
}
fn assert_video_stream_empty_or_absent(path: &str, scenario: &str) {
match find_video_stream_info(path).expect("failed to probe output") {
None => {}
Some(StreamInfo::Video { nb_frames, .. }) => assert_eq!(
nb_frames, 0,
"scenario `{scenario}`: video stream should be empty"
),
Some(other) => panic!("expected video stream info, got {other:?}"),
}
}
#[test]
fn zero_frame_stream_completes_like_cli() {
let fixture = tmp_path("fixture_zero_frames.mp4");
FfmpegContext::builder()
.input(lavfi_video())
.output(
Output::from(fixture.as_str())
.set_video_codec("mpeg4")
.set_recording_time_us(500_000),
)
.build()
.unwrap()
.start()
.unwrap()
.wait()
.unwrap();
let out = tmp_path("zero_frames_out.mp4");
let scheduler = FfmpegContext::builder()
.input(Input::from(fixture.as_str()))
.filter_desc("select=0")
.output(Output::from(out.as_str()).set_video_codec("mpeg4"))
.build()
.unwrap()
.start()
.unwrap();
let result = wait_with_watchdog(scheduler, 60, "zero-frame stream");
assert!(
result.is_ok(),
"zero-frame stream must complete like FFmpeg CLI (empty output, success), got {result:?}"
);
assert!(
std::fs::metadata(&out).is_ok(),
"an (empty) output file must still be written"
);
assert_video_stream_empty_or_absent(&out, "zero-frame stream");
}
#[test]
fn zero_frame_sibling_does_not_hang_healthy_stream() {
let fixture = tmp_path("fixture_av.mp4");
FfmpegContext::builder()
.input(lavfi_video())
.input(lavfi_audio())
.output(
Output::from(fixture.as_str())
.set_video_codec("mpeg4")
.set_audio_codec("aac")
.set_recording_time_us(500_000),
)
.build()
.unwrap()
.start()
.unwrap()
.wait()
.unwrap();
let out = tmp_path("sibling_out.mp4");
let scheduler = FfmpegContext::builder()
.input(Input::from(fixture.as_str()))
.filter_desc("select=0")
.output(
Output::from(out.as_str())
.set_video_codec("mpeg4")
.set_audio_codec("aac"),
)
.build()
.unwrap()
.start()
.unwrap();
let result = wait_with_watchdog(scheduler, 60, "zero-frame sibling");
assert!(
result.is_ok(),
"a zero-frame sibling stream must not fail or hang the healthy stream, got {result:?}"
);
match find_audio_stream_info(&out)
.expect("failed to probe output")
.expect("audio stream missing from output")
{
StreamInfo::Audio { nb_frames, .. } => assert!(
nb_frames > 0,
"healthy audio stream must reach the output, got nb_frames={nb_frames}"
),
other => panic!("expected audio stream info, got {other:?}"),
}
assert_video_stream_empty_or_absent(&out, "zero-frame sibling");
}