135 lines
5.7 KiB
Rust
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}");
|
|
}
|