use crate::core::filter::frame_filter_context::FrameFilterContext;
use crate::filter::frame_filter::FrameFilter;
use crate::wgpu_filter::shaders;
use crate::wgpu_filter::wgpu_frame_filter::WgpuFrameFilter;
use ffmpeg_next::Frame;
use ffmpeg_sys_next::{av_frame_get_buffer, AVPixelFormat};
use std::collections::HashMap;
fn make_ctx(map: &mut HashMap<String, Box<dyn std::any::Any + Send>>) -> FrameFilterContext<'_> {
FrameFilterContext::new("wgpu_test", map)
}
fn make_planar_frame(w: i32, h: i32, fmt: AVPixelFormat, const_luma: Option<u8>, pts: i64) -> Frame {
let (sub_x, sub_y) = match fmt {
AVPixelFormat::AV_PIX_FMT_YUV420P | AVPixelFormat::AV_PIX_FMT_YUVJ420P => (2usize, 2usize),
AVPixelFormat::AV_PIX_FMT_YUV422P => (2, 1),
AVPixelFormat::AV_PIX_FMT_YUV444P => (1, 1),
other => panic!("unsupported test format {other:?}"),
};
unsafe {
let mut frame = Frame::empty();
let p = frame.as_mut_ptr();
(*p).width = w;
(*p).height = h;
(*p).format = fmt as i32;
assert!(av_frame_get_buffer(p, 1) >= 0);
(*p).pts = pts;
(*p).time_base = ffmpeg_sys_next::AVRational { num: 1, den: 30 };
let ls_y = (*p).linesize[0] as usize;
let y = std::slice::from_raw_parts_mut((*p).data[0], ls_y * h as usize);
for row in 0..h as usize {
for col in 0..w as usize {
y[row * ls_y + col] =
const_luma.unwrap_or(((16 + row * 2 + col) % 220 + 16) as u8);
}
}
let cw = (w as usize).div_ceil(sub_x);
let ch = (h as usize).div_ceil(sub_y);
for plane in 1..=2 {
let ls = (*p).linesize[plane] as usize;
let data = std::slice::from_raw_parts_mut((*p).data[plane], ls * ch);
for row in 0..ch {
for col in 0..cw {
data[row * ls + col] = 128;
}
}
}
frame
}
}
fn make_yuv420p_frame(w: i32, h: i32) -> Frame {
make_planar_frame(w, h, AVPixelFormat::AV_PIX_FMT_YUV420P, None, 0)
}
fn make_marker_frame(pts: i64) -> Frame {
unsafe {
let mut frame = Frame::empty();
(*frame.as_mut_ptr()).pts = pts;
frame
}
}
fn init_filter(filter: &mut WgpuFrameFilter) -> bool {
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
match filter.init(&ctx) {
Ok(()) => true,
Err(e) if e.contains("adapter") || e.contains("device") => {
eprintln!("skipping wgpu test (no GPU): {e}");
false
}
Err(e) => panic!("init failed: {e}"),
}
}
fn drive(filter: &mut WgpuFrameFilter, inputs: Vec<Frame>, expected: usize) -> Vec<Frame> {
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
let mut out = Vec::new();
for frame in inputs {
if let Some(f) = filter.filter_frame(frame, &ctx).expect("filter_frame") {
out.push(f);
}
while let Some(f) = filter.request_frame(&ctx).expect("request_frame") {
out.push(f);
}
}
for _ in 0..2000 {
while let Some(f) = filter.request_frame(&ctx).expect("request_frame") {
out.push(f);
}
if out.len() >= expected {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert_eq!(out.len(), expected, "expected {expected} outputs");
out
}
fn max_luma_diff(out: &Frame, expected: &Frame, w: usize, h: usize) -> i32 {
unsafe {
let o = out.as_ptr();
let e = expected.as_ptr();
let ls_o = (*o).linesize[0] as usize;
let ls_e = (*e).linesize[0] as usize;
let od = std::slice::from_raw_parts((*o).data[0], ls_o * h);
let ed = std::slice::from_raw_parts((*e).data[0], ls_e * h);
let mut max_diff = 0i32;
for row in 0..h {
for col in 0..w {
let d = (od[row * ls_o + col] as i32 - ed[row * ls_e + col] as i32).abs();
max_diff = max_diff.max(d);
}
}
max_diff
}
}
#[test]
fn test_identity_roundtrip() {
let mut filter = WgpuFrameFilter::new_identity().unwrap();
if !init_filter(&mut filter) {
return;
}
let (w, h) = (322, 182); let expected = make_yuv420p_frame(w, h);
let out = drive(&mut filter, vec![make_yuv420p_frame(w, h)], 1)
.pop()
.unwrap();
unsafe {
assert_eq!((*out.as_ptr()).width, w);
assert_eq!((*out.as_ptr()).height, h);
assert_eq!((*out.as_ptr()).format, AVPixelFormat::AV_PIX_FMT_YUV420P as i32);
}
let diff = max_luma_diff(&out, &expected, w as usize, h as usize);
assert!(diff <= 3, "luma max diff too large: {diff}");
}
#[test]
fn test_yuv444p_and_yuv422p_roundtrip() {
for fmt in [
AVPixelFormat::AV_PIX_FMT_YUV444P,
AVPixelFormat::AV_PIX_FMT_YUV422P,
] {
let mut filter = WgpuFrameFilter::new_identity().unwrap();
if !init_filter(&mut filter) {
return;
}
let (w, h) = (321, 181);
let expected = make_planar_frame(w, h, fmt, None, 0);
let out = drive(&mut filter, vec![make_planar_frame(w, h, fmt, None, 0)], 1)
.pop()
.unwrap();
unsafe {
assert_eq!((*out.as_ptr()).width, w);
assert_eq!((*out.as_ptr()).height, h);
assert_eq!((*out.as_ptr()).format, AVPixelFormat::AV_PIX_FMT_YUV420P as i32);
}
let diff = max_luma_diff(&out, &expected, w as usize, h as usize);
assert!(diff <= 3, "{fmt:?} luma max diff too large: {diff}");
}
}
#[test]
fn test_async_ordering_and_eof_flush() {
let mut filter = WgpuFrameFilter::new_identity().unwrap(); if !init_filter(&mut filter) {
return;
}
let lumas = [40u8, 90, 140, 190];
let mut inputs: Vec<Frame> = lumas
.iter()
.enumerate()
.map(|(i, &l)| make_planar_frame(64, 48, AVPixelFormat::AV_PIX_FMT_YUV420P, Some(l), i as i64))
.collect();
inputs.push(make_marker_frame(100));
let out = drive(&mut filter, inputs, 5);
unsafe {
for (i, frame) in out.iter().enumerate().take(4) {
let p = frame.as_ptr();
assert_eq!((*p).pts, i as i64, "output order broken at {i}");
let luma = *(*p).data[0];
let want = lumas[i] as i32;
assert!(
(luma as i32 - want).abs() <= 3,
"frame {i}: luma {luma}, want ~{want}"
);
}
let marker = out[4].as_ptr();
assert_eq!((*marker).pts, 100);
assert!((*marker).data[0].is_null(), "marker must stay props-only");
}
}
#[test]
fn test_eof_marker_drains_in_flight_without_polling() {
let mut filter = WgpuFrameFilter::new_identity().unwrap(); if !init_filter(&mut filter) {
return;
}
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
let mut out = Vec::new();
for i in 0..3i64 {
let frame = make_planar_frame(
1280,
720,
AVPixelFormat::AV_PIX_FMT_YUV420P,
Some(40 + 40 * i as u8),
i,
);
if let Some(f) = filter.filter_frame(frame, &ctx).expect("filter_frame") {
out.push(f);
}
}
if let Some(f) = filter
.filter_frame(make_marker_frame(100), &ctx)
.expect("marker filter_frame")
{
out.push(f);
}
while let Some(f) = filter.request_frame(&ctx).expect("request_frame") {
out.push(f);
}
assert_eq!(
out.len(),
4,
"in-flight frames or the EOF marker were dropped at EOF"
);
unsafe {
for (i, frame) in out.iter().enumerate().take(3) {
assert_eq!((*frame.as_ptr()).pts, i as i64, "order broken at {i}");
}
let marker = out[3].as_ptr();
assert_eq!((*marker).pts, 100);
assert!((*marker).data[0].is_null(), "marker must stay props-only");
}
}
#[test]
fn test_size_change_midstream() {
let mut filter = WgpuFrameFilter::new_identity().unwrap();
if !init_filter(&mut filter) {
return;
}
let sizes = [(320, 180), (322, 182), (320, 180)];
let inputs: Vec<Frame> = sizes
.iter()
.enumerate()
.map(|(i, &(w, h))| {
make_planar_frame(
w,
h,
AVPixelFormat::AV_PIX_FMT_YUV420P,
Some(60 + 60 * i as u8),
i as i64,
)
})
.collect();
let out = drive(&mut filter, inputs, 3);
unsafe {
for (i, frame) in out.iter().enumerate() {
let p = frame.as_ptr();
assert_eq!((*p).pts, i as i64);
assert_eq!(((*p).width, (*p).height), sizes[i], "size mismatch at {i}");
let luma = *(*p).data[0];
let want = 60 + 60 * i as i32;
assert!(
(luma as i32 - want).abs() <= 3,
"frame {i}: luma {luma}, want ~{want}"
);
}
}
}
#[test]
fn test_frames_in_flight_one_is_synchronous() {
let mut filter = WgpuFrameFilter::builder()
.shader_wgsl(shaders::IDENTITY_FS)
.frames_in_flight(1)
.build()
.unwrap();
if !init_filter(&mut filter) {
return;
}
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
for i in 0..2 {
let out = filter
.filter_frame(make_yuv420p_frame(160, 90), &ctx)
.expect("filter_frame");
assert!(out.is_some(), "sync mode must return its own frame ({i})");
}
}
#[test]
fn test_oversized_frame_rejected() {
let mut filter = WgpuFrameFilter::new_identity().unwrap();
if !init_filter(&mut filter) {
return;
}
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
let frame = make_planar_frame(8200, 16, AVPixelFormat::AV_PIX_FMT_YUV420P, Some(60), 0);
let err = match filter.filter_frame(frame, &ctx) {
Err(e) => e,
Ok(_) => panic!("oversized frame must be rejected"),
};
assert!(
err.contains("maximum texture dimension"),
"unexpected error: {err}"
);
}
#[test]
fn test_builder_rejects_bad_frames_in_flight() {
for n in [0usize, 5] {
let result = WgpuFrameFilter::builder()
.shader_wgsl(shaders::IDENTITY_FS)
.frames_in_flight(n)
.build();
assert!(result.is_err(), "frames_in_flight({n}) must be rejected");
}
}
#[test]
fn test_output_resize() {
let mut filter = WgpuFrameFilter::builder()
.shader_wgsl(shaders::IDENTITY_FS)
.output_size(160, 90)
.build()
.unwrap();
if !init_filter(&mut filter) {
return;
}
let out = drive(&mut filter, vec![make_yuv420p_frame(320, 180)], 1)
.pop()
.unwrap();
unsafe {
assert_eq!((*out.as_ptr()).width, 160);
assert_eq!((*out.as_ptr()).height, 90);
}
}
#[test]
fn test_rejects_unsupported_and_hw_formats() {
let mut filter = WgpuFrameFilter::new_identity().unwrap();
if !init_filter(&mut filter) {
return;
}
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
unsafe {
let mut rgb = Frame::empty();
let p = rgb.as_mut_ptr();
(*p).width = 64;
(*p).height = 64;
(*p).format = AVPixelFormat::AV_PIX_FMT_RGB24 as i32;
assert!(av_frame_get_buffer(p, 1) >= 0);
let err = match filter.filter_frame(rgb, &ctx) {
Err(e) => e,
Ok(_) => panic!("RGB24 input must be rejected"),
};
assert!(err.contains("format=yuv420p"), "unexpected error: {err}");
let mut hw = make_yuv420p_frame(64, 64);
(*hw.as_mut_ptr()).format = AVPixelFormat::AV_PIX_FMT_CUDA as i32;
let err = match filter.filter_frame(hw, &ctx) {
Err(e) => e,
Ok(_) => panic!("a fake hardware frame must fail the download"),
};
assert!(
err.contains("download hardware frame"),
"unexpected error: {err}"
);
}
}
#[test]
fn test_params_handle_size_check() {
let filter = WgpuFrameFilter::builder()
.shader_wgsl(shaders::IDENTITY_FS)
.params(1.0f32)
.build()
.unwrap();
assert!(filter.params_handle::<f32>().is_ok());
assert!(filter.params_handle::<[f32; 4]>().is_err());
}
#[test]
fn test_params_shader_size_mismatch_fails_at_init() {
let shader = r#"
@group(0) @binding(0) var texture1: texture_2d<f32>;
@group(0) @binding(1) var sampler1: sampler;
struct EzUniforms { play_time: f32, width: f32, height: f32, _pad: f32 };
@group(0) @binding(2) var<uniform> ez: EzUniforms;
struct BigParams { a: vec4<f32>, b: vec4<f32> };
@group(1) @binding(0) var<uniform> params: BigParams;
@fragment
fn fs_main(@location(0) tex_coord: vec2<f32>) -> @location(0) vec4<f32> {
return textureSample(texture1, sampler1, tex_coord) + params.a * 0.0;
}
"#;
let mut filter = WgpuFrameFilter::builder()
.shader_wgsl(shader)
.params(1.0f32)
.build()
.unwrap();
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
match filter.init(&ctx) {
Err(e) if e.contains("adapter") || e.contains("device") => {
eprintln!("skipping wgpu test (no GPU): {e}");
}
Err(e) => assert!(
e.contains("pipeline") || e.contains("binding"),
"expected binding-contract diagnostics, got: {e}"
),
Ok(()) => panic!("init must fail when shader params exceed provided buffer"),
}
}
#[test]
fn test_bad_shader_fails_at_init_with_diagnostics() {
let mut filter = WgpuFrameFilter::new_simple("not valid wgsl at all").unwrap();
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
match filter.init(&ctx) {
Err(e) if e.contains("adapter") || e.contains("device") => {
eprintln!("skipping wgpu test (no GPU): {e}");
}
Err(e) => assert!(
e.contains("shader") || e.contains("Shader") || e.contains("parse"),
"expected shader diagnostics, got: {e}"
),
Ok(()) => panic!("init must fail for invalid WGSL"),
}
}
struct CountingFilter {
seen: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl FrameFilter for CountingFilter {
fn media_type(&self) -> ffmpeg_sys_next::AVMediaType {
ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_VIDEO
}
fn filter_frame(
&mut self,
frame: Frame,
_ctx: &FrameFilterContext,
) -> Result<Option<Frame>, String> {
self.seen
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(Some(frame))
}
}
#[test]
fn test_frame_pipeline_driver_semantics() {
use crate::filter::frame_pipeline::FramePipeline;
use ffmpeg_sys_next::AVMediaType::AVMEDIA_TYPE_VIDEO;
fn poll_all(pipeline: &mut FramePipeline, out: &mut Vec<Frame>) {
for i in 0..pipeline.filter_len() {
loop {
match pipeline.request_frame(i).expect("request_frame") {
Some(f) => {
if let Some(f) =
pipeline.run_filters_from(i + 1, f).expect("run_filters_from")
{
out.push(f);
}
}
None => break,
}
}
}
}
let filter = WgpuFrameFilter::builder()
.shader_wgsl(shaders::IDENTITY_FS)
.build()
.expect("builder");
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let mut pipeline = FramePipeline::new(AVMEDIA_TYPE_VIDEO, None);
pipeline.add_filter("wgpu", Box::new(filter));
pipeline.add_filter(
"count",
Box::new(CountingFilter {
seen: std::sync::Arc::clone(&seen),
}),
);
match pipeline.init_filters() {
Ok(()) => {}
Err(e) if e.contains("adapter") || e.contains("device") => {
eprintln!("skipping wgpu test (no GPU): {e}");
return;
}
Err(e) => panic!("init failed: {e}"),
}
let n_real = 4usize;
let mut inputs: Vec<Frame> = (0..n_real)
.map(|i| {
make_planar_frame(
64,
48,
AVPixelFormat::AV_PIX_FMT_YUV420P,
Some((40 + 40 * i) as u8),
i as i64,
)
})
.collect();
inputs.push(make_marker_frame(n_real as i64));
let expected = n_real + 1;
let mut out = Vec::new();
for frame in inputs {
if let Some(f) = pipeline.run_filters(frame).expect("run_filters") {
out.push(f);
}
poll_all(&mut pipeline, &mut out);
}
for _ in 0..2000 {
poll_all(&mut pipeline, &mut out);
if out.len() >= expected {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
pipeline.uninit_filters();
assert_eq!(out.len(), expected, "all frames must drain through the chain");
for (i, f) in out.iter().enumerate() {
assert_eq!(unsafe { (*f.as_ptr()).pts }, i as i64, "arrival order");
}
unsafe {
assert!(
(*out.last().unwrap().as_ptr()).buf[0].is_null(),
"marker must exit last and stay props-only"
);
}
assert_eq!(
seen.load(std::sync::atomic::Ordering::Relaxed),
expected,
"downstream filter must see every drained frame"
);
}
#[test]
fn test_drain_without_marker() {
let mut filter = WgpuFrameFilter::builder()
.shader_wgsl(shaders::IDENTITY_FS)
.build()
.expect("builder");
if !init_filter(&mut filter) {
return;
}
let inputs: Vec<Frame> = (0..4)
.map(|i| {
make_planar_frame(
64,
48,
AVPixelFormat::AV_PIX_FMT_YUV420P,
Some((40 + 40 * i) as u8),
i as i64,
)
})
.collect();
let out = drive(&mut filter, inputs, 4);
for (i, f) in out.iter().enumerate() {
assert_eq!(unsafe { (*f.as_ptr()).pts }, i as i64, "arrival order");
}
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
filter.uninit(&ctx);
}
#[test]
fn test_zero_copy_readback_roundtrip_and_recycle() {
let mut filter = WgpuFrameFilter::builder()
.shader_wgsl(shaders::IDENTITY_FS)
.zero_copy_readback(true)
.build()
.expect("builder");
if !init_filter(&mut filter) {
return;
}
let (w, h) = (322, 182); let expected = make_yuv420p_frame(w, h);
let n = 12usize;
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
let mut seen = 0usize;
for i in 0..n {
let mut frame = make_yuv420p_frame(w, h);
unsafe { (*frame.as_mut_ptr()).pts = i as i64 };
if let Some(out) = filter.filter_frame(frame, &ctx).expect("filter_frame") {
assert_eq!(unsafe { (*out.as_ptr()).pts }, seen as i64, "order");
let diff = max_luma_diff(&out, &expected, w as usize, h as usize);
assert!(diff <= 3, "luma diff too large at frame {seen}: {diff}");
seen += 1;
}
while let Some(out) = filter.request_frame(&ctx).expect("request_frame") {
assert_eq!(unsafe { (*out.as_ptr()).pts }, seen as i64, "order");
seen += 1;
}
}
for _ in 0..2000 {
while let Some(out) = filter.request_frame(&ctx).expect("request_frame") {
assert_eq!(unsafe { (*out.as_ptr()).pts }, seen as i64, "order");
seen += 1;
}
if seen >= n {
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert_eq!(seen, n, "all zero-copy frames must drain");
filter.uninit(&ctx);
}
#[test]
fn test_zero_copy_frame_outlives_filter() {
let mut filter = WgpuFrameFilter::builder()
.shader_wgsl(shaders::IDENTITY_FS)
.zero_copy_readback(true)
.frames_in_flight(1)
.build()
.expect("builder");
if !init_filter(&mut filter) {
return;
}
let (w, h) = (64, 48);
let expected = make_yuv420p_frame(w, h);
let out = drive(&mut filter, vec![make_yuv420p_frame(w, h)], 1)
.pop()
.unwrap();
let mut map = HashMap::new();
let ctx = make_ctx(&mut map);
filter.uninit(&ctx);
drop(filter);
let diff = max_luma_diff(&out, &expected, w as usize, h as usize);
assert!(diff <= 3, "luma diff too large after filter drop: {diff}");
}