#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use ff_filter::{FilterError, FilterGraph, ScaleAlgorithm};
use ff_format::{PixelFormat, PooledBuffer, Timestamp, VideoFrame};
fn make_yuv_frame(width: u32, height: u32, y: u8, u: u8, v: u8) -> VideoFrame {
let y_plane = vec![y; (width * height) as usize];
let u_plane = vec![u; ((width / 2) * (height / 2)) as usize];
let v_plane = vec![v; ((width / 2) * (height / 2)) as usize];
VideoFrame::new(
vec![
PooledBuffer::standalone(y_plane),
PooledBuffer::standalone(u_plane),
PooledBuffer::standalone(v_plane),
],
vec![width as usize, (width / 2) as usize, (width / 2) as usize],
width,
height,
PixelFormat::Yuv420p,
Timestamp::default(),
true,
)
.unwrap()
}
fn saturated_frame() -> VideoFrame {
make_yuv_frame(64, 64, 128, 200, 60)
}
fn has_filter(name: &str, args: &str) -> bool {
let Ok(mut graph) = FilterGraph::builder().raw_filter(name, args).build() else {
return false;
};
graph.push_video(0, &saturated_frame()).is_ok()
}
fn plane_mean(frame: &VideoFrame, plane: usize, w: usize, h: usize) -> f64 {
let data = frame.plane(plane).expect("plane must exist");
let stride = frame.stride(plane).expect("stride must exist");
let mut sum = 0.0_f64;
for row in 0..h {
let start = row * stride;
sum += data[start..start + w]
.iter()
.map(|&b| f64::from(b))
.sum::<f64>();
}
#[allow(clippy::cast_precision_loss)]
{
sum / (w * h) as f64
}
}
fn assert_desaturated(frame: &VideoFrame, w: u32, h: u32) {
assert_eq!(
frame.format(),
PixelFormat::Yuv420p,
"the plane arithmetic below assumes yuv420p output"
);
let (cw, ch) = ((w as usize).div_ceil(2), (h as usize).div_ceil(2));
for (plane, label) in [(1usize, "U"), (2, "V")] {
let mean = plane_mean(frame, plane, cw, ch);
assert!(
(mean - 128.0).abs() < 1.0,
"the {label} plane must be neutral after hue=s=0, mean was {mean}"
);
}
}
#[test]
fn parse_desc_should_build_a_working_graph_from_a_chain_description() {
if !has_filter("scale", "64:48") || !has_filter("hue", "s=0") {
println!("skipping: this FFmpeg build lacks scale or hue");
return;
}
let mut graph =
FilterGraph::parse_desc("scale=64:48,hue=s=0").expect("a valid description must build");
graph
.push_video(0, &saturated_frame())
.expect("FFmpeg must accept the parsed chain");
let out = graph
.pull_video()
.expect("pull_video must not fail")
.expect("expected a frame out of the parsed chain");
assert_eq!(out.width(), 64, "scale=64:48 must set the width");
assert_eq!(out.height(), 48, "scale=64:48 must set the height");
assert_desaturated(&out, 64, 48);
}
#[test]
fn parse_desc_should_accept_a_branching_description() {
if !has_filter("hue", "s=0") {
println!("skipping: this FFmpeg build lacks hue");
return;
}
let mut graph = FilterGraph::parse_desc("split[a][b];[a]hue=s=0[c];[b][c]overlay")
.expect("a valid branching description must build");
graph
.push_video(0, &saturated_frame())
.expect("FFmpeg must accept the parsed branching graph");
let out = graph
.pull_video()
.expect("pull_video must not fail")
.expect("expected a frame out of the branching description");
assert_eq!(out.width(), 64, "overlay must not change the width");
assert_eq!(out.height(), 64, "overlay must not change the height");
assert_desaturated(&out, 64, 64);
}
#[test]
fn parse_desc_should_compose_with_typed_steps_in_one_chain() {
if !has_filter("scale", "64:48") || !has_filter("hue", "s=0") {
println!("skipping: this FFmpeg build lacks scale or hue");
return;
}
let mut graph = FilterGraph::builder()
.scale(64, 48, ScaleAlgorithm::Fast)
.parse_desc("hue=s=0")
.build()
.expect("a typed step followed by a description must build");
graph
.push_video(0, &saturated_frame())
.expect("FFmpeg must accept the mixed graph");
let out = graph
.pull_video()
.expect("pull_video must not fail")
.expect("expected a frame out of the mixed graph");
assert_eq!(out.width(), 64, "the typed scale step must have run");
assert_eq!(out.height(), 48, "the typed scale step must have run");
assert_desaturated(&out, 64, 48);
}
#[test]
fn parse_desc_should_reject_an_unknown_filter_at_build_time() {
if !has_filter("null", "") {
println!("skipping: this FFmpeg build has no filters registered");
return;
}
let result = FilterGraph::parse_desc("no_such_filter_xyz");
let Err(FilterError::InvalidConfig { reason }) = result else {
panic!("an unknown filter must fail as InvalidConfig at build time, got {result:?}");
};
assert!(
reason.contains("no_such_filter_xyz"),
"the error must name the offending description, got {reason:?}"
);
}
#[test]
fn parse_desc_should_reject_a_bad_option_at_build_time() {
if !has_filter("hue", "s=0") {
println!("skipping: this FFmpeg build lacks hue");
return;
}
for desc in ["hue=nosuchopt=1", "hue=s=notanumber"] {
let result = FilterGraph::parse_desc(desc);
let Err(FilterError::InvalidConfig { reason }) = result else {
panic!("a bad option in {desc:?} must fail at build time, got {result:?}");
};
assert!(
reason.contains(desc),
"the error must name the offending description, got {reason:?}"
);
}
assert!(
FilterGraph::builder()
.raw_filter("hue", "nosuchopt=1")
.build()
.is_ok(),
"raw_filter must still defer its option check to the first push; if this \
starts failing, the two hatches no longer differ and the docs saying so are wrong"
);
}
#[test]
fn parse_desc_should_reject_a_description_that_is_not_one_in_one_out() {
if !has_filter("split", "1") {
println!("skipping: this FFmpeg build has no split filter");
return;
}
let result = FilterGraph::parse_desc("split");
let Err(FilterError::InvalidConfig { reason }) = result else {
panic!("a two-output description must fail as InvalidConfig, got {result:?}");
};
assert!(
reason.contains("split") && reason.contains("but has 1 and 2"),
"the error must name the description and report the pad counts, got {reason:?}"
);
}