#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod fixtures;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use avio::{Clip, EncoderConfig, Timeline, TimelineError};
use ff_decode::VideoDecoder;
use ff_encode::{BitrateMode, VideoCodec};
use ff_filter::XfadeTransition;
use fixtures::{FileGuard, make_source_file, test_output_path};
const SRC_FRAMES: usize = 15;
const CANVAS: u32 = 64;
fn is_environment_unavailable(e: &TimelineError) -> bool {
matches!(
e,
TimelineError::Filter(_) | TimelineError::Encode(_) | TimelineError::Decode(_)
)
}
fn decode_stats(path: &std::path::Path) -> (usize, Option<(u32, u32)>) {
let Ok(mut d) = VideoDecoder::open(path).build() else {
return (0, None);
};
let mut n = 0usize;
let mut dims = None;
while let Ok(Some(f)) = d.decode_one() {
if dims.is_none() {
dims = Some((f.width(), f.height()));
}
n += 1;
}
(n, dims)
}
fn assert_valid_export(path: &std::path::Path, route: &str) {
let (count, dims) = decode_stats(path);
assert!(
(SRC_FRAMES - 1..=SRC_FRAMES + 1).contains(&count),
"{route} export should decode ~{SRC_FRAMES} frames, got {count}"
);
assert_eq!(
dims,
Some((CANVAS, CANVAS)),
"{route} export frames should be the {CANVAS}x{CANVAS} canvas size"
);
}
fn export_config() -> EncoderConfig {
EncoderConfig::builder()
.video_codec(VideoCodec::H264)
.bitrate_mode(BitrateMode::Cbr(800_000))
.build()
}
fn build_timeline(src: &std::path::Path) -> Option<Timeline> {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![Clip::new(src)])
.build()
.ok()
}
fn render_or_skip(result: Result<(), TimelineError>) -> bool {
match result {
Ok(()) => true,
Err(e) if is_environment_unavailable(&e) => false,
Err(e) => panic!("unexpected export error: {e}"),
}
}
fn bright_bbox(rgba: &[u8], w: u32, h: u32) -> Option<(u32, u32, u32, u32)> {
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
let mut any = false;
for y in 0..h {
for x in 0..w {
let i = ((y * w + x) * 4) as usize;
if rgba[i] > 160 && rgba[i + 1] > 160 && rgba[i + 2] > 160 {
any = true;
x0 = x0.min(x);
y0 = y0.min(y);
x1 = x1.max(x);
y1 = y1.max(y);
}
}
}
any.then_some((x0, y0, x1, y1))
}
fn count_and_bbox(path: &std::path::Path, at: usize) -> (usize, Option<(u32, u32, u32, u32)>) {
let Ok(mut d) = VideoDecoder::open(path)
.output_format(ff_format::PixelFormat::Rgba)
.build()
else {
return (0, None);
};
let (mut n, mut bbox) = (0usize, None);
while let Ok(Some(f)) = d.decode_one() {
if n == at {
bbox = f
.to_rgba()
.and_then(|r| bright_bbox(&r, f.width(), f.height()));
}
n += 1;
}
(n, bbox)
}
fn corner_luma(path: &std::path::Path, at: usize) -> Option<f64> {
let mut d = VideoDecoder::open(path)
.output_format(ff_format::PixelFormat::Rgba)
.build()
.ok()?;
let mut n = 0usize;
while let Ok(Some(f)) = d.decode_one() {
if n == at {
let rgba = f.to_rgba()?;
let (w, h) = (f.width(), f.height());
let mut sum = 0f64;
for y in h.saturating_sub(8)..h {
for x in w.saturating_sub(8)..w {
let i = ((y * w + x) * 4) as usize;
sum += f64::from(rgba[i]);
}
}
return Some(sum / 64.0);
}
n += 1;
}
None
}
fn two_track_timeline(base: &std::path::Path, over: &std::path::Path) -> Option<Timeline> {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![Clip::new(base)])
.video_track(vec![
Clip::new(over).with_position(10.0, 4.0).with_scale(0.5),
])
.build()
.ok()
}
fn mean_rgb(path: &std::path::Path) -> Option<(f64, f64, f64)> {
let mut d = VideoDecoder::open(path)
.output_format(ff_format::PixelFormat::Rgba)
.build()
.ok()?;
let f = d.decode_one().ok()??;
let rgba = f.to_rgba()?;
let (mut r, mut g, mut b) = (0f64, 0f64, 0f64);
let n = (rgba.len() / 4) as f64;
for px in rgba.chunks_exact(4) {
r += f64::from(px[0]);
g += f64::from(px[1]);
b += f64::from(px[2]);
}
Some((r / n, g / n, b / n))
}
fn opacity_timeline(base: &std::path::Path, over: &std::path::Path) -> Option<Timeline> {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![Clip::new(base)])
.video_track(vec![Clip::new(over).with_opacity(0.5)])
.build()
.ok()
}
#[cfg(feature = "gpu")]
#[test]
fn an_overlay_should_apply_its_opacity_exactly_once() {
let dark = test_output_path("op_dark.mp4");
let bright = test_output_path("op_bright.mp4");
let _gd = FileGuard::new(dark.clone());
let _gb = FileGuard::new(bright.clone());
if make_source_file(&dark, CANVAS, CANVAS, 30.0, SRC_FRAMES, 40, 128, 128).is_none() {
return;
}
if make_source_file(&bright, CANVAS, CANVAS, 30.0, SRC_FRAMES, 235, 128, 128).is_none() {
return;
}
let out_cpu = test_output_path("op_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = opacity_timeline(&dark, &bright) else {
return;
};
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return;
}
let out_gpu = test_output_path("op_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = opacity_timeline(&dark, &bright) else {
return;
};
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let (Some(cpu), Some(gpu)) = (mean_rgb(&out_cpu), mean_rgb(&out_gpu)) else {
return;
};
println!("opacity once: cpu={cpu:?} gpu={gpu:?}");
assert!(
cpu.0 > 120.0 && cpu.0 < 160.0,
"the control must really be a half-strength overlay, got {cpu:?}"
);
assert!(
(cpu.0 - gpu.0).abs() < 4.0,
"the GPU must apply the overlay's opacity once, like the CPU: cpu={cpu:?} gpu={gpu:?}"
);
}
#[cfg(feature = "gpu")]
#[test]
fn a_base_track_should_apply_its_own_effect_exactly_once_under_an_overlay() {
let base = test_output_path("be_base.mp4");
let over = test_output_path("be_over.mp4");
let _gb = FileGuard::new(base.clone());
let _go = FileGuard::new(over.clone());
if make_source_file(&base, CANVAS, CANVAS, 30.0, SRC_FRAMES, 120, 200, 90).is_none() {
return;
}
if make_source_file(&over, CANVAS, CANVAS, 30.0, SRC_FRAMES, 235, 128, 128).is_none() {
return;
}
let build = || {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![
Clip::new(&base).with_video_effect(ff_filter::FilterStep::Hue { degrees: 60.0 }),
])
.video_track(vec![
Clip::new(&over).with_position(10.0, 4.0).with_scale(0.5),
])
.build()
.ok()
};
let out_cpu = test_output_path("be_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = build() else { return };
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return;
}
let out_gpu = test_output_path("be_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = build() else { return };
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let (Some(cpu), Some(gpu)) = (mean_rgb(&out_cpu), mean_rgb(&out_gpu)) else {
return;
};
println!("base effect once: cpu={cpu:?} gpu={gpu:?}");
let spread = (cpu.0 - cpu.1).abs() + (cpu.1 - cpu.2).abs();
assert!(
spread > 20.0,
"the control must be visibly hue-rotated, or a second rotation would not show: {cpu:?}"
);
assert!(
(cpu.0 - gpu.0).abs() < 12.0
&& (cpu.1 - gpu.1).abs() < 12.0
&& (cpu.2 - gpu.2).abs() < 12.0,
"the base's effect must be applied once, like the CPU: cpu={cpu:?} gpu={gpu:?}"
);
}
#[cfg(feature = "gpu")]
#[test]
fn an_overlay_should_be_stretched_to_the_canvas_the_way_the_cpu_stretches_it() {
let dark = test_output_path("wide_dark.mp4");
let wide = test_output_path("wide_over.mp4");
let _gd = FileGuard::new(dark.clone());
let _gw = FileGuard::new(wide.clone());
if make_source_file(&dark, CANVAS, CANVAS, 30.0, SRC_FRAMES, 40, 128, 128).is_none() {
return;
}
if make_source_file(&wide, CANVAS, CANVAS / 2, 30.0, SRC_FRAMES, 235, 128, 128).is_none() {
return;
}
let build = || {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![Clip::new(&dark)])
.video_track(vec![
Clip::new(&wide).with_position(0.0, 0.0).with_scale(0.5),
])
.build()
.ok()
};
let out_cpu = test_output_path("wide_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = build() else { return };
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return;
}
let out_gpu = test_output_path("wide_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = build() else { return };
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let (_, cpu_box) = count_and_bbox(&out_cpu, 5);
let (_, gpu_box) = count_and_bbox(&out_gpu, 5);
println!("stretched overlay: cpu={cpu_box:?} gpu={gpu_box:?}");
assert_eq!(
cpu_box,
Some((0, 0, 31, 31)),
"the CPU control must stretch the 64x32 overlay into a 32x32 square"
);
assert_eq!(
gpu_box, cpu_box,
"the GPU must stretch the overlay as the CPU does, not letterbox it"
);
}
#[cfg(feature = "gpu")]
#[test]
fn two_track_export_should_match_the_cpu_route() {
let dark = test_output_path("mt_dark.mp4");
let bright = test_output_path("mt_bright.mp4");
let _gd = FileGuard::new(dark.clone());
let _gb = FileGuard::new(bright.clone());
if make_source_file(&dark, CANVAS, CANVAS, 30.0, SRC_FRAMES, 40, 128, 128).is_none() {
return; }
if make_source_file(&bright, CANVAS, CANVAS, 30.0, SRC_FRAMES, 235, 128, 128).is_none() {
return;
}
let out_cpu = test_output_path("mt_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = two_track_timeline(&dark, &bright) else {
return;
};
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return; }
let out_gpu = test_output_path("mt_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = two_track_timeline(&dark, &bright) else {
return;
};
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let (cpu_n, cpu_box) = count_and_bbox(&out_cpu, 5);
let (gpu_n, gpu_box) = count_and_bbox(&out_gpu, 5);
println!("two-track: cpu=({cpu_n}, {cpu_box:?}) gpu=({gpu_n}, {gpu_box:?})");
assert_eq!(cpu_n, gpu_n, "both routes must export the same frame count");
assert_eq!(
cpu_box,
Some((10, 4, 41, 35)),
"the CPU fixture must still land where it was measured"
);
assert_eq!(
gpu_box, cpu_box,
"the GPU must place the overlay where the CPU does"
);
}
#[cfg(feature = "gpu")]
#[test]
fn a_base_track_that_ends_first_should_not_promote_the_overlay() {
let short = test_output_path("mtbase_short.mp4");
let bright = test_output_path("mtbase_bright.mp4");
let _gs = FileGuard::new(short.clone());
let _gb = FileGuard::new(bright.clone());
if make_source_file(&short, CANVAS, CANVAS, 30.0, 6, 40, 128, 128).is_none() {
return;
}
if make_source_file(&bright, CANVAS, CANVAS, 30.0, SRC_FRAMES, 235, 128, 128).is_none() {
return;
}
let out_cpu = test_output_path("mtbase_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = two_track_timeline(&short, &bright) else {
return;
};
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return;
}
let out_gpu = test_output_path("mtbase_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = two_track_timeline(&short, &bright) else {
return;
};
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let (cpu_n, cpu_box) = count_and_bbox(&out_cpu, 10);
let (gpu_n, gpu_box) = count_and_bbox(&out_gpu, 10);
println!("base-ends-first: cpu=({cpu_n}, {cpu_box:?}) gpu=({gpu_n}, {gpu_box:?})");
assert!(
cpu_n > 6,
"the control must outlive the base track, got {cpu_n}"
);
assert_eq!(cpu_n, gpu_n, "both routes must export the same frame count");
assert_eq!(
cpu_box,
Some((10, 4, 41, 35)),
"the CPU must keep the overlay placed after the base ends"
);
assert_eq!(
gpu_box, cpu_box,
"the overlay must not be promoted to base when the base ends"
);
let cpu_corner = corner_luma(&out_cpu, 10);
let gpu_corner = corner_luma(&out_gpu, 10);
println!("base-ends-first corner: cpu={cpu_corner:?} gpu={gpu_corner:?}");
if let (Some(c), Some(g)) = (cpu_corner, gpu_corner) {
assert!(
c < 24.0,
"the CPU control must render the ended base's area black, got {c}"
);
assert!(
(c - g).abs() < 12.0,
"the ended base's area must match the CPU: cpu={c} gpu={g}"
);
}
}
#[cfg(feature = "gpu")]
#[test]
fn a_transition_on_the_base_track_should_render_under_a_live_overlay() {
let dark = test_output_path("bt_dark.mp4");
let mid = test_output_path("bt_mid.mp4");
let bright = test_output_path("bt_bright.mp4");
let _gd = FileGuard::new(dark.clone());
let _gm = FileGuard::new(mid.clone());
let _gb = FileGuard::new(bright.clone());
if make_source_file(&dark, CANVAS, CANVAS, 30.0, SRC_FRAMES, 40, 128, 128).is_none() {
return;
}
if make_source_file(&mid, CANVAS, CANVAS, 30.0, SRC_FRAMES, 90, 128, 128).is_none() {
return;
}
if make_source_file(&bright, CANVAS, CANVAS, 30.0, SRC_FRAMES, 235, 128, 128).is_none() {
return;
}
let build = || {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![
Clip::new(&dark)
.offset(Duration::ZERO)
.trim(Duration::ZERO, Duration::from_millis(400)),
Clip::new(&mid)
.offset(Duration::from_millis(400))
.trim(Duration::ZERO, Duration::from_millis(100))
.with_transition(XfadeTransition::Fade, Duration::from_millis(100)),
])
.video_track(vec![
Clip::new(&bright).with_position(10.0, 4.0).with_scale(0.5),
])
.build()
.ok()
};
let out_cpu = test_output_path("bt_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = build() else { return };
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return;
}
let out_gpu = test_output_path("bt_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = build() else { return };
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let (cpu_n, cpu_box) = count_and_bbox(&out_cpu, 13);
let (gpu_n, gpu_box) = count_and_bbox(&out_gpu, 13);
let cpu_corner = corner_luma(&out_cpu, 13);
let gpu_corner = corner_luma(&out_gpu, 13);
println!(
"base transition under overlay: cpu=({cpu_n}, {cpu_box:?}, {cpu_corner:?}) \
gpu=({gpu_n}, {gpu_box:?}, {gpu_corner:?})"
);
assert_eq!(cpu_n, gpu_n, "both routes must export the same frame count");
assert_eq!(
cpu_box,
Some((10, 4, 41, 35)),
"the CPU control must keep the overlay placed while the base blends"
);
assert_eq!(
gpu_box, cpu_box,
"the overlay must stay placed while the base runs its transition"
);
if let (Some(c), Some(g)) = (cpu_corner, gpu_corner) {
assert!(
(c - g).abs() < 12.0,
"the blending base must match the CPU outside the overlay: cpu={c} gpu={g}"
);
}
}
#[cfg(feature = "gpu")]
#[test]
fn tracks_of_unequal_length_should_end_where_the_cpu_ends() {
let dark = test_output_path("mtlen_dark.mp4");
let short = test_output_path("mtlen_short.mp4");
let _gd = FileGuard::new(dark.clone());
let _gs = FileGuard::new(short.clone());
if make_source_file(&dark, CANVAS, CANVAS, 30.0, SRC_FRAMES, 40, 128, 128).is_none() {
return;
}
if make_source_file(&short, CANVAS, CANVAS, 30.0, 6, 235, 128, 128).is_none() {
return;
}
let out_cpu = test_output_path("mtlen_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = two_track_timeline(&dark, &short) else {
return;
};
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return;
}
let out_gpu = test_output_path("mtlen_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = two_track_timeline(&dark, &short) else {
return;
};
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let (cpu_n, _) = count_and_bbox(&out_cpu, 0);
let (gpu_n, _) = count_and_bbox(&out_gpu, 0);
println!("unequal length: cpu={cpu_n} gpu={gpu_n}");
assert!(
cpu_n < SRC_FRAMES,
"the control must actually be truncated by the short overlay, got {cpu_n}"
);
assert_eq!(
gpu_n, cpu_n,
"the GPU export must end where the CPU export ends"
);
}
#[test]
fn export_should_produce_frames_on_cpu_and_gpu_routes() {
let src = test_output_path("gpuexport_src.mp4");
let _gs = FileGuard::new(src.clone());
if make_source_file(&src, CANVAS, CANVAS, 30.0, SRC_FRAMES, 120, 90, 160).is_none() {
return; }
let out_cpu = test_output_path("gpuexport_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_timeline) = build_timeline(&src) else {
return; };
if !render_or_skip(cpu_timeline.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
assert_valid_export(&out_cpu, "force-CPU");
#[cfg(feature = "gpu")]
{
if avio::GpuCompositor::new().is_none() {
return; }
let out_gpu = test_output_path("gpuexport_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_timeline) = build_timeline(&src) else {
return;
};
if !render_or_skip(gpu_timeline.render(&out_gpu, export_config())) {
return;
}
assert_valid_export(&out_gpu, "GPU");
}
}
#[cfg(feature = "gpu")]
#[test]
fn gpu_export_should_conform_a_slower_source_to_the_timeline_rate() {
const SRC_FPS: f64 = 24.0;
const OUT_FPS: f64 = 30.0;
const SRC_24_FRAMES: usize = 24;
let src = test_output_path("gpuexport_24fps_src.mp4");
let _gs = FileGuard::new(src.clone());
if make_source_file(&src, CANVAS, CANVAS, SRC_FPS, SRC_24_FRAMES, 120, 90, 160).is_none() {
return; }
if avio::GpuCompositor::new().is_none() {
return; }
let (src_frames, _) = decode_stats(&src);
if src_frames == 0 {
return; }
#[allow(
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_truncation
)]
let expected = (src_frames as f64 * OUT_FPS / SRC_FPS).round() as usize;
let Some(timeline) = Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(OUT_FPS)
.video_track(vec![Clip::new(&src)])
.build()
.ok()
else {
return; };
let out = test_output_path("gpuexport_24to30.mp4");
let _gg = FileGuard::new(out.clone());
if !render_or_skip(timeline.render(&out, export_config())) {
return;
}
let (count, dims) = decode_stats(&out);
assert_eq!(
dims,
Some((CANVAS, CANVAS)),
"conformed export frames should be the {CANVAS}x{CANVAS} canvas size"
);
assert!(
count > src_frames,
"conform must add frames: {src_frames} source frames became {count} outputs"
);
assert!(
(expected - 2..=expected + 2).contains(&count),
"a {SRC_FPS} fps source ({src_frames} frames) in a {OUT_FPS} fps timeline should \
export ~{expected} frames, got {count}"
);
}
const TOL_TRANSITION_MEAN: f64 = 6.0;
fn make_structured_source(path: &std::path::Path, frames: usize, phase: u8) -> Option<()> {
use ff_encode::VideoEncoder;
use ff_format::VideoFrame;
let mut enc = VideoEncoder::create(path)
.video(CANVAS, CANVAS, 30.0)
.video_codec(VideoCodec::Mpeg4)
.build()
.ok()?;
for _ in 0..frames {
let mut rgba = vec![0u8; (CANVAS * CANVAS * 4) as usize];
for y in 0..CANVAS {
for x in 0..CANVAS {
let o = ((y * CANVAS + x) * 4) as usize;
rgba[o] = u8::try_from(x * 255 / CANVAS)
.unwrap_or(255)
.wrapping_add(phase);
rgba[o + 1] = u8::try_from(y * 255 / CANVAS).unwrap_or(255);
rgba[o + 2] = 128u8.wrapping_sub(phase);
rgba[o + 3] = 255;
}
}
enc.push_video(&VideoFrame::from_rgba(CANVAS, CANVAS, rgba).ok()?)
.ok()?;
}
enc.finish().ok()?;
Some(())
}
fn decode_rgba(path: &std::path::Path) -> Vec<Vec<u8>> {
let Ok(mut d) = VideoDecoder::open(path)
.output_format(ff_format::PixelFormat::Rgba)
.build()
else {
return Vec::new();
};
let mut out = Vec::new();
while let Ok(Some(f)) = d.decode_one() {
if let Some(plane) = f.plane(0) {
out.push(plane.to_vec());
}
}
out
}
fn mean_abs_diff_rgb(a: &[u8], b: &[u8]) -> f64 {
let n = a.len().min(b.len()) / 4;
if n == 0 {
return f64::MAX;
}
let mut sum = 0f64;
for i in 0..n {
for c in 0..3 {
sum += f64::from(a[i * 4 + c].abs_diff(b[i * 4 + c]));
}
}
#[allow(clippy::cast_precision_loss)]
let denom = (n * 3) as f64;
sum / denom
}
#[cfg(feature = "gpu")]
#[test]
fn gpu_export_should_match_the_cpu_export_for_every_rendered_transition() {
use std::time::Duration;
use ff_filter::XfadeTransition;
const CLIP_FRAMES: usize = 30; const WINDOW: usize = 15; const SOURCE_FRAMES: usize = CLIP_FRAMES + 2 * WINDOW;
let a = test_output_path("gpuexport_tr_a.mp4");
let b = test_output_path("gpuexport_tr_b.mp4");
let _ga = FileGuard::new(a.clone());
let _gb = FileGuard::new(b.clone());
if make_structured_source(&a, SOURCE_FRAMES, 0).is_none()
|| make_structured_source(&b, SOURCE_FRAMES, 100).is_none()
{
return; }
if avio::GpuCompositor::new().is_none() {
return; }
for kind in [
XfadeTransition::Fade,
XfadeTransition::WipeLeft,
XfadeTransition::WipeRight,
XfadeTransition::WipeUp,
XfadeTransition::WipeDown,
XfadeTransition::FadeBlack,
XfadeTransition::FadeWhite,
] {
let build = || {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![
Clip::new(&a).trim(Duration::ZERO, Duration::from_secs(1)),
Clip::new(&b)
.offset(Duration::from_secs(1))
.trim(Duration::ZERO, Duration::from_secs(1))
.with_transition(kind, Duration::from_millis(500)),
])
.build()
.ok()
};
let (Some(gpu_timeline), Some(cpu_timeline)) = (build(), build()) else {
return; };
let out_gpu = test_output_path("gpuexport_tr_gpu.mp4");
let out_cpu = test_output_path("gpuexport_tr_cpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let _gc = FileGuard::new(out_cpu.clone());
if !render_or_skip(gpu_timeline.render(&out_gpu, export_config()))
|| !render_or_skip(cpu_timeline.render_forcing_cpu(&out_cpu, export_config()))
{
return;
}
let gpu = decode_rgba(&out_gpu);
let cpu = decode_rgba(&out_cpu);
if gpu.is_empty() || cpu.is_empty() {
return; }
let expected = CLIP_FRAMES * 2;
assert!(
(expected - 1..=expected + 1).contains(&gpu.len()),
"a {WINDOW}-frame transition must leave the {expected}-frame timeline its \
length, got {}",
gpu.len()
);
assert_eq!(
gpu.len(),
cpu.len(),
"both routes must export the same number of frames"
);
let worst = gpu
.iter()
.zip(cpu.iter())
.enumerate()
.map(|(i, (g, c))| (mean_abs_diff_rgb(g, c), i))
.fold((0f64, 0usize), |acc, x| if x.0 > acc.0 { x } else { acc });
println!("{kind:?}: worst frame {} mean={:.3}", worst.1, worst.0);
assert!(
worst.0 <= TOL_TRANSITION_MEAN,
"{kind:?}: GPU and CPU exports diverged at frame {}: mean={:.3} \
(tolerance {TOL_TRANSITION_MEAN})",
worst.1,
worst.0
);
}
}
#[test]
fn render_with_progress_forcing_cpu_should_report_progress_and_export() {
let src = test_output_path("gpuexport_progress_src.mp4");
let _gs = FileGuard::new(src.clone());
if make_source_file(&src, CANVAS, CANVAS, 30.0, SRC_FRAMES, 60, 140, 200).is_none() {
return;
}
let out = test_output_path("gpuexport_progress_cpu.mp4");
let _go = FileGuard::new(out.clone());
let Some(timeline) = build_timeline(&src) else {
return;
};
let frames = AtomicU32::new(0);
let result = timeline.render_with_progress_forcing_cpu(&out, export_config(), |_p| {
frames.fetch_add(1, Ordering::Relaxed);
true
});
if !render_or_skip(result) {
return;
}
assert!(
frames.load(Ordering::Relaxed) >= 1,
"the force-CPU progress callback must fire at least once"
);
assert_valid_export(&out, "force-CPU (progress)");
}
#[cfg(feature = "gpu")]
#[test]
fn a_positioned_base_clip_should_export_the_same_on_both_routes() {
let bright = test_output_path("placed_base_src.mp4");
let _gs = FileGuard::new(bright.clone());
if make_source_file(&bright, CANVAS, CANVAS, 30.0, SRC_FRAMES, 235, 128, 128).is_none() {
return;
}
let build = || {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![
Clip::new(&bright).with_position(10.0, 4.0).with_scale(0.5),
])
.build()
.ok()
};
let out_cpu = test_output_path("placed_base_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = build() else { return };
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return;
}
let out_gpu = test_output_path("placed_base_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = build() else { return };
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let (cpu_n, cpu_box) = count_and_bbox(&out_cpu, 3);
let (gpu_n, gpu_box) = count_and_bbox(&out_gpu, 3);
println!("placed base: cpu=({cpu_n}, {cpu_box:?}) gpu=({gpu_n}, {gpu_box:?})");
assert_eq!(cpu_n, gpu_n, "both routes must export the same frame count");
assert_eq!(
cpu_box,
Some((10, 4, 41, 35)),
"the CPU control must place the lone clip where it was measured"
);
assert_eq!(
gpu_box, cpu_box,
"the GPU route must place the lone clip like the CPU"
);
}
#[cfg(feature = "gpu")]
#[test]
fn a_rotated_base_clip_should_take_the_cpu_route() {
let bright = test_output_path("rotated_base_src.mp4");
let _gs = FileGuard::new(bright.clone());
if make_source_file(&bright, CANVAS, CANVAS, 30.0, SRC_FRAMES, 235, 128, 128).is_none() {
return;
}
let build = || {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![Clip::new(&bright).with_rotation(45.0)])
.build()
.ok()
};
let out_cpu = test_output_path("rotated_base_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = build() else { return };
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return;
}
let out_gpu = test_output_path("rotated_base_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = build() else { return };
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let cpu_corner = corner_luma(&out_cpu, 3);
let gpu_corner = corner_luma(&out_gpu, 3);
println!("rotated base: cpu corner={cpu_corner:?} gpu corner={gpu_corner:?}");
let (Some(c), Some(g)) = (cpu_corner, gpu_corner) else {
panic!("both routes must decode a sample frame");
};
assert!(
c < 32.0,
"the CPU control must render the rotation's black corner, got {c}"
);
assert!(
(c - g).abs() < 4.0,
"`render` must fall back to the CPU for a rotated base: cpu={c} gpu={g}"
);
}
#[cfg(feature = "gpu")]
#[test]
fn a_placed_base_under_an_overlay_should_export_the_same_on_both_routes() {
let bright = test_output_path("placed_under_overlay_base.mp4");
let dim = test_output_path("placed_under_overlay_over.mp4");
let _gb = FileGuard::new(bright.clone());
let _gd = FileGuard::new(dim.clone());
if make_source_file(&bright, CANVAS, CANVAS, 30.0, SRC_FRAMES, 235, 128, 128).is_none() {
return;
}
if make_source_file(&dim, CANVAS, CANVAS, 30.0, SRC_FRAMES, 90, 128, 128).is_none() {
return;
}
let build = || {
Timeline::builder()
.canvas(CANVAS, CANVAS)
.frame_rate(30.0)
.video_track(vec![
Clip::new(&bright).with_position(10.0, 4.0).with_scale(0.5),
])
.video_track(vec![
Clip::new(&dim).with_position(40.0, 40.0).with_scale(0.25),
])
.build()
.ok()
};
let out_cpu = test_output_path("placed_under_overlay_cpu.mp4");
let _gc = FileGuard::new(out_cpu.clone());
let Some(cpu_t) = build() else { return };
if !render_or_skip(cpu_t.render_forcing_cpu(&out_cpu, export_config())) {
return;
}
if avio::GpuCompositor::new().is_none() {
return;
}
let out_gpu = test_output_path("placed_under_overlay_gpu.mp4");
let _gg = FileGuard::new(out_gpu.clone());
let Some(gpu_t) = build() else { return };
if !render_or_skip(gpu_t.render(&out_gpu, export_config())) {
return;
}
let (cpu_n, cpu_box) = count_and_bbox(&out_cpu, 3);
let (gpu_n, gpu_box) = count_and_bbox(&out_gpu, 3);
println!("placed base under overlay: cpu=({cpu_n}, {cpu_box:?}) gpu=({gpu_n}, {gpu_box:?})");
assert_eq!(cpu_n, gpu_n, "both routes must export the same frame count");
assert_eq!(
cpu_box,
Some((10, 4, 41, 35)),
"the CPU control must place the base once under the overlay"
);
assert_eq!(
gpu_box, cpu_box,
"the GPU stack pass must not place the base a second time"
);
}