filmcraft/crates/golden/tests/golden.rs

776 lines
32 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 built-in graphics template (Lower Third – Slab) placed on a 320×180 sequence with its
/// editable properties overridden (name, slab and accent colours, hidden role line): checks the
/// template's scaling, pins (the slab widens with the longer name) and the overrides.
fn graphic_template() -> Scene {
use filmcraft_project::gtemplate::{builtin_templates, layer_uid};
let t = builtin_templates().into_iter().find(|t| t.id == "builtin:lower-third-slab").unwrap();
let (mut layers, meta) = t.instantiate((W, H));
let mut over = |control: &str, v: ParamValue| {
let c = t.controls.iter().find(|c| c.id == control).unwrap();
let l = layers.iter_mut().find(|l| layer_uid(l) == c.layer).unwrap();
if c.param == "enabled" {
l.enabled = v.as_bool().unwrap();
} else {
l.params.get_mut(&c.param).unwrap().value = v;
}
};
over("name", ParamValue::Text("Golden Overrides".into()));
over("slab_color", ParamValue::Color([0.75, 0.15, 0.18, 1.0]));
over("accent_color", ParamValue::Color([0.2, 0.85, 0.4, 1.0]));
over("show_role", ParamValue::Bool(false));
let mut s = graphic_scene(DemoScene::Forest, layers);
let seq = s.seq;
let q = s.project.sequence_mut(seq).unwrap();
q.video_tracks[1].items[0].graphic = Some(Box::new(meta));
s
}
/// Per-character styles (a bold, bigger, coloured word and an underlined one) and a rounded box
/// pinned around the text on all four edges.
fn graphic_rich_text() -> Scene {
use filmcraft_project::graphic::{new_shape_layer, new_text_layer};
use filmcraft_project::graphic_design::{CharStyle, LayerExtra, Pin, PinTarget, StyleRun};
let mut text = new_text_layer("Mixed STYLES in one layer", Vec2::new(28.0, 100.0), 16.0);
let runs = vec![
StyleRun { start: 6, end: 12, style: CharStyle { size: Some(26.0), faux_bold: Some(true), fill: Some([1.0, 0.8, 0.1, 1.0]), ..Default::default() } },
StyleRun {
start: 16,
end: 19,
style: CharStyle { underline: Some(true), font: Some("Noto Serif".into()), faux_italic: Some(true), ..Default::default() },
},
];
text.layer = Some(Box::new(LayerExtra { uid: 1, runs, ..Default::default() }));
let mut bx = new_shape_layer(0, Vec2::new(150.0, 90.0), Vec2::new(10.0, 10.0), vec![]);
set(&mut bx, &[("corner_radius", fl(6.0)), ("fill_color", ParamValue::Color([0.1, 0.12, 0.3, 1.0])), ("opacity", fl(80.0))]);
let pin = Pin { to: PinTarget::Layer(1), left: true, top: true, right: true, bottom: true, offsets: [-10.0, -8.0, 10.0, 8.0] };
bx.layer = Some(Box::new(LayerExtra { uid: 2, pin: Some(pin), ..Default::default() }));
graphic_scene(DemoScene::Dunes, vec![bx, text])
}
/// 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)
}
/// HDR grading: Rec. 2100 PQ footage in a PQ sequence, graded by Lumetri in the PQ signal
/// (exposure, contrast over HDR White, HDR Specular pulling the speculars in), shown tone mapped.
fn lumetri_hdr_pq() -> Scene {
let mut b = Builder::new(1);
b.p.sequence_mut(b.seq).unwrap().settings.color =
filmcraft_color::ColorPipeline { working: filmcraft_color::WorkingSpace::Rec2100Pq, ..filmcraft_color::ColorPipeline::REC709 };
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);
let c = b.place(0, src, 0, 48);
b.effect(
c,
"lumetri",
&[("exposure", fl(0.4)), ("contrast", fl(30.0)), ("hdr_white", fl(1000.0)), ("hdr_specular", fl(-60.0)), ("temperature", fl(-15.0))],
);
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)
}
/// M5.11 Distort: Corner Pin (a demo picture pinned into a quad) over Turbulent-Displaced bars.
fn vfx_distort() -> Scene {
let mut b = Builder::new(2);
let bars = b.media(Generator::BarsAndTone);
let fg = b.demo(DemoScene::Dunes);
let c = b.place(0, bars, 0, 48);
b.effect(c, "turbulent_displace", &[("amount", fl(40.0)), ("size", fl(80.0)), ("complexity", fl(2.0))]);
let top = b.place(1, fg, 0, 48);
b.effect(
top,
"corner_pin",
&[("upper_left", pt(120.0, 60.0)), ("upper_right", pt(560.0, 20.0)), ("lower_left", pt(60.0, 330.0)), ("lower_right", pt(520.0, 300.0))],
);
b.at(12)
}
/// M5.11 Keying: Ultra Key removing the bars' green over a demo background, with spill suppression.
fn vfx_ultra_key() -> 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 top = b.place(1, bars, 0, 48);
b.effect(top, "ultra_key", &[("key_color", ParamValue::Color([0.0, 0.75, 0.0, 1.0]))]);
b.at(12)
}
/// M5.11 Lights & Glows: Wonder Glow and Glint on a night city.
fn vfx_glows() -> Scene {
let mut b = Builder::new(1);
let city = b.demo(DemoScene::CityNight);
let c = b.place(0, city, 0, 48);
b.effect(c, "wonder_glow", &[("threshold", fl(55.0)), ("radius", fl(20.0))]);
b.effect(c, "glint", &[("threshold", fl(80.0)), ("length", fl(40.0))]);
b.at(12)
}
/// M5.11 Utility / Perspective: Spacer inset with rounded corners, an inside Stroke and a fading
/// Long Shadow, over a Gradient generator.
fn vfx_spacer_stroke_shadow() -> Scene {
let mut b = Builder::new(2);
let bg = b.media(Generator::BlackVideo);
let fg = b.demo(DemoScene::Forest);
let back = b.place(0, bg, 0, 48);
b.effect(
back,
"gradient",
&[
("start", pt(0.0, 0.0)),
("end", pt(640.0, 360.0)),
("start_color", ParamValue::Color([0.15, 0.2, 0.45, 1.0])),
("end_color", ParamValue::Color([0.9, 0.6, 0.3, 1.0])),
],
);
let top = b.place(1, fg, 0, 48);
b.effect(top, "spacer", &[("left", fl(90.0)), ("radius", fl(30.0))]);
b.effect(top, "stroke", &[("width", fl(6.0)), ("position", ParamValue::Choice(2))]);
b.effect(top, "long_shadow", &[("length", fl(80.0)), ("opacity", fl(70.0))]);
b.at(12)
}
/// M5.11 Immersive Video: VR Rotate Sphere (pan and tilt) on equirectangular-treated aurora.
fn vfx_vr_rotate() -> Scene {
let mut b = Builder::new(1);
let aur = b.demo(DemoScene::Aurora);
let c = b.place(0, aur, 0, 48);
b.effect(c, "vr_rotate_sphere", &[("pan", fl(90.0)), ("tilt", fl(25.0)), ("roll", fl(10.0))]);
b.at(12)
}
/// M5.11 Stylize / Image Control: Rounded Crop, Color Emboss and Channel Mix on plasma.
fn vfx_stylize() -> Scene {
let mut b = Builder::new(1);
let pl = b.demo(DemoScene::Plasma);
let c = b.place(0, pl, 0, 48);
b.effect(c, "channel_mix", &[("rr", fl(0.0)), ("rb", fl(100.0)), ("bb", fl(0.0)), ("br", fl(100.0))]);
b.effect(c, "color_emboss", &[("relief", fl(3.0)), ("contrast", fl(150.0))]);
b.effect(c, "rounded_crop", &[("left", fl(8.0)), ("right", fl(8.0)), ("top", fl(8.0)), ("bottom", fl(8.0)), ("radius", fl(50.0)), ("border", fl(4.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),
("graphic_template", "Graphics template (Lower Third – Slab) with overridden properties", graphic_template),
("graphic_rich_text", "Per-character text styles and a box pinned around the text", graphic_rich_text),
("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),
("lumetri_hdr_pq", "Lumetri grading in a Rec. 2100 PQ sequence (HDR White, HDR Specular), tone mapped", lumetri_hdr_pq),
("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),
("vfx_distort", "Corner Pin over Turbulent Displace", vfx_distort),
("vfx_ultra_key", "Ultra Key on colour bars over footage", vfx_ultra_key),
("vfx_glows", "Wonder Glow and Glint", vfx_glows),
("vfx_spacer_stroke_shadow", "Spacer, Stroke and Long Shadow over a Gradient", vfx_spacer_stroke_shadow),
("vfx_vr_rotate", "VR Rotate Sphere", vfx_vr_rotate),
("vfx_stylize", "Channel Mix, Color Emboss and Rounded Crop", vfx_stylize),
]
}
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,
graphic_template,
graphic_rich_text,
log_to_rec709,
hdr_tone_map,
lumetri_hdr_pq,
mask_blur,
mask_color,
mask_inverted,
mask_opacity,
vfx_distort,
vfx_ultra_key,
vfx_glows,
vfx_spacer_stroke_shadow,
vfx_vr_rotate,
vfx_stylize
);
/// Effects panel ▸ Lumetri Presets thumbnail grid: every built-in preset graded by our Lumetri on
/// the procedural preview picture (`filmcraft_render::lumetri_presets`).
#[test]
fn lumetri_presets_grid() {
use filmcraft_render::lumetri_presets as lp;
let img = lp::grid(&lp::presets(), 6, 96, 54);
let rgba = Rgba8::new(img.w as u32, img.h as u32, img.over_black_rgba8());
let name = "lumetri_presets_grid";
let d = assert_golden(&golden_path(name), &rgba, Tolerance::RENDER, "Lumetri Presets thumbnail grid", &format!("crates/golden/tests/golden.rs ({name})"));
eprintln!("{name}: {d}");
}
/// 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"));
}