Files
filmcraft/crates/gpu/tests/export_pipeline.rs
Manuel Camacho 2b0f263418 GPU3: render exports on the GPU compositor (#30)
Add off-screen GPU export renderer (filmcraft-gpu::ExportRenderer) reading back
the linear float accumulator as premultiplied RGBA f32, plug it into the
export pipeline via FrameRenderer hook and GpuRendering setting, register
in desktop app and CLI, and record export.gpu_frames statistics.

🤖 Completed by agy (Antigravity Agent, Gemini 3.8 Flash) based on draft by Merlin
2026-10-09 06:18:17 -07:00

135 lines
5.7 KiB
Rust

//! End-to-end GPU export parity: the registered `filmcraft-gpu` frame renderer drives the full
//! export pipeline (`filmcraft_export::export` → PNG sequence) and its frames must match the
//! `GpuRendering::Off` (CPU reference) frames within the GPU-vs-CPU parity tolerance — p99 of the
//! per-pixel max RGB difference over black ≤ 6 and mean < 1.5 (the golden-test criterion).
use filmcraft_export::{ExportSettings, Format, FrameRenderer, GpuRenderStats, GpuRendering, Progress, export, register_frame_renderer};
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{Generator, MediaSource};
use filmcraft_project::{ItemKind, Label, MediaClip, MediaRef, SequenceSettings, TrackKind};
use filmcraft_render::SourceMap;
use filmcraft_time::{TICKS_PER_SECOND, TimeRange};
use std::sync::Arc;
/// The same wrapper the app and CLI register: `filmcraft-gpu`'s renderer behind
/// `filmcraft_export`'s trait.
struct GpuFrameRenderer(filmcraft_gpu::ExportRenderer);
impl FrameRenderer for GpuFrameRenderer {
fn render(
&mut self,
project: &filmcraft_project::Project,
seq: filmcraft_project::ItemId,
t: filmcraft_time::Tick,
opts: filmcraft_render::RenderOptions,
sources: &dyn filmcraft_render::SourceProvider,
) -> Option<filmcraft_render::Image> {
self.0.render(project, seq, t, opts, sources)
}
}
fn project(width: u32, height: u32) -> (Arc<filmcraft_project::Project>, filmcraft_project::ItemId, SourceMap) {
let mut p = filmcraft_project::Project::new("x");
let g = GeneratorSource::new(
Generator::ColorMatte { color: [0.2, 0.5, 0.9, 1.0] },
320,
180,
filmcraft_time::FrameRate::FPS_24,
filmcraft_time::Tick(2 * TICKS_PER_SECOND),
);
let info = g.info().clone();
let red = p.add_item(
&info.name.clone(),
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(g.generator.clone()),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
let seq = p.new_sequence("s", SequenceSettings { width, height, frame_rate: filmcraft_time::FrameRate::FPS_24, ..Default::default() }, 1, 1, None);
let v = p
.make_track_item(
red,
TrackKind::Video,
filmcraft_time::Tick::ZERO,
TimeRange::new(filmcraft_time::Tick::ZERO, filmcraft_time::FrameRate::FPS_24.tick_of(2)),
filmcraft_time::FrameRate::FPS_24,
)
.unwrap();
p.sequence_mut(seq).unwrap().video_tracks[0].items.push(v);
let mut m = SourceMap::default();
m.0.insert(red, Arc::new(g));
(Arc::new(p), seq, m)
}
fn png(dir: &str, name: &str) -> Vec<u8> {
// image_sequence_path flattens the path stem: <stem><index>.png in the temp dir itself.
let index: u64 = name.trim_start_matches("img").trim_end_matches(".png").parse().unwrap_or(0);
std::fs::read(std::env::temp_dir().join(format!("fc-gpu-export-{dir}{index:03}.png"))).unwrap()
}
fn export_png(dir: &str, gpu: GpuRendering, (w, h): (u32, u32)) {
let (p, seq, m) = project(w, h);
let mut s = ExportSettings {
format: Format::PngSequence,
path: std::env::temp_dir().join(format!("fc-gpu-export-{dir}")).to_string_lossy().into_owned(),
..Default::default()
};
s.range = Some(TimeRange::new(filmcraft_time::Tick::ZERO, filmcraft_time::FrameRate::FPS_24.tick_of(2)));
s.gpu_rendering = gpu;
export(&p, seq, &s, &m, &Progress::default()).unwrap();
}
fn assert_parity(cpu_png: &[u8], gpu_png: &[u8], what: &str) {
let a = image::load_from_memory(cpu_png).unwrap().to_rgba8();
let b = image::load_from_memory(gpu_png).unwrap().to_rgba8();
assert_eq!(a.dimensions(), b.dimensions(), "{what}: size mismatch");
let d: Vec<u32> = a
.as_raw()
.as_chunks::<4>()
.0
.iter()
.zip(b.as_raw().as_chunks::<4>().0)
.map(|(x, y)| (0..3).map(|k| (x[k] as u32).abs_diff(y[k] as u32)).max().unwrap_or(0))
.collect();
let mut d = d;
d.sort_unstable();
let p99 = d[d.len() * 99 / 100];
let mean = d.iter().sum::<u32>() as f64 / d.len() as f64;
eprintln!("{what}: GPU vs CPU p99 {p99}, mean {mean:.3}");
assert!(p99 <= 6 && mean < 1.5, "{what}: GPU parity p99 {p99}, mean {mean:.3}");
}
#[test]
fn gpu_export_matches_cpu_and_counts() {
let Some(_) = filmcraft_gpu::ExportRenderer::new() else {
eprintln!("SKIPPED (gpu export pipeline): no adapter");
return;
};
// Clean registry first: Auto with no factory must be byte-equal to Off.
export_png("off-cpu", GpuRendering::Off, (320, 180));
export_png("off-auto", GpuRendering::Auto, (320, 180));
assert_eq!(png("off-cpu", "img000.png"), png("off-auto", "img000.png"), "Auto with no registered renderer must be byte-equal to Off");
register_frame_renderer(|| Some(Box::new(GpuFrameRenderer(filmcraft_gpu::ExportRenderer::new().expect("adapter present")))));
let sizes = [(320u32, 180u32), (17, 13), (1, 1), (257, 129)];
for (i, size) in sizes.into_iter().enumerate() {
export_png("cpu", GpuRendering::Off, size);
export_png("gpu", GpuRendering::Auto, size);
for f in ["000", "001"] {
assert_parity(&png("cpu", &format!("img{f}.png")), &png("gpu", &format!("img{f}.png")), &format!("{:?} frame {f}", size));
}
let _ = i;
}
let GpuRenderStats { frames, .. } = filmcraft_export::gpu_render_stats();
assert!(frames >= 8, "gpu frames counted across the parity exports: {frames}");
}