filmcraft/crates/golden/tests/golden.rs

599 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Golden-image tests: procedural projects rendered through the CPU compositor and compared with
//! committed reference PNGs (`goldens/*.png`, made by this test with `FILMCRAFT_BLESS=1`), plus
//! GPU-vs-CPU parity on the same scenes when a GPU adapter is available.
//!
//! Tolerances (8-bit sRGB levels, see `filmcraft_testkit::golden`):
//! - CPU vs golden: `Tolerance::RENDER` — PSNR ≥ 45 dB, max abs ≤ 12, 99th percentile ≤ 2.
//! - GPU vs CPU: 99th percentile of the per-pixel max channel difference ≤ 6 and mean < 1.5
//! (antialiased edges and half-float textures differ slightly; the CPU is the reference).
use std::path::PathBuf;
use std::sync::Arc;
use filmcraft_color::{ColorInfo, ColorSpace, Gamut, Primaries, Transfer};
use filmcraft_geom::Vec2;
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{DemoScene, Generator, MediaSource, SharedSource};
use filmcraft_project::{
ClipId, EffectInstance, ItemId, ItemKind, Label, Mask, MaskMode, MaskPath, MediaClip, MediaRef, ParamValue, Project, SequenceSettings, TrackItem,
TrackKind, Transition, TransitionId, find_effect,
};
use filmcraft_render::{RenderOptions, SourceMap, render_sequence};
use filmcraft_testkit::golden::{Rgba8, Tolerance, assert_golden, diff};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange};
const W: u32 = 320;
const H: u32 = 180;
const RATE: FrameRate = FrameRate::FPS_24;
/// A procedurally generated project and the frame to render.
struct Scene {
project: Project,
seq: ItemId,
sources: SourceMap,
frame: i64,
}
struct Builder {
p: Project,
map: SourceMap,
seq: ItemId,
next_transition: u64,
}
impl Builder {
fn new(video_tracks: usize) -> Self {
let mut p = Project::new("golden");
let seq = p.new_sequence("golden", SequenceSettings { width: W, height: H, frame_rate: RATE, ..Default::default() }, video_tracks, 0, None);
Builder { p, map: SourceMap::default(), seq, next_transition: 1 }
}
/// A generated media item (640×360, 24 fps, 10 s).
fn media(&mut self, g: Generator) -> ItemId {
let src = GeneratorSource::new(g, 640, 360, RATE, Tick(10 * TICKS_PER_SECOND));
let info = src.info().clone();
let id = self.p.add_item(
&info.name.clone(),
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(src.generator.clone()),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
self.map.0.insert(id, Arc::new(src) as SharedSource);
id
}
fn demo(&mut self, scene: DemoScene) -> ItemId {
self.media(Generator::Demo(scene))
}
/// Demo footage re-encoded as camera log / HDR (see [`Encoded`]), with an optional Interpret
/// Footage colour-space override.
fn encoded(&mut self, scene: DemoScene, target: ColorSpace, gain: f32, flag: Option<ColorInfo>, interpret: Option<ColorSpace>) -> ItemId {
let id = self.demo(scene);
let inner = self.map.0.remove(&id).unwrap();
self.map.0.insert(id, Arc::new(Encoded { inner, target, gain, flag }) as SharedSource);
if let ItemKind::Media(m) = &mut self.p.item_mut(id).unwrap().kind {
m.interpret.color_space = interpret;
}
id
}
/// Place `item` on video track `track` (frames), scaled to the frame size.
fn place(&mut self, track: usize, item: ItemId, start: i64, dur: i64) -> ClipId {
let mut ti = self.p.make_track_item(item, TrackKind::Video, RATE.tick_of(start), TimeRange::new(Tick::ZERO, RATE.tick_of(dur)), RATE).unwrap();
ti.scale_to_frame = true;
let id = ti.id;
let s = self.p.sequence_mut(self.seq).unwrap();
s.video_tracks[track].items.push(ti);
s.video_tracks[track].sort();
id
}
fn clip(&mut self, id: ClipId) -> &mut TrackItem {
self.p.sequence_mut(self.seq).unwrap().find_item_mut(id).unwrap().1
}
/// Set intrinsic-effect parameters (`motion`, `opacity`).
fn fixed(&mut self, id: ClipId, effect: &str, params: &[(&str, ParamValue)]) {
let e = self.clip(id).effect_mut(effect).unwrap();
set(e, params);
}
/// Append a standard effect with parameters.
fn effect(&mut self, id: ClipId, effect: &str, params: &[(&str, ParamValue)]) {
let mut e = find_effect(effect).unwrap_or_else(|| panic!("no effect {effect}")).instance();
set(&mut e, params);
self.clip(id).effects.push(e);
}
/// A transition of `dur` frames centred on the cut at `cut` between `a` and `b` on `track`.
fn transition(&mut self, track: usize, effect: &str, a: ClipId, b: ClipId, cut: i64, dur: i64) {
let id = TransitionId(self.next_transition);
self.next_transition += 1;
let tr = Transition {
id,
effect: find_effect(effect).unwrap_or_else(|| panic!("no transition {effect}")).instance(),
start: RATE.tick_of(cut - dur / 2),
duration: RATE.tick_of(dur),
from: Some(a),
to: Some(b),
align: Default::default(),
reverse: false,
};
self.p.sequence_mut(self.seq).unwrap().video_tracks[track].transitions.push(tr);
}
/// Add a mask to the clip's effect `effect` (feather/expansion in clip pixels).
fn mask(&mut self, id: ClipId, effect: &str, path: MaskPath, feather: f64, f: impl FnOnce(&mut Mask)) {
let mut m = Mask::new("Mask", path);
m.feather.value = fl(feather);
f(&mut m);
self.clip(id).effect_mut(effect).unwrap_or_else(|| panic!("no effect {effect}")).masks.push(m);
}
fn at(self, frame: i64) -> Scene {
Scene { project: self.p, seq: self.seq, sources: self.map, frame }
}
}
fn set(e: &mut EffectInstance, params: &[(&str, ParamValue)]) {
let id = e.effect.clone();
for (k, v) in params {
e.param_mut(k).unwrap_or_else(|| panic!("{id}: no param {k}")).value = v.clone();
}
}
fn fl(v: f64) -> ParamValue {
ParamValue::Float(v)
}
fn pt(x: f64, y: f64) -> ParamValue {
ParamValue::Vec2(Vec2::new(x, y))
}
fn blend(name: &str) -> ParamValue {
ParamValue::Choice(filmcraft_project::effect::BLEND_MODES.iter().position(|b| *b == name).unwrap_or_else(|| panic!("blend {name}")) as u32)
}
/// A source that re-encodes another source's (sRGB) frames into a camera log or HDR colour space:
/// linear BT.709 × `gain` → `target` gamut → `target` curve, stored as full-range RGBA8. `flag`
/// replaces the frame's colour metadata (e.g. PQ/BT.2020, so auto-detection kicks in).
struct Encoded {
inner: SharedSource,
target: ColorSpace,
gain: f32,
flag: Option<ColorInfo>,
}
impl MediaSource for Encoded {
fn info(&self) -> &filmcraft_media::MediaInfo {
self.inner.info()
}
fn video_frame(&self, req: filmcraft_media::FrameRequest) -> filmcraft_media::Result<Arc<filmcraft_frame::VideoFrame>> {
let f = self.inner.video_frame(req)?;
let rgba = f.to_rgba8();
let m = filmcraft_color::spaces::to_f32(&filmcraft_color::spaces::gamut_matrix(Gamut::Bt709, self.target.gamut()));
let curve = self.target.curve();
let out: Vec<u8> = rgba
.chunks_exact(4)
.flat_map(|p| {
let lin = [0, 1, 2].map(|k| filmcraft_color::srgb_to_linear(p[k] as f32 / 255.0) * self.gain);
let c = filmcraft_color::spaces::apply3(&m, lin);
let e = c
.map(|v| (filmcraft_color::transform::encode_channel(v as f64, curve, filmcraft_color::Range::Full).clamp(0.0, 1.0) * 255.0).round() as u8);
[e[0], e[1], e[2], p[3]]
})
.collect();
let mut nf = filmcraft_frame::VideoFrame::rgba8(f.width, f.height, out);
if let Some(c) = self.flag {
nf.color = c;
}
Ok(Arc::new(nf))
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result<filmcraft_frame::AudioBuffer> {
self.inner.audio(start, frames, sample_rate)
}
}
// ---- scenes ----
/// Motion (scale, rotation, position) and 70 % opacity over a full-frame background.
fn transform_opacity() -> Scene {
let mut b = Builder::new(2);
let bg = b.demo(DemoScene::OceanSunset);
let fg = b.demo(DemoScene::Aurora);
b.place(0, bg, 0, 48);
let top = b.place(1, fg, 0, 48);
b.fixed(top, "motion", &[("scale", fl(45.0)), ("rotation", fl(20.0)), ("position", pt(215.0, 70.0))]);
b.fixed(top, "opacity", &[("opacity", fl(70.0))]);
b.at(12)
}
/// Four quadrant layers over a background in Multiply, Screen, Overlay and Difference.
fn blend_modes() -> Scene {
let mut b = Builder::new(5);
let bg = b.demo(DemoScene::OceanSunset);
let fg = b.demo(DemoScene::Dunes);
b.place(0, bg, 0, 48);
for (i, (mode, x, y)) in [("Multiply", 80.0, 45.0), ("Screen", 240.0, 45.0), ("Overlay", 80.0, 135.0), ("Difference", 240.0, 135.0)].into_iter().enumerate()
{
let c = b.place(i + 1, fg, 0, 48);
b.fixed(c, "motion", &[("scale", fl(50.0)), ("position", pt(x, y))]);
b.fixed(c, "opacity", &[("blend", blend(mode))]);
}
b.at(12)
}
/// Gaussian Blur on colour bars (sharp edges show the kernel).
fn gaussian_blur() -> Scene {
let mut b = Builder::new(1);
let bars = b.media(Generator::BarsAndTone);
let c = b.place(0, bars, 0, 48);
b.effect(c, "gaussian_blur", &[("blurriness", fl(12.0))]);
b.at(12)
}
/// Lumetri Color basic correction: temperature, exposure, contrast, highlights/shadows, saturation.
fn lumetri_basic() -> Scene {
let mut b = Builder::new(1);
let bg = b.demo(DemoScene::OceanSunset);
let c = b.place(0, bg, 0, 48);
b.effect(
c,
"lumetri",
&[
("temperature", fl(25.0)),
("exposure", fl(0.6)),
("contrast", fl(30.0)),
("highlights", fl(-30.0)),
("shadows", fl(25.0)),
("saturation", fl(135.0)),
],
);
b.at(12)
}
/// Crop (left/top/right/bottom) of a top layer revealing the background.
fn crop() -> Scene {
let mut b = Builder::new(2);
let bg = b.demo(DemoScene::OceanSunset);
let bars = b.media(Generator::BarsAndTone);
b.place(0, bg, 0, 48);
let c = b.place(1, bars, 0, 48);
b.effect(c, "crop", &[("left", fl(15.0)), ("top", fl(10.0)), ("right", fl(25.0)), ("bottom", fl(20.0))]);
b.at(12)
}
/// Two clips with a 12-frame transition centred on the cut at frame 24, rendered at `frame`.
fn transition(effect: &str, frame: i64) -> Scene {
let mut b = Builder::new(1);
let a = b.demo(DemoScene::OceanSunset);
let c = b.demo(DemoScene::Aurora);
let ca = b.place(0, a, 0, 24);
let cb = b.place(0, c, 24, 24);
b.transition(0, effect, ca, cb, 24, 12);
b.at(frame)
}
/// Timecode and Clip Name burn-ins (text engine: JetBrains Mono / Inter).
fn text_burnin() -> Scene {
let mut b = Builder::new(1);
let bg = b.demo(DemoScene::CityNight);
let c = b.place(0, bg, 0, 48);
b.effect(c, "timecode", &[("position", pt(160.0, 40.0)), ("size", fl(20.0))]);
b.effect(c, "clip_name", &[("position", pt(160.0, 145.0)), ("size", fl(12.0))]);
b.at(30)
}
/// A graphic clip on V2 over `bg` on V1, with `layers` (graphic layer effect instances).
fn graphic_scene(bg: DemoScene, layers: Vec<EffectInstance>) -> Scene {
let mut b = Builder::new(2);
let bgi = b.demo(bg);
b.place(0, bgi, 0, 48);
let g = b.p.add_item("Graphic", Label::Rose, ItemKind::Graphic { width: W, height: H, rate: RATE }, None);
let c = b.place(1, g, 0, 48);
for mut l in layers {
filmcraft_project::resolve_auto_points(&mut l, (W, H), (W, H));
b.clip(c).effects.push(l);
}
b.at(12)
}
/// A title: bold centred text with an outer stroke and a soft drop shadow, a lower-third bar with
/// rounded corners and a background-boxed caption line.
fn graphic_title() -> Scene {
use filmcraft_project::graphic::{new_shape_layer, new_text_layer};
let mut bar = new_shape_layer(0, Vec2::new(160.0, 150.0), Vec2::new(280.0, 34.0), vec![]);
set(&mut bar, &[("corner_radius", fl(8.0)), ("fill_color", ParamValue::Color([0.12, 0.35, 0.8, 1.0])), ("opacity", fl(85.0))]);
let mut title = new_text_layer("Night Drive", Vec2::new(160.0, 80.0), 44.0);
set(
&mut title,
&[
("font_style", ParamValue::Text("Bold".into())),
("align", ParamValue::Choice(1)),
("stroke", ParamValue::Bool(true)),
("stroke_width", fl(2.5)),
("stroke_color", ParamValue::Color([0.05, 0.05, 0.1, 1.0])),
("shadow", ParamValue::Bool(true)),
("shadow_distance", fl(5.0)),
("shadow_blur", fl(8.0)),
("tracking", fl(40.0)),
],
);
let mut sub = new_text_layer("Directed by Nobody", Vec2::new(160.0, 156.0), 16.0);
set(
&mut sub,
&[
("align", ParamValue::Choice(1)),
("font", ParamValue::Text("Noto Serif".into())),
("faux_italic", ParamValue::Bool(true)),
("caps", ParamValue::Choice(2)),
("fill_color", ParamValue::Color([1.0, 0.92, 0.6, 1.0])),
],
);
graphic_scene(DemoScene::CityNight, vec![bar, title, sub])
}
/// Shapes: ellipse with centre stroke, rotated polygon with inner stroke, a pen path, rotated
/// boxed text with a background and 2 strokes.
fn graphic_shapes() -> Scene {
use filmcraft_project::graphic::{new_shape_layer, new_text_layer};
let mut ell = new_shape_layer(1, Vec2::new(70.0, 60.0), Vec2::new(100.0, 70.0), vec![]);
set(
&mut ell,
&[
("stroke", ParamValue::Bool(true)),
("stroke_width", fl(6.0)),
("stroke_type", ParamValue::Choice(1)),
("stroke_color", ParamValue::Color([1.0, 1.0, 1.0, 1.0])),
],
);
let mut poly = new_shape_layer(2, Vec2::new(250.0, 60.0), Vec2::new(80.0, 80.0), vec![]);
set(
&mut poly,
&[
("sides", fl(5.0)),
("rotation", fl(18.0)),
("fill_color", ParamValue::Color([0.2, 0.8, 0.4, 1.0])),
("stroke", ParamValue::Bool(true)),
("stroke_type", ParamValue::Choice(2)),
("stroke_width", fl(5.0)),
],
);
let path = new_shape_layer(3, Vec2::new(70.0, 140.0), Vec2::new(0.0, 0.0), vec![[-40.0, 20.0], [0.0, -25.0], [40.0, 20.0], [0.0, 5.0]]);
let mut txt = new_text_layer("Rotated\nTwo lines", Vec2::new(215.0, 140.0), 20.0);
set(
&mut txt,
&[
("rotation", fl(-12.0)),
("align", ParamValue::Choice(1)),
("background", ParamValue::Bool(true)),
("background_radius", fl(6.0)),
("background_size", fl(6.0)),
("stroke", ParamValue::Bool(true)),
("stroke_width", fl(1.5)),
("stroke2", ParamValue::Bool(true)),
("stroke2_width", fl(3.5)),
],
);
graphic_scene(DemoScene::Aurora, vec![ell, poly, path, txt])
}
/// Camera log → Rec. 709: Ocean Sunset encoded as S-Log3 / S-Gamut3.Cine one stop over, then
/// interpreted as such in a Rec. 709 sequence (decode, gamut compression, BT.2390 tone mapping).
fn log_to_rec709() -> Scene {
let mut b = Builder::new(1);
let src = b.encoded(DemoScene::OceanSunset, ColorSpace::SLog3SGamut3Cine, 2.0, None, Some(ColorSpace::SLog3SGamut3Cine));
b.place(0, src, 0, 48);
b.at(12)
}
/// HDR → SDR: City Night as Rec. 2100 PQ with highlights up to ~1200 cd/m² (×6 over reference
/// white), detected from the frame's metadata and tone mapped into a Rec. 709 sequence.
fn hdr_tone_map() -> Scene {
let mut b = Builder::new(1);
let pq = ColorInfo { transfer: Transfer::Pq, primaries: Primaries::Bt2020, ..ColorInfo::SRGB_FULL };
let src = b.encoded(DemoScene::CityNight, ColorSpace::Rec2100Pq, 6.0, Some(pq), None);
b.place(0, src, 0, 48);
b.at(12)
}
/// Gaussian Blur limited to a feathered ellipse mask (media is 640×360 clip pixels).
fn mask_blur() -> Scene {
let mut b = Builder::new(1);
let bars = b.media(Generator::BarsAndTone);
let c = b.place(0, bars, 0, 48);
b.effect(c, "gaussian_blur", &[("blurriness", fl(14.0))]);
b.mask(c, "gaussian_blur", MaskPath::ellipse(Vec2::new(320.0, 170.0), Vec2::new(210.0, 120.0)), 60.0, |_| {});
b.at(12)
}
/// Black & White inside a hard 4-point polygon (expanded by 12 px) minus a feathered pen (Bézier)
/// mask: mask modes, expansion and smooth vertices.
fn mask_color() -> Scene {
let mut b = Builder::new(1);
let bg = b.demo(DemoScene::OceanSunset);
let c = b.place(0, bg, 0, 48);
b.effect(c, "black_white", &[]);
let quad = MaskPath::polygon(&[Vec2::new(60.0, 40.0), Vec2::new(560.0, 70.0), Vec2::new(600.0, 320.0), Vec2::new(90.0, 300.0)]);
b.mask(c, "black_white", quad, 0.0, |m| m.expansion.value = fl(12.0));
let mut pen = MaskPath::polygon(&[Vec2::new(250.0, 110.0), Vec2::new(420.0, 160.0), Vec2::new(300.0, 270.0)]);
pen.vertices[0] = filmcraft_project::MaskVertex::smooth(Vec2::new(250.0, 110.0), Vec2::new(60.0, -30.0));
pen.vertices[2] = filmcraft_project::MaskVertex::smooth(Vec2::new(300.0, 270.0), Vec2::new(-80.0, -10.0));
b.mask(c, "black_white", pen, 16.0, |m| m.mode = MaskMode::Subtract);
b.at(12)
}
/// Mosaic outside an ellipse (inverted mask), 80 % mask opacity.
fn mask_inverted() -> Scene {
let mut b = Builder::new(1);
let bg = b.demo(DemoScene::Aurora);
let c = b.place(0, bg, 0, 48);
b.effect(c, "mosaic", &[("horizontal", fl(24.0)), ("vertical", fl(14.0))]);
b.mask(c, "mosaic", MaskPath::ellipse(Vec2::new(300.0, 180.0), Vec2::new(150.0, 110.0)), 8.0, |m| {
m.inverted = true;
m.opacity.value = fl(80.0);
});
b.at(12)
}
/// Opacity mask: a feathered ellipse cut out of a rotated, scaled top layer over a background
/// (the mask is in clip space, so it follows Motion).
fn mask_opacity() -> Scene {
let mut b = Builder::new(2);
let bg = b.demo(DemoScene::OceanSunset);
let fg = b.demo(DemoScene::CityNight);
b.place(0, bg, 0, 48);
let top = b.place(1, fg, 0, 48);
b.fixed(top, "motion", &[("scale", fl(70.0)), ("rotation", fl(-10.0)), ("position", pt(170.0, 95.0))]);
b.mask(top, "opacity", MaskPath::ellipse(Vec2::new(320.0, 180.0), Vec2::new(250.0, 140.0)), 90.0, |_| {});
b.at(12)
}
/// (name, title, scene).
fn scenes() -> Vec<(&'static str, &'static str, fn() -> Scene)> {
vec![
("transform_opacity", "Motion transform and opacity", transform_opacity),
("blend_modes", "Blend modes (Multiply, Screen, Overlay, Difference)", blend_modes),
("gaussian_blur", "Gaussian Blur effect", gaussian_blur),
("lumetri_basic", "Lumetri Color basic correction", lumetri_basic),
("crop", "Crop effect", crop),
("transition_cross_dissolve", "Cross Dissolve at 50%", || transition("cross_dissolve", 24)),
("transition_dip_to_black", "Dip to Black at 25%", || transition("dip_to_black", 21)),
("transition_wipe", "Wipe at 50%", || transition("wipe", 24)),
("text_burnin", "Timecode and Clip Name burn-in text", text_burnin),
("graphic_title", "Graphic clip: title text with stroke and shadow, lower-third bar", graphic_title),
("graphic_shapes", "Graphic clip: ellipse, polygon, path and rotated text with background", graphic_shapes),
("log_to_rec709", "Colour management: S-Log3/S-Gamut3.Cine footage to Rec. 709 (tone mapped)", log_to_rec709),
("hdr_tone_map", "Colour management: Rec. 2100 PQ footage tone mapped into a Rec. 709 sequence", hdr_tone_map),
("mask_blur", "Masks: Gaussian Blur inside a feathered ellipse mask", mask_blur),
("mask_color", "Masks: Black & White inside an expanded polygon minus a feathered Bezier mask", mask_color),
("mask_inverted", "Masks: Mosaic outside an inverted ellipse mask at 80% opacity", mask_inverted),
("mask_opacity", "Masks: feathered ellipse opacity mask on a rotated top layer", mask_opacity),
]
}
fn render_cpu(s: &Scene) -> Rgba8 {
let img = render_sequence(&s.project, s.seq, RATE.tick_of(s.frame), RenderOptions::default(), &s.sources);
Rgba8::new(img.w as u32, img.h as u32, img.over_black_rgba8())
}
fn golden_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("goldens").join(format!("{name}.png"))
}
fn check(name: &str) {
let (_, title, make) = scenes().into_iter().find(|(n, _, _)| *n == name).unwrap();
let img = render_cpu(&make());
assert_eq!((img.w, img.h), (W, H));
let d = assert_golden(&golden_path(name), &img, Tolerance::RENDER, title, &format!("crates/golden/tests/golden.rs ({name})"));
eprintln!("{name}: {d}");
}
macro_rules! goldens {
($($name:ident),* $(,)?) => {
mod cpu {
$(
#[test]
fn $name() {
super::check(stringify!($name));
}
)*
}
#[test]
fn every_scene_has_a_test() {
let tested = [$(stringify!($name)),*];
for (n, _, _) in scenes() {
assert!(tested.contains(&n), "scene {n} has no golden test");
}
}
};
}
goldens!(
transform_opacity,
blend_modes,
gaussian_blur,
lumetri_basic,
crop,
transition_cross_dissolve,
transition_dip_to_black,
transition_wipe,
text_burnin,
graphic_title,
graphic_shapes,
log_to_rec709,
hdr_tone_map,
mask_blur,
mask_color,
mask_inverted,
mask_opacity
);
/// Sanity: the scenes are not trivially empty or identical to each other.
#[test]
fn scenes_are_distinct() {
let imgs: Vec<(&str, Rgba8)> = scenes().into_iter().map(|(n, _, f)| (n, render_cpu(&f()))).collect();
for (n, img) in &imgs {
let lit = img.px.chunks_exact(4).filter(|p| p[0] as u32 + p[1] as u32 + p[2] as u32 > 30).count();
assert!(lit > img.px.len() / 4 / 4, "{n}: mostly black");
}
for i in 0..imgs.len() {
for j in i + 1..imgs.len() {
let d = diff(&imgs[i].1, &imgs[j].1).unwrap();
assert!(d.psnr < 30.0, "{} and {} are near-identical ({d})", imgs[i].0, imgs[j].0);
}
}
}
// ---- GPU parity ----
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::default();
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
}
#[test]
fn gpu_matches_cpu_on_golden_scenes() {
let Some((dev, q)) = device() else {
eprintln!("SKIPPED (gpu parity): no GPU adapter");
return;
};
let mut c = filmcraft_gpu::GpuCompositor::new(&dev, &q);
let mut failures = Vec::new();
for (name, _, make) in scenes() {
let s = make();
let t = RATE.tick_of(s.frame);
let cpu = render_cpu(&s);
let plan = filmcraft_render::plan::plan_frame(&s.project, s.seq, t, RenderOptions::default(), &s.sources);
let kind = if matches!(plan, filmcraft_render::plan::FramePlan::Layers { .. }) { "layers" } else { "cpu image" };
c.composite(&plan);
let (gw, gh, mut px) = c.read_output().expect("GPU readback");
px.chunks_exact_mut(4).for_each(|p| p[3] = 255);
let gpu = Rgba8::new(gw, gh, px);
let d = diff(&cpu, &gpu).unwrap();
// mean over RGB only (alpha is equal by construction)
let mean_rgb = d.mean_abs * 4.0 / 3.0;
eprintln!("{name} ({kind}): GPU vs CPU {d}");
if d.p99 > 6 || mean_rgb >= 1.5 {
let dir = filmcraft_testkit::golden::failures_dir();
let _ = filmcraft_testkit::golden::write_png(&dir.join(format!("{name}.gpu.png")), &gpu);
let _ = filmcraft_testkit::golden::write_png(&dir.join(format!("{name}.cpu.png")), &cpu);
failures.push(format!("{name} ({kind}): {d}; images in {}", dir.display()));
}
}
assert!(failures.is_empty(), "GPU differs from the CPU reference:\n{}", failures.join("\n"));
}