use ez_ffmpeg::frame_export::{ColorPolicy, FrameExtractor, PixelLayout, Sampling};
use ez_ffmpeg::Input;
fn lavfi(spec: &str) -> Input {
Input::from(spec).set_format("lavfi")
}
#[test]
fn extracts_all_frames_rgb24_tightly_packed() {
let frames = FrameExtractor::new(lavfi("testsrc2=s=64x48:r=10:d=1"))
.pixel(PixelLayout::Rgb24)
.collect_frames()
.expect("extraction failed");
assert_eq!(frames.len(), 10, "1s @ 10fps => 10 frames");
for (i, f) in frames.iter().enumerate() {
assert_eq!(f.width(), 64);
assert_eq!(f.height(), 48);
assert_eq!(f.layout(), PixelLayout::Rgb24);
assert_eq!(f.as_bytes().len(), 64 * 48 * 3, "tight RGB24 packing");
assert_eq!(f.row_bytes(), 64 * 3);
assert_eq!(f.index() as usize, i, "indices are dense and 0-based");
}
let pts: Vec<i64> = frames.iter().filter_map(|f| f.pts_us()).collect();
assert_eq!(pts.len(), frames.len(), "every frame has a pts");
assert!(
pts.windows(2).all(|w| w[0] < w[1]),
"pts strictly increasing"
);
}
#[test]
fn max_frames_never_overshoots() {
let frames = FrameExtractor::new(lavfi("testsrc2=s=32x32:r=30:d=5"))
.max_frames(7)
.collect_frames()
.expect("extraction failed");
assert_eq!(frames.len(), 7, "the sink owns the exact cap");
}
#[test]
fn every_nth_selects_expected_count() {
let frames = FrameExtractor::new(lavfi("testsrc2=s=32x32:r=10:d=1"))
.sampling(Sampling::EveryNth(3))
.collect_frames()
.expect("extraction failed");
assert_eq!(frames.len(), 4);
assert!(frames
.iter()
.enumerate()
.all(|(i, f)| f.index() as usize == i));
}
#[test]
fn every_sec_thins_to_grid() {
let frames = FrameExtractor::new(lavfi("testsrc2=s=32x32:r=10:d=4"))
.sampling(Sampling::EverySec(1.0))
.collect_frames()
.expect("extraction failed");
assert!(
(3..=5).contains(&frames.len()),
"expected ~4 one-per-second frames, got {}",
frames.len()
);
}
#[test]
fn resize_and_gray8() {
let frames = FrameExtractor::new(lavfi("testsrc2=s=128x96:r=10:d=1"))
.width(64)
.height(48)
.pixel(PixelLayout::Gray8)
.max_frames(3)
.collect_frames()
.expect("extraction failed");
assert_eq!(frames.len(), 3);
for f in &frames {
assert_eq!((f.width(), f.height()), (64, 48));
assert_eq!(f.layout(), PixelLayout::Gray8);
assert_eq!(f.as_bytes().len(), 64 * 48, "Gray8 is 1 byte/pixel");
}
}
#[test]
fn rgba32_has_alpha_width() {
let frames = FrameExtractor::new(lavfi("testsrc2=s=16x16:r=10:d=1"))
.pixel(PixelLayout::Rgba32)
.max_frames(1)
.collect_frames()
.expect("extraction failed");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].as_bytes().len(), 16 * 16 * 4);
}
#[test]
fn no_video_stream_is_typed_error() {
let err = FrameExtractor::new(lavfi("sine=frequency=440:duration=1"))
.frames()
.err()
.expect("should fail with no video stream");
match err {
ez_ffmpeg::error::Error::FrameExport(
ez_ffmpeg::frame_export::FrameExportError::NoVideoStream,
) => {}
other => panic!("expected NoVideoStream, got {other:?}"),
}
}
fn expect_signal(
rx: &std::sync::mpsc::Receiver<()>,
scenario: std::thread::JoinHandle<()>,
secs: u64,
hang_msg: &str,
) -> std::thread::JoinHandle<()> {
use std::sync::mpsc::RecvTimeoutError;
match rx.recv_timeout(std::time::Duration::from_secs(secs)) {
Ok(()) => scenario,
Err(RecvTimeoutError::Timeout) => panic!("{hang_msg}"),
Err(RecvTimeoutError::Disconnected) => match scenario.join() {
Err(panic) => std::panic::resume_unwind(panic),
Ok(()) => unreachable!("thread cannot exit cleanly without signalling"),
},
}
}
#[test]
fn drop_mid_stream_does_not_deadlock() {
let (tx, rx) = std::sync::mpsc::channel();
let scenario = std::thread::spawn(move || {
let mut it = FrameExtractor::new(lavfi("testsrc2=s=320x240:r=30:d=10"))
.channel_capacity(1)
.frames()
.expect("start failed");
let first = it.next().expect("at least one frame").expect("frame ok");
assert_eq!(first.index(), 0);
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = tx.send(());
drop(it);
let _ = tx.send(());
});
let scenario = expect_signal(
&rx,
scenario,
60,
"frame scenario did not reach its drop point within 60s",
);
let scenario = expect_signal(
&rx,
scenario,
30,
"dropping FrameIter mid-stream did not tear down within 30s (deadlock)",
);
scenario.join().expect("scenario thread must exit cleanly");
}
#[test]
fn iterator_is_fused_after_completion() {
let mut it = FrameExtractor::new(lavfi("testsrc2=s=16x16:r=10:d=1"))
.max_frames(2)
.frames()
.expect("start failed");
let mut count = 0;
while let Some(item) = it.next() {
item.expect("frame ok");
count += 1;
}
assert_eq!(count, 2);
assert!(it.next().is_none());
assert!(it.next().is_none());
}
fn encode_mkv_fixture(path: &str) {
use ez_ffmpeg::{FfmpegContext, FfmpegScheduler, Output};
FfmpegScheduler::new(
FfmpegContext::builder()
.input(lavfi("testsrc2=s=64x48:r=25:d=2"))
.output(Output::from(path).set_video_codec("mpeg2video"))
.build()
.expect("build fixture"),
)
.start()
.and_then(|s| s.wait())
.expect("encode fixture");
}
#[test]
fn pts_us_is_exact_on_real_container_time_base() {
let dir = std::env::temp_dir().join(format!("ez_fe_pts_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mkv = dir.join("t.mkv");
let mkv = mkv.to_str().unwrap();
encode_mkv_fixture(mkv);
let frames = FrameExtractor::new(mkv)
.max_frames(30)
.collect_frames()
.expect("extract");
let p25 = frames.get(25).and_then(|f| f.pts_us());
assert!(
p25.map(|v| (v - 1_000_000).abs() < 100_000)
.unwrap_or(false),
"frame 25 of a 25 fps stream must sit near 1s, got {p25:?}"
);
let sec = FrameExtractor::new(mkv)
.sampling(Sampling::EverySec(1.0))
.collect_frames()
.expect("everysec");
assert!(
(1..=3).contains(&sec.len()),
"EverySec(1.0) over 2s must select ~2 frames, got {}",
sec.len()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolution_guess_multi_video_best_not_first_requires_index() {
use ez_ffmpeg::{FfmpegContext, FfmpegScheduler, Output};
let dir = std::env::temp_dir().join(format!("ez_fe_guess_mv_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("two_video.mkv");
FfmpegScheduler::new(
FfmpegContext::builder()
.input(lavfi("testsrc2=s=64x48:r=10:d=1"))
.filter_desc("[0:v]split=2[t][a];[t]fps=2[b]")
.output(
Output::from(path.to_str().unwrap())
.set_video_codec("mpeg2video")
.add_stream_map("[b]")
.add_stream_map("[a]"),
)
.build()
.expect("build sparse+dense two-video fixture"),
)
.start()
.and_then(|s| s.wait())
.expect("encode sparse+dense two-video fixture");
let path = path.to_str().unwrap();
let err = FrameExtractor::new(path)
.color(ColorPolicy::TaggedOrResolutionGuess)
.frames()
.err()
.expect("guess policy without an index must be rejected on this layout");
match err {
ez_ffmpeg::error::Error::FrameExport(
ez_ffmpeg::frame_export::FrameExportError::InvalidOption(msg),
) => {
assert!(
msg.contains("video_stream_index"),
"error must name the fix: {msg}"
);
}
other => panic!("expected typed InvalidOption, got {other:?}"),
}
let frames = FrameExtractor::new(path)
.color(ColorPolicy::TaggedOrResolutionGuess)
.video_stream_index(1)
.collect_frames()
.expect("explicit index works with the guess policy");
assert!(!frames.is_empty());
let frames = FrameExtractor::new(path)
.collect_frames()
.expect("default Tagged policy must still work on this layout");
assert!(!frames.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
fn encode_fixture(desc: &str, path: &str, codec: &str, codec_opts: &[(&str, &str)]) {
use ez_ffmpeg::{FfmpegContext, FfmpegScheduler, Output};
let mut output = Output::from(path).set_video_codec(codec);
for (k, v) in codec_opts {
output = output.set_video_codec_opt(*k, *v);
}
FfmpegScheduler::new(
FfmpegContext::builder()
.input(lavfi(desc))
.output(output)
.build()
.expect("build fixture"),
)
.start()
.and_then(|s| s.wait())
.expect("encode fixture");
}
#[test]
fn keyframes_only_selects_exactly_the_keyframes() {
use ez_ffmpeg::packet_scanner::PacketScanner;
use ez_ffmpeg::stream_info::StreamInfo;
let dir = std::env::temp_dir().join(format!("ez_fe_kf_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("gop10.mkv");
let path = path.to_str().unwrap();
encode_fixture(
"testsrc2=s=64x48:r=10:d=3",
path,
"mpeg4",
&[("g", "10"), ("sc_threshold", "1000000000"), ("bf", "0")],
);
let mut scanner = PacketScanner::open(path).expect("open fixture");
let time_base = match scanner.video_stream().expect("fixture has video") {
StreamInfo::Video { time_base, .. } => *time_base,
other => panic!("expected a video stream, got {other:?}"),
};
let mut key_pts_us = Vec::new();
let mut total_packets = 0usize;
for packet in scanner.packets() {
let info = packet.expect("read packet");
if !info.is_video() {
continue;
}
total_packets += 1;
if info.is_keyframe() {
let pts = info.pts().expect("video packet pts");
key_pts_us.push(pts * 1_000_000 * time_base.num as i64 / time_base.den as i64);
}
}
assert_eq!(total_packets, 30, "3s @ 10fps fixture must hold 30 frames");
assert_eq!(key_pts_us.len(), 3, "GOP 10 => keyframes at frames 0/10/20");
for (got, want) in key_pts_us.iter().zip([0i64, 1_000_000, 2_000_000]) {
assert!(
(got - want).abs() <= 20_000,
"fixture keyframe at {got}us, expected ~{want}us"
);
}
let frames = FrameExtractor::new(path)
.sampling(Sampling::KeyframesOnly)
.collect_frames()
.expect("extraction failed");
assert_eq!(
frames.len(),
key_pts_us.len(),
"exactly one exported frame per container keyframe"
);
for (i, (f, want)) in frames.iter().zip(&key_pts_us).enumerate() {
assert_eq!(f.index() as usize, i, "indices are dense and 0-based");
let got = f.pts_us().expect("exported frame pts");
assert!(
(got - want).abs() <= 20_000,
"exported keyframe {i} at {got}us must match container keyframe at {want}us"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn keyframes_only_intra_only_source_keeps_every_frame() {
let dir = std::env::temp_dir().join(format!("ez_fe_kf_intra_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("intra.mkv");
let path = path.to_str().unwrap();
encode_fixture("testsrc2=s=32x32:r=10:d=1", path, "mjpeg", &[]);
let frames = FrameExtractor::new(path)
.sampling(Sampling::KeyframesOnly)
.collect_frames()
.expect("extraction failed");
assert_eq!(
frames.len(),
10,
"every frame of an intra-only 1s @ 10fps stream is a keyframe"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn out_of_bounds_video_stream_index_is_typed_error() {
let err = FrameExtractor::new(lavfi("testsrc2=s=16x16:r=10:d=1"))
.video_stream_index(99)
.frames()
.err()
.expect("index 99 on a single-stream input must fail");
match err {
ez_ffmpeg::error::Error::FrameExport(
ez_ffmpeg::frame_export::FrameExportError::StreamIndexOutOfBounds { index, count },
) => {
assert_eq!(index, 99);
assert_eq!(count, 1, "the lavfi source exposes exactly one stream");
}
other => panic!("expected StreamIndexOutOfBounds, got {other:?}"),
}
}
#[test]
fn video_index_pointing_at_audio_stream_is_typed_error() {
let err = FrameExtractor::new(lavfi("sine=frequency=440:duration=1"))
.video_stream_index(0)
.frames()
.err()
.expect("video extraction from an audio stream must fail");
match err {
ez_ffmpeg::error::Error::FrameExport(
ez_ffmpeg::frame_export::FrameExportError::NotAVideoStream { index },
) => assert_eq!(index, 0),
other => panic!("expected NotAVideoStream, got {other:?}"),
}
}
#[test]
fn start_and_duration_window_bounds_output() {
let dir = std::env::temp_dir().join(format!("ez_fe_window_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("window.mkv");
let path = path.to_str().unwrap();
encode_fixture("testsrc2=s=64x48:r=10:d=2", path, "mjpeg", &[]);
let all = FrameExtractor::new(path)
.collect_frames()
.expect("full extraction");
assert_eq!(all.len(), 20, "2s @ 10fps must yield 20 frames unwindowed");
assert_ne!(
all[0].as_bytes(),
all[5].as_bytes(),
"fixture frames at 0s and 0.5s must differ for the seek check below"
);
let frames = FrameExtractor::new(path)
.start_time_us(500_000)
.duration_us(1_000_000)
.collect_frames()
.expect("windowed extraction");
assert_eq!(
frames.len(),
10,
"0.5s..1.5s of a 10fps intra-only stream is exactly 10 frames"
);
assert_eq!(
frames[0].as_bytes(),
all[5].as_bytes(),
"first windowed frame must be the source frame at 0.5s"
);
let pts: Vec<i64> = frames.iter().filter_map(|f| f.pts_us()).collect();
assert_eq!(pts.len(), frames.len(), "every windowed frame has a pts");
assert!(
pts.windows(2).all(|w| w[0] < w[1]),
"windowed pts strictly increasing: {pts:?}"
);
assert!(
pts.iter().all(|&p| (0..1_000_000).contains(&p)),
"every windowed pts must fall inside the 1s duration window: {pts:?}"
);
let first = *pts.first().unwrap();
let last = *pts.last().unwrap();
assert!(
(0..=20_000).contains(&first),
"first frame must sit at the window start, got {first}us"
);
assert!(
(880_000..=920_000).contains(&last),
"last frame must sit at ~0.9s, got {last}us"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_sec_with_start_time_skips_gop_lead_in() {
use ez_ffmpeg::{FfmpegContext, FfmpegScheduler, Output};
let dir = std::env::temp_dir().join(format!("ez_fe_sec_gop_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("gop.mkv");
FfmpegScheduler::new(
FfmpegContext::builder()
.input(lavfi("testsrc2=s=16x16:r=10:d=3"))
.output(
Output::from(path.to_str().unwrap())
.set_video_codec("mpeg2video")
.set_video_codec_opt("g", "30"),
)
.build()
.expect("build GOP fixture"),
)
.start()
.and_then(|s| s.wait())
.expect("encode GOP fixture");
let frames = FrameExtractor::new(path.to_str().unwrap())
.sampling(Sampling::EverySec(0.5))
.start_time_us(1_500_000)
.collect_frames()
.expect("extraction");
assert_eq!(frames.len(), 3, "got {:?}", pts_list(&frames));
let pts = pts_list(&frames);
assert!(
pts.iter().all(|&t| t >= 0),
"no pre-start frame may be selected: {pts:?}"
);
assert!(
pts.windows(2).all(|w| w[1] - w[0] >= 400_000),
"selections must follow the 0.5 s grid: {pts:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn pts_list(frames: &[ez_ffmpeg::frame_export::VideoFrame]) -> Vec<i64> {
frames.iter().filter_map(|f| f.pts_us()).collect()
}
#[test]
fn sparse_sampling_multi_video_requires_explicit_index() {
use ez_ffmpeg::{FfmpegContext, FfmpegScheduler, Output};
let dir = std::env::temp_dir().join(format!("ez_fe_sparse_mv_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("two_video.mkv");
FfmpegScheduler::new(
FfmpegContext::builder()
.input(lavfi("testsrc2=s=16x16:r=10:d=0.5"))
.filter_desc("[0:v]split=2[a][b]")
.output(
Output::from(path.to_str().unwrap())
.set_video_codec("mpeg2video")
.add_stream_map("[a]")
.add_stream_map("[b]"),
)
.build()
.expect("build two-video fixture"),
)
.start()
.and_then(|s| s.wait())
.expect("encode two-video fixture");
let err = FrameExtractor::new(path.to_str().unwrap())
.sampling(Sampling::EveryNth(3))
.frames()
.err()
.expect("ambiguous multi-video EveryNth must be rejected");
assert!(
matches!(
err,
ez_ffmpeg::error::Error::FrameExport(
ez_ffmpeg::frame_export::FrameExportError::InvalidOption(_)
)
),
"got {err:?}"
);
let frames = FrameExtractor::new(path.to_str().unwrap())
.sampling(Sampling::EveryNth(3))
.video_stream_index(0)
.collect_frames()
.expect("explicit index works");
assert_eq!(frames.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn every_sec_respects_forced_framerate_clock() {
use ez_ffmpeg::{FfmpegContext, FfmpegScheduler, Output};
let dir = std::env::temp_dir().join(format!("ez_fe_forced_fps_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cfr10.mkv");
FfmpegScheduler::new(
FfmpegContext::builder()
.input(lavfi("testsrc2=s=16x16:r=10:d=1"))
.output(Output::from(path.to_str().unwrap()).set_video_codec("mpeg2video"))
.build()
.expect("build fixture"),
)
.start()
.and_then(|s| s.wait())
.expect("encode fixture");
let frames = FrameExtractor::new(Input::from(path.to_str().unwrap()).set_framerate(20, 1))
.sampling(Sampling::EverySec(0.15))
.collect_frames()
.expect("extraction");
assert!(
(3..=5).contains(&frames.len()),
"forced-framerate EverySec must follow the rewritten clock, got {} frames",
frames.len()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn non_positive_input_recording_time_is_rejected() {
let err = FrameExtractor::new(Input::from("missing.mp4").set_recording_time_us(0))
.frames()
.err()
.expect("zero recording time must be rejected");
assert!(
matches!(
err,
ez_ffmpeg::error::Error::FrameExport(
ez_ffmpeg::frame_export::FrameExportError::InvalidOption(_)
)
),
"got {err:?}"
);
}