filmcraft/crates/render/src/vfx/tests.rs

1186 lines
46 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.

//! Tests for the M5.11 video effects: every effect runs, stays finite and in range, and is
//! deterministic; neutral parameters are the identity; each effect has a known-value check; the
//! frame/track environment works through the real compositor.
use std::sync::Arc;
use filmcraft_project::{EffectKind, Interpolation, Keyframe, Param, ParamKind, find_effect};
use super::*;
fn cx() -> FxCtx<'static> {
FxCtx {
t: Tick::ZERO,
px_scale: 1.0,
seconds: 0.0,
timecode: "01:00:00:00",
clip_name: "clip",
project: None,
env: None,
frame_rate: 24.0,
working: filmcraft_color::WorkingSpace::Rec709,
}
}
fn inst(id: &str) -> EffectInstance {
find_effect(id).unwrap_or_else(|| panic!("no effect {id}")).instance()
}
fn set(e: &mut EffectInstance, k: &str, v: ParamValue) {
e.params.get_mut(k).unwrap_or_else(|| panic!("{}: no param {k}", e.effect)).value = v;
}
fn setf(e: &mut EffectInstance, k: &str, v: f64) {
set(e, k, ParamValue::Float(v));
}
/// A deterministic textured test picture (straight sRGB-ish values in linear premultiplied form).
fn picture(w: usize, h: usize) -> Image {
let mut img = Image::new(w, h);
for y in 0..h {
for x in 0..w {
let (u, v) = (x as f32 / w as f32, y as f32 / h as f32);
let n = noise3(x as f32 / 6.0, y as f32 / 6.0, 0.5, 9) * 0.25;
let c = [(0.2 + 0.6 * u + n).clamp(0.0, 1.0), (0.3 + 0.5 * v - n).clamp(0.0, 1.0), (0.5 + 0.4 * (u - v) + n * 0.5).clamp(0.0, 1.0)];
let l = dec(c);
img.px[(y * w + x) * 4..(y * w + x) * 4 + 4].copy_from_slice(&[l[0], l[1], l[2], 1.0]);
}
}
img
}
fn run(id: &str, e: &EffectInstance, img: &Image, cx: &FxCtx) -> Image {
let mut o = img.clone();
crate::effects::apply(&mut o, e, cx);
let _ = id;
o
}
fn max_diff(a: &Image, b: &Image) -> f32 {
a.px.iter().zip(&b.px).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
}
/// The ids this milestone added or rebuilt.
const NEW_IDS: &[&str] = &[
"lighting_effects",
"bokeh_blur",
"channel_blur",
"compound_blur",
"focus_blur",
"reduce_interlace_flicker",
"asc_cdl",
"video_limiter",
"vignette",
"corner_pin",
"magnify",
"spherize",
"turbulent_displace",
"warp_stabilizer",
"gradient",
"channel_mix",
"color_replace",
"rounded_crop",
"vr_blur",
"vr_chromatic_aberrations",
"vr_color_gradients",
"vr_denoise",
"vr_digital_glitch",
"vr_fractal_noise",
"vr_glow",
"vr_plane_to_sphere",
"vr_projection",
"vr_rotate_sphere",
"vr_sharpen",
"alpha_adjust",
"logo_cutout",
"echo_glow",
"edge_glow",
"glint",
"light_leaks",
"rgb_split",
"volumetric_rays",
"wonder_glow",
"long_shadow",
"brush_strokes",
"color_emboss",
"roughen_edges",
"posterize_time",
"rotate_3d",
"auto_reframe",
"camera_shake",
"grow",
"shrink",
"move",
"spin",
"spacer",
"wiggle",
"auto_align",
"cineon_converter",
"clone",
"stroke",
"metadata_burnin",
"echo",
"lightning",
"cell_pattern",
"checkerboard",
"ellipse",
"paint_bucket",
"write_on",
"alpha_glow",
"block_dissolve",
"directional_blur_legacy",
"gaussian_blur_legacy",
"gradient_wipe_legacy",
"linear_wipe_legacy",
"magnify_legacy",
"mosaic_legacy",
"noise_legacy",
"twirl_legacy",
// rebuilt
"ultra_key",
"lens_flare",
"track_matte",
"simple_text",
"noise",
"mosaic",
];
#[test]
fn every_new_effect_is_defined_as_video() {
for id in NEW_IDS {
let d = find_effect(id).unwrap_or_else(|| panic!("{id}"));
assert_eq!(d.kind, EffectKind::Video, "{id}");
assert!(!d.category.is_empty(), "{id}");
for p in &d.params {
if let ParamKind::Float { min, max, .. } = p.kind {
let v = p.default.as_f64().unwrap();
assert!(v >= min && v <= max, "{id}.{} default {v} outside {min}..{max}", p.id);
}
}
}
}
/// Push every float parameter towards its soft maximum so the code paths run.
fn pushed(id: &str) -> EffectInstance {
let def = find_effect(id).unwrap();
let mut e = def.instance();
for (pid, p) in e.params.iter_mut() {
if let ParamValue::Float(v) = &mut p.value
&& let Some(ParamKind::Float { soft_max, soft_min, .. }) = def.param(pid).map(|d| &d.kind)
{
*v = (*v + (soft_max - soft_min) * 0.3).min(*soft_max);
}
}
e
}
#[test]
fn every_effect_is_finite_in_range_and_deterministic() {
let img = picture(40, 28);
let c = FxCtx { seconds: 0.4, ..cx() };
for id in NEW_IDS {
for e in [inst(id), pushed(id)] {
let a = run(id, &e, &img, &c);
let b = run(id, &e, &img, &c);
assert_eq!(a.px, b.px, "{id} not deterministic");
assert_eq!((a.w, a.h), (img.w, img.h), "{id} changed size");
for p in a.px.as_chunks::<4>().0 {
assert!(p.iter().all(|v| v.is_finite()), "{id}: {p:?}");
assert!(p[3] >= -1e-4 && p[3] <= 1.0 + 1e-3, "{id}: alpha {}", p[3]);
assert!(p[..3].iter().all(|v| *v >= -1e-3), "{id}: negative {p:?}");
}
}
}
}
#[test]
fn every_choice_value_runs() {
let img = picture(24, 16);
for id in NEW_IDS {
let def = find_effect(id).unwrap();
for p in &def.params {
if let ParamKind::Choice(opts) = p.kind {
for i in 0..opts.len() {
let mut e = pushed(id);
set(&mut e, p.id, ParamValue::Choice(i as u32));
let o = run(id, &e, &img, &cx());
assert!(o.px.iter().all(|v| v.is_finite()), "{id}.{}={i}", p.id);
}
}
}
}
}
#[test]
fn neutral_parameters_are_identity() {
let img = picture(32, 24);
let cases: Vec<(&str, Vec<(&str, ParamValue)>)> = vec![
("asc_cdl", vec![]),
("channel_mix", vec![]),
("vignette", vec![("amount", ParamValue::Float(0.0))]),
("corner_pin", vec![]),
("spherize", vec![]),
("turbulent_displace", vec![("amount", ParamValue::Float(0.0))]),
("rotate_3d", vec![]),
("grow", vec![]),
("spin", vec![]),
("alpha_adjust", vec![]),
("rounded_crop", vec![("radius", ParamValue::Float(0.0))]),
("block_dissolve", vec![]),
("gradient_wipe_legacy", vec![]),
("linear_wipe_legacy", vec![]),
("channel_blur", vec![]),
("bokeh_blur", vec![("amount", ParamValue::Float(0.0))]),
("reduce_interlace_flicker", vec![]),
("vr_blur", vec![]),
("vr_sharpen", vec![]),
("vr_denoise", vec![]),
("vr_digital_glitch", vec![]),
("vr_chromatic_aberrations", vec![]),
("vr_rotate_sphere", vec![]),
("vr_projection", vec![]),
("rgb_split", vec![("amount", ParamValue::Float(0.0))]),
("color_replace", vec![("target", ParamValue::Color([0.0, 0.0, 0.0, 1.0])), ("similarity", ParamValue::Float(0.0))]),
("clone", vec![("columns", ParamValue::Float(1.0))]),
("auto_align", vec![("horizontal", ParamValue::Choice(0)), ("vertical", ParamValue::Choice(0))]),
("auto_align", vec![]),
("magnify", vec![("magnification", ParamValue::Float(100.0))]),
("stroke", vec![("width", ParamValue::Float(0.0))]),
("long_shadow", vec![("length", ParamValue::Float(0.0))]),
("roughen_edges", vec![("border", ParamValue::Float(0.0))]),
("posterize_time", vec![]),
("echo", vec![]),
("warp_stabilizer", vec![]),
("track_matte", vec![]),
("noise", vec![]),
("noise_legacy", vec![]),
("gaussian_blur_legacy", vec![]),
("directional_blur_legacy", vec![]),
("video_limiter", vec![("compression", ParamValue::Choice(0))]),
("cineon_converter", vec![("conversion", ParamValue::Choice(2)), ("black10", ParamValue::Float(95.0)), ("white10", ParamValue::Float(685.0))]),
("light_leaks", vec![("intensity", ParamValue::Float(0.0))]),
("lens_flare", vec![("brightness", ParamValue::Float(0.0))]),
("simple_text", vec![("text", ParamValue::Text(String::new()))]),
];
for (id, params) in cases {
let mut e = inst(id);
for (k, v) in params {
set(&mut e, k, v);
}
let o = run(id, &e, &img, &cx());
let d = max_diff(&o, &img);
// in-range values pass the limiter's knee untouched; Cineon log→log at the default points
assert!(d < 2e-3, "{id} not identity: max diff {d}");
}
}
fn enc_px(img: &Image, x: usize, y: usize) -> [f32; 4] {
let p = img.get(x, y);
let c = enc(Image::unpremul(p));
[c[0], c[1], c[2], p[3]]
}
fn solid(w: usize, h: usize, c: [f32; 3]) -> Image {
let l = dec(c);
Image::filled(w, h, [l[0], l[1], l[2], 1.0])
}
fn close(a: f32, b: f32, tol: f32) -> bool {
(a - b).abs() <= tol
}
#[test]
fn asc_cdl_known_values() {
// slope 2, offset 0.1, power 2 on 0.2 → (0.5)² = 0.25; saturation 0 → luma
let out = color::cdl([0.2, 0.2, 0.2], [2.0; 3], [0.1; 3], [2.0; 3], 1.0);
assert!(out.iter().all(|v| close(*v, 0.25, 1e-6)), "{out:?}");
let grey = color::cdl([1.0, 0.0, 0.0], [1.0; 3], [0.0; 3], [1.0; 3], 0.0);
assert!(grey.iter().all(|v| close(*v, 0.2126, 1e-5)), "{grey:?}");
let mut e = inst("asc_cdl");
setf(&mut e, "r_slope", 2.0);
let o = run("asc_cdl", &e, &solid(4, 4, [0.25, 0.25, 0.25]), &cx());
let p = enc_px(&o, 1, 1);
assert!(close(p[0], 0.5, 2e-3) && close(p[1], 0.25, 2e-3), "{p:?}");
}
#[test]
fn channel_mix_swaps_channels() {
let mut e = inst("channel_mix");
for (k, v) in [("rr", 0.0), ("rb", 100.0), ("bb", 0.0), ("br", 100.0)] {
setf(&mut e, k, v);
}
let o = run("channel_mix", &e, &solid(4, 4, [0.8, 0.4, 0.1]), &cx());
let p = enc_px(&o, 0, 0);
assert!(close(p[0], 0.1, 2e-3) && close(p[1], 0.4, 2e-3) && close(p[2], 0.8, 2e-3), "{p:?}");
}
#[test]
fn video_limiter_clips_super_whites() {
let img = Image::filled(4, 4, [3.0, 3.0, 3.0, 1.0]);
let o = run("video_limiter", &inst("video_limiter"), &img, &cx());
let p = o.get(0, 0);
assert!(p[0] <= 1.0 + 1e-4 && p[0] > 0.9, "{p:?}");
// 105 IRE allows a little more
let mut e = inst("video_limiter");
set(&mut e, "clip_level", ParamValue::Choice(5));
set(&mut e, "compression", ParamValue::Choice(0));
let o = run("video_limiter", &e, &img, &cx());
assert!(o.get(0, 0)[0] > 1.0, "{:?}", o.get(0, 0));
// gamut warning paints the clipped pixels
let mut e = inst("video_limiter");
set(&mut e, "gamut_warning", ParamValue::Bool(true));
let o = run("video_limiter", &e, &img, &cx());
let p = o.get(0, 0);
assert!(p[0] > 0.9 && p[1] < 0.01, "{p:?}");
// the limiter keeps legal R′G′B′ and the luma knee
let v = color::limit([1.2, 0.2, -0.1], 1.0, 0.0, 2);
assert!(v.iter().all(|c| *c >= 0.0 && *c <= 1.0), "{v:?}");
}
#[test]
fn vignette_darkens_corners_only() {
let img = solid(64, 36, [0.6, 0.6, 0.6]);
let o = run("vignette", &inst("vignette"), &img, &cx());
assert!(enc_px(&o, 0, 0)[0] < 0.45, "corner {:?}", enc_px(&o, 0, 0));
assert!(close(enc_px(&o, 32, 18)[0], 0.6, 2e-3), "centre {:?}", enc_px(&o, 32, 18));
}
#[test]
fn corner_pin_maps_corners() {
let img = picture(40, 30);
let mut e = inst("corner_pin");
// shrink the right half to the middle: UR (40,0)→(20,0), LR (40,30)→(20,30)
set(&mut e, "upper_left", ParamValue::Vec2(Vec2::new(0.0, 0.0)));
set(&mut e, "lower_left", ParamValue::Vec2(Vec2::new(0.0, 30.0)));
set(&mut e, "upper_right", ParamValue::Vec2(Vec2::new(20.0, 0.0)));
set(&mut e, "lower_right", ParamValue::Vec2(Vec2::new(20.0, 30.0)));
let o = run("corner_pin", &e, &img, &cx());
assert_eq!(o.get(30, 15)[3], 0.0, "outside the pinned quad is clear");
// output x maps to source 2x
let a = o.get(5, 10);
let b = img.sample_bilinear(11.0, 10.5);
for k in 0..4 {
assert!(close(a[k], b[k], 1e-3), "{a:?} {b:?}");
}
// the homography helpers invert exactly
let q = [Vec2::new(3.0, 1.0), Vec2::new(30.0, 5.0), Vec2::new(28.0, 25.0), Vec2::new(1.0, 20.0)];
let h = distort::square_to_quad(q);
for (i, (u, v)) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].iter().enumerate() {
let (x, y) = distort::h_apply(&h, *u, *v).unwrap();
assert!((x - q[i].x).abs() < 1e-9 && (y - q[i].y).abs() < 1e-9);
}
}
#[test]
fn magnify_and_spherize_enlarge_the_centre() {
let img = picture(60, 60);
let o = run("magnify", &inst("magnify"), &img, &cx());
// 200 %: the pixel 10 px right of centre shows the source 5 px right of centre
let a = o.get(40, 30);
let b = img.sample_bilinear(35.25, 30.5);
for k in 0..3 {
assert!(close(a[k], b[k], 0.02), "{a:?} {b:?}");
}
let mut e = inst("spherize");
setf(&mut e, "radius", 25.0);
let o = run("spherize", &e, &img, &cx());
assert!(max_diff(&o, &img) > 0.01);
// outside the radius nothing moves
assert_eq!(o.get(1, 1), img.get(1, 1));
}
#[test]
fn turbulent_displace_moves_pixels_and_pins_edges() {
let img = picture(48, 48);
let o = run("turbulent_displace", &inst("turbulent_displace"), &img, &cx());
assert!(max_diff(&o, &img) > 0.01);
// Pin All: the corners stay
for (x, y) in [(0, 0), (47, 0), (0, 47), (47, 47)] {
let (a, b) = (o.get(x, y), img.get(x, y));
assert!((0..4).all(|k| close(a[k], b[k], 0.03)), "{x},{y}: {a:?} {b:?}");
}
// evolution changes the pattern; seed too
let mut e = inst("turbulent_displace");
setf(&mut e, "evolution", 90.0);
assert!(max_diff(&run("t", &e, &img, &cx()), &o) > 1e-3);
}
#[test]
fn rotate_3d_z_matches_a_2d_rotation() {
let img = picture(41, 41);
let mut e = inst("rotate_3d");
setf(&mut e, "rot_z", 90.0);
let o = run("rotate_3d", &e, &img, &cx());
// rotating 90° clockwise about the centre: output (x,y) samples source (y, 41−x)
let a = o.get(30, 10);
let b = img.sample_bilinear(10.5, 41.0 - 30.5);
for k in 0..3 {
assert!(close(a[k], b[k], 0.02), "{a:?} {b:?}");
}
// a Y rotation past 90° shows the back face; Hide Back Face clears it
setf(&mut e, "rot_z", 0.0);
setf(&mut e, "rot_y", 180.0);
set(&mut e, "hide_back", ParamValue::Bool(true));
assert!(run("r", &e, &img, &cx()).px.iter().all(|v| *v == 0.0));
}
#[test]
fn track_matte_uses_the_other_tracks_alpha_and_luma() {
struct Env(Image);
impl FxEnv for Env {
fn frame(&self, _: f64, _: &EffectInstance) -> Option<Image> {
None
}
fn source_frame(&self, _: f64, _: f32) -> Option<Image> {
None
}
fn track(&self, i: usize) -> Option<Image> {
(i == 1).then(|| self.0.clone())
}
fn layer_to_output(&self) -> Affine {
Affine::IDENTITY
}
fn clip_seconds(&self) -> f64 {
1.0
}
fn clip_offset(&self) -> f64 {
0.0
}
fn frame_rate(&self) -> f64 {
24.0
}
fn media_timecode(&self) -> String {
String::new()
}
fn file_name(&self) -> String {
String::new()
}
fn sequence_name(&self) -> String {
String::new()
}
fn sequence_size(&self) -> (u32, u32) {
(8, 4)
}
fn source_size(&self) -> (u32, u32) {
(8, 4)
}
fn clip_key(&self) -> u64 {
1
}
}
// matte: left half opaque white, right half transparent
let mut matte = Image::new(8, 4);
for y in 0..4 {
for x in 0..4 {
matte.px[(y * 8 + x) * 4..(y * 8 + x) * 4 + 4].copy_from_slice(&[1.0, 1.0, 1.0, 1.0]);
}
}
let env = Env(matte);
let c = FxCtx { env: Some(&env), ..cx() };
let img = solid(8, 4, [0.5, 0.5, 0.5]);
let mut e = inst("track_matte");
set(&mut e, "matte", ParamValue::Choice(2));
let o = run("track_matte", &e, &img, &c);
assert!(close(o.get(1, 1)[3], 1.0, 1e-4) && close(o.get(6, 1)[3], 0.0, 1e-4));
set(&mut e, "reverse", ParamValue::Bool(true));
let o = run("track_matte", &e, &img, &c);
assert!(close(o.get(1, 1)[3], 0.0, 1e-4) && close(o.get(6, 1)[3], 1.0, 1e-4));
set(&mut e, "reverse", ParamValue::Bool(false));
set(&mut e, "composite", ParamValue::Choice(1));
let o = run("track_matte", &e, &img, &c);
assert!(close(o.get(1, 1)[3], 1.0, 1e-3) && close(o.get(6, 1)[3], 0.0, 1e-4));
}
#[test]
fn keyers_known_values() {
// Ultra Key: pure green screen → transparent, grey foreground → opaque
let mut img = solid(8, 2, [0.0, 0.8, 0.2]);
let grey = dec([0.5, 0.45, 0.42]);
for x in 4..8 {
for y in 0..2 {
img.px[(y * 8 + x) * 4..(y * 8 + x) * 4 + 4].copy_from_slice(&[grey[0], grey[1], grey[2], 1.0]);
}
}
let o = run("ultra_key", &inst("ultra_key"), &img, &cx());
assert!(o.get(1, 0)[3] < 0.05, "screen {:?}", o.get(1, 0));
assert!(o.get(6, 0)[3] > 0.95, "fg {:?}", o.get(6, 0));
let mut e = inst("ultra_key");
set(&mut e, "output", ParamValue::Choice(1));
let m = run("ultra_key", &e, &img, &cx());
assert!(m.get(1, 0)[0] < 0.05 && m.get(6, 0)[0] > 0.9 && m.get(1, 0)[3] == 1.0);
let mut aggressive = inst("ultra_key");
set(&mut aggressive, "setting", ParamValue::Choice(2));
let ag = run("ultra_key", &aggressive, &img, &cx());
let def = run("ultra_key", &inst("ultra_key"), &img, &cx());
let delta = (0..img.w * img.h).map(|i| (ag.px[i * 4 + 3] - def.px[i * 4 + 3]).abs()).fold(0.0f32, f32::max);
assert!(delta > 1e-4, "Aggressive Setting left the Default matte unchanged");
// Alpha Adjust: invert and opacity
let mut a = Image::filled(2, 2, [0.1, 0.1, 0.1, 0.25]);
let mut e = inst("alpha_adjust");
set(&mut e, "invert", ParamValue::Bool(true));
setf(&mut e, "opacity", 50.0);
crate::effects::apply(&mut a, &e, &cx());
assert!(close(a.get(0, 0)[3], 0.375, 1e-5), "{:?}", a.get(0, 0));
// Logo Cutout: the white background goes, a black logo stays black and opaque
let mut img = solid(4, 1, [1.0, 1.0, 1.0]);
img.px[0..4].copy_from_slice(&[0.0, 0.0, 0.0, 1.0]);
let o = run("logo_cutout", &inst("logo_cutout"), &img, &cx());
assert_eq!(o.get(2, 0)[3], 0.0);
assert!(close(o.get(0, 0)[3], 1.0, 1e-4) && o.get(0, 0)[0] < 1e-4);
// 50 % grey over white un-multiplies to black at alpha 0.5
let mut e = inst("logo_cutout");
setf(&mut e, "threshold", 0.0);
setf(&mut e, "softness", 100.0);
let o = run("logo_cutout", &e, &solid(1, 1, [0.5, 0.5, 0.5]), &cx());
let p = o.get(0, 0);
assert!(close(p[3], 0.5, 1e-3) && p[0] < 1e-3, "{p:?}");
}
#[test]
fn colour_replace_and_cineon() {
let o = run("color_replace", &inst("color_replace"), &solid(2, 2, [1.0, 0.0, 0.0]), &cx());
let p = enc_px(&o, 0, 0);
assert!(p[2] > 0.9 && p[0] < 0.1, "{p:?}");
// Cineon: code 685 → white, 95 → black; log→lin→log round trip
let (b, w) = (95.0, 685.0);
assert!(close(color::cineon_value(685.0 / 1023.0, 0, b, w, 0.0, 1.0, 1.7, 0.0), 1.0, 1e-4));
assert!(close(color::cineon_value(95.0 / 1023.0, 0, b, w, 0.0, 1.0, 1.7, 0.0), 0.0, 1e-4));
for v in [0.2f32, 0.4, 0.6] {
let l = color::cineon_value(v, 0, b, w, 0.0, 1.0, 1.7, 0.0);
let back = color::cineon_value(l, 1, b, w, 0.0, 1.0, 1.7, 0.0);
assert!(close(back, v, 2e-3), "{v} → {l} → {back}");
}
}
#[test]
fn lights_add_light_around_highlights() {
let mut img = solid(48, 48, [0.05, 0.05, 0.05]);
for y in 22..26 {
for x in 22..26 {
img.px[(y * 48 + x) * 4..(y * 48 + x) * 4 + 4].copy_from_slice(&[1.0, 1.0, 1.0, 1.0]);
}
}
let total = |i: &Image| i.px.as_chunks::<4>().0.iter().map(|p| p[0] as f64).sum::<f64>();
for id in ["echo_glow", "glint", "wonder_glow", "volumetric_rays", "edge_glow"] {
let o = run(id, &inst(id), &img, &cx());
let added = total(&o) - total(&img);
assert!(added > 0.5, "{id}: added only {added}");
assert!(o.get(2, 2)[0] < 0.2, "{id}: lit the far corner {:?}", o.get(2, 2));
}
// Glint with 4 rays at 0°: light along the horizontal, not the diagonal
let mut e = inst("glint");
setf(&mut e, "rotation", 0.0);
let o = run("glint", &e, &img, &cx());
assert!(o.get(38, 24)[0] > o.get(34, 34)[0], "{:?} {:?}", o.get(38, 24), o.get(34, 34));
// RGB Split: red sampled from the left
let mut s = Image::new(9, 1);
s.px[4 * 4..4 * 4 + 4].copy_from_slice(&[1.0, 1.0, 1.0, 1.0]);
let mut e = inst("rgb_split");
setf(&mut e, "amount", 2.0);
let o = run("rgb_split", &e, &s, &cx());
assert!(close(o.get(6, 0)[0], 1.0, 1e-4) && close(o.get(2, 0)[2], 1.0, 1e-4) && close(o.get(4, 0)[1], 1.0, 1e-4));
}
#[test]
fn long_shadow_and_stroke_geometry() {
// a 4×4 opaque square at (4,4) on a transparent 24×24 layer
let mut img = Image::new(24, 24);
for y in 4..8 {
for x in 4..8 {
img.px[(y * 24 + x) * 4..(y * 24 + x) * 4 + 4].copy_from_slice(&[1.0, 1.0, 1.0, 1.0]);
}
}
let mut e = inst("long_shadow");
setf(&mut e, "angle", 90.0); // to the right
setf(&mut e, "length", 10.0);
set(&mut e, "fade", ParamValue::Bool(false));
setf(&mut e, "opacity", 100.0);
let o = run("long_shadow", &e, &img, &cx());
assert!(o.get(14, 5)[3] > 0.8, "shadow to the right {:?}", o.get(14, 5));
assert!(o.get(20, 5)[3] < 0.2, "ends after Length {:?}", o.get(20, 5));
assert!(o.get(5, 14)[3] < 0.05, "not below {:?}", o.get(5, 14));
// Stroke outside 2 px
let mut e = inst("stroke");
setf(&mut e, "width", 2.0);
set(&mut e, "color", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
let o = run("stroke", &e, &img, &cx());
assert!(o.get(9, 5)[0] > 0.9 && o.get(9, 5)[1] < 0.05, "stroke {:?}", o.get(9, 5));
assert!(o.get(12, 5)[3] < 0.01, "beyond the stroke {:?}", o.get(12, 5));
assert_eq!(o.get(5, 5), [1.0, 1.0, 1.0, 1.0], "inside untouched");
// exact distance transform
let mut inside = vec![false; 7 * 5];
inside[2 * 7 + 3] = true;
let d = stylize::distance_to(&inside, 7, 5);
assert!(close(d[2 * 7 + 3], 0.0, 1e-6) && close(d[0], (9.0f32 + 4.0).sqrt(), 1e-5));
}
#[test]
fn generators_known_values() {
let img = solid(40, 20, [0.5, 0.5, 0.5]);
// Gradient: black at the top centre, white at the bottom centre
let mut e = inst("gradient");
set(&mut e, "start", ParamValue::Vec2(Vec2::new(20.0, 0.0)));
set(&mut e, "end", ParamValue::Vec2(Vec2::new(20.0, 20.0)));
let o = run("gradient", &e, &img, &cx());
assert!(enc_px(&o, 20, 0)[0] < 0.05 && enc_px(&o, 20, 19)[0] > 0.95 && close(enc_px(&o, 20, 10)[0], 0.525, 0.03));
// Checkerboard: alternating cells
let mut e = inst("checkerboard");
setf(&mut e, "width", 10.0);
set(&mut e, "anchor", ParamValue::Vec2(Vec2::new(0.0, 0.0)));
let o = run("checkerboard", &e, &img, &cx());
assert!(o.get(5, 5)[3] < 0.01 && o.get(15, 5)[3] > 0.99 && o.get(15, 15)[3] < 0.01);
// Ellipse ring: on the ring opaque, at the centre clear
let mut e = inst("ellipse");
setf(&mut e, "width", 16.0);
setf(&mut e, "height", 16.0);
setf(&mut e, "thickness", 2.0);
let o = run("ellipse", &e, &img, &cx());
assert!(o.get(28, 10)[3] > 0.8 && o.get(20, 10)[3] < 0.01, "{:?} {:?}", o.get(28, 10), o.get(20, 10));
// Cell Pattern is opaque grey in 0..1
let o = run("cell_pattern", &inst("cell_pattern"), &img, &cx());
assert!(o.px.as_chunks::<4>().0.iter().all(|p| p[3] == 1.0 && p[0] >= 0.0 && p[0] <= 1.0 && p[0] == p[1]));
// Lightning: deterministic per seed, different across seeds and strikes
let a = run("lightning", &inst("lightning"), &img, &cx());
let mut e = inst("lightning");
setf(&mut e, "seed", 5.0);
assert!(max_diff(&a, &run("lightning", &e, &img, &cx())) > 0.01);
let later = FxCtx { seconds: 2.0, ..cx() };
assert!(max_diff(&a, &run("lightning", &inst("lightning"), &img, &later)) > 0.01);
}
#[test]
fn paint_bucket_fills_the_connected_region() {
// left half red, right half blue; fill at the left
let mut img = solid(10, 4, [1.0, 0.0, 0.0]);
let blue = dec([0.0, 0.0, 1.0]);
for y in 0..4 {
for x in 5..10 {
img.px[(y * 10 + x) * 4..(y * 10 + x) * 4 + 4].copy_from_slice(&[blue[0], blue[1], blue[2], 1.0]);
}
}
let mut e = inst("paint_bucket");
set(&mut e, "point", ParamValue::Vec2(Vec2::new(1.0, 1.0)));
set(&mut e, "color", ParamValue::Color([0.0, 1.0, 0.0, 1.0]));
let o = run("paint_bucket", &e, &img, &cx());
assert!(enc_px(&o, 1, 1)[1] > 0.95 && enc_px(&o, 1, 1)[0] < 0.05);
assert!(enc_px(&o, 8, 1)[2] > 0.95 && enc_px(&o, 8, 1)[1] < 0.05);
}
#[test]
fn write_on_follows_the_animated_brush() {
use filmcraft_project::{Interpolation, Keyframe};
let img = solid(40, 20, [0.0, 0.0, 0.0]);
let mut e = inst("write_on");
let p = e.params.get_mut("brush").unwrap();
p.keyframes = vec![
Keyframe {
time: Tick::ZERO,
value: ParamValue::Vec2(Vec2::new(5.0, 10.0)),
interp: Interpolation::Linear,
out_influence: 1.0 / 3.0,
in_influence: 1.0 / 3.0,
},
Keyframe {
time: Tick::from_seconds_f64(1.0),
value: ParamValue::Vec2(Vec2::new(35.0, 10.0)),
interp: Interpolation::Linear,
out_influence: 1.0 / 3.0,
in_influence: 1.0 / 3.0,
},
];
// half way: painted from x=5 to x=20, not beyond
let c = FxCtx { t: Tick::from_seconds_f64(0.5), seconds: 0.5, ..cx() };
let o = run("write_on", &e, &img, &c);
assert!(enc_px(&o, 12, 10)[0] > 0.9, "{:?}", enc_px(&o, 12, 10));
assert!(enc_px(&o, 30, 10)[0] < 0.05, "{:?}", enc_px(&o, 30, 10));
}
#[test]
fn transform_presets_animate_over_the_clip() {
struct Env;
impl FxEnv for Env {
fn frame(&self, _: f64, _: &EffectInstance) -> Option<Image> {
None
}
fn source_frame(&self, _: f64, _: f32) -> Option<Image> {
None
}
fn track(&self, _: usize) -> Option<Image> {
None
}
fn layer_to_output(&self) -> Affine {
Affine::IDENTITY
}
fn clip_seconds(&self) -> f64 {
2.0
}
fn clip_offset(&self) -> f64 {
0.0
}
fn frame_rate(&self) -> f64 {
24.0
}
fn media_timecode(&self) -> String {
"00:00:10:00".into()
}
fn file_name(&self) -> String {
"A001.mov".into()
}
fn sequence_name(&self) -> String {
"Seq".into()
}
fn sequence_size(&self) -> (u32, u32) {
(40, 40)
}
fn source_size(&self) -> (u32, u32) {
(40, 40)
}
fn clip_key(&self) -> u64 {
2
}
}
let env = Env;
let img = picture(40, 40);
let end = FxCtx { seconds: 2.0, env: Some(&env), ..cx() };
// Grow ends at 120 %
let o = run("grow", &inst("grow"), &img, &end);
let a = o.get(30, 20);
let b = img.sample_bilinear(20.0 + 10.5 / 1.2, 20.0 + 0.5 / 1.2);
assert!((0..3).all(|k| close(a[k], b[k], 0.02)), "{a:?} {b:?}");
// Spin ends at 360° (identity again); half way it is upside down
let o = run("spin", &inst("spin"), &img, &end);
assert!(max_diff(&o, &img) < 0.02);
let mid = FxCtx { seconds: 1.0, env: Some(&env), ..cx() };
let o = run("spin", &inst("spin"), &img, &mid);
let (a, b) = (o.get(10, 10), img.get(29, 29));
assert!((0..3).all(|k| close(a[k], b[k], 0.03)), "{a:?} {b:?}");
// Move from (−100, 0) to (0, 0): at the end identity, at the start shifted out
assert!(max_diff(&run("move", &inst("move"), &img, &end), &img) < 1e-4);
// burn-in text sources
let e = inst("metadata_burnin");
assert_eq!(text::burnin_text(&e, &end), "01:00:00:00");
let mut e2 = e.clone();
set(&mut e2, "source", ParamValue::Choice(1));
assert_eq!(text::burnin_text(&e2, &end), "00:00:10:00");
set(&mut e2, "source", ParamValue::Choice(3));
set(&mut e2, "prefix", ParamValue::Text("FILE".into()));
assert_eq!(text::burnin_text(&e2, &end), "FILE A001.mov");
set(&mut e2, "source", ParamValue::Choice(4));
assert_eq!(text::burnin_text(&e2, &mid), "FILE 24");
}
#[test]
fn transform_shutter_override_blurs_moving_position() {
let mut e = inst("transform");
set(&mut e, "shutter_override", ParamValue::Bool(true));
setf(&mut e, "shutter_angle", 180.0);
let a = Keyframe::new(Tick::ZERO, ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)));
let mut b = Keyframe::new(Tick::from_seconds_f64(1.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(40.0, 16.0)));
b.interp = Interpolation::Linear;
e.params.insert("position".into(), Param { value: ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)), keyframes: vec![a, b] });
let img = picture(32, 32);
let cx = FxCtx { t: Tick::from_seconds_f64(0.5), seconds: 0.5, ..cx() };
let sharp = {
let mut off = e.clone();
set(&mut off, "shutter_override", ParamValue::Bool(false));
run("transform", &off, &img, &cx)
};
let blurred = run("transform", &e, &img, &cx);
let delta = max_diff(&sharp, &blurred);
assert!(delta > 1e-3, "shutter override left the Transform picture unchanged ({delta})");
}
#[test]
fn clone_auto_align_and_rounded_crop() {
let img = picture(40, 20);
let mut e = inst("clone");
setf(&mut e, "columns", 2.0);
let o = run("clone", &e, &img, &cx());
// two half-size copies side by side, letterboxed vertically
assert_eq!(o.get(10, 1)[3], 0.0);
let (a, b) = (o.get(10, 10), o.get(30, 10));
assert!((0..4).all(|k| close(a[k], b[k], 1e-5)));
// Auto Align left: content moves to x = 0
let mut layer = Image::new(20, 10);
for y in 3..6 {
for x in 10..14 {
layer.px[(y * 20 + x) * 4..(y * 20 + x) * 4 + 4].copy_from_slice(&[1.0, 1.0, 1.0, 1.0]);
}
}
let mut e = inst("auto_align");
set(&mut e, "horizontal", ParamValue::Choice(1));
set(&mut e, "vertical", ParamValue::Choice(1));
let o = run("auto_align", &e, &layer, &cx());
assert_eq!(o.get(0, 0)[3], 1.0);
assert_eq!(o.get(4, 0)[3], 0.0);
// Rounded Crop: corners are cut, the centre stays
let o = run("rounded_crop", &inst("rounded_crop"), &picture(100, 100), &cx());
assert_eq!(o.get(0, 0)[3], 0.0);
assert_eq!(o.get(50, 50)[3], 1.0);
}
#[test]
fn vr_rotate_sphere_pans_exactly() {
let img = picture(64, 32);
let mut e = inst("vr_rotate_sphere");
setf(&mut e, "pan", 360.0);
assert!(max_diff(&run("vr", &e, &img, &cx()), &img) < 1e-4);
setf(&mut e, "pan", 90.0);
let o = run("vr", &e, &img, &cx());
// a quarter turn is a 16-pixel horizontal shift (with wrap)
let mut moved = 0.0f32;
for y in 4..28 {
for x in 0..64 {
let a = o.get(x, y);
let b = img.get((x + 64 - 16) % 64, y);
let c = img.get((x + 16) % 64, y);
moved = moved.max((0..3).map(|k| (a[k] - b[k]).abs()).fold(0.0, f32::max).min((0..3).map(|k| (a[k] - c[k]).abs()).fold(0.0, f32::max)));
}
}
assert!(moved < 2e-3, "{moved}");
// VR Blur is seamless across the ±180° edge
let mut e = inst("vr_blur");
setf(&mut e, "blurriness", 8.0);
let o = run("vr_blur", &e, &img, &cx());
let seam = |i: &Image| (0..3).map(|k| (i.get(0, 16)[k] - i.get(63, 16)[k]).abs()).fold(0.0, f32::max);
assert!(seam(&o) < seam(&img) * 0.3, "{} vs {}", seam(&o), seam(&img));
// Plane to Sphere: the flat picture lands in front (lon 0), nothing behind
let o = run("vr_plane_to_sphere", &inst("vr_plane_to_sphere"), &img, &cx());
assert!(o.get(32, 16)[3] > 0.99 && o.get(0, 16)[3] == 0.0);
// stereoscopic layouts process each half
let mut e = inst("vr_rotate_sphere");
setf(&mut e, "pan", 90.0);
set(&mut e, "frame_layout", ParamValue::Choice(1));
let o = run("vr", &e, &img, &cx());
assert_eq!((o.w, o.h), (64, 32));
}
#[test]
fn bokeh_and_focus_blur() {
let mut img = solid(48, 48, [0.0, 0.0, 0.0]);
img.px[(24 * 48 + 24) * 4..(24 * 48 + 24) * 4 + 4].copy_from_slice(&[1.0, 1.0, 1.0, 1.0]);
let mut e = inst("bokeh_blur");
setf(&mut e, "amount", 8.0);
set(&mut e, "shape", ParamValue::Choice(2)); // square
setf(&mut e, "rotation", 45.0); // axis-aligned, half-side 8/√2
let o = run("bokeh", &e, &img, &cx());
// the bright point becomes a square: lit at (+5, +5), dark at (+7, 0) where a disc would be lit
assert!(o.get(29, 29)[0] > 1e-4 && o.get(31, 24)[0] < 1e-6, "{:?} {:?}", o.get(29, 29), o.get(31, 24));
set(&mut e, "shape", ParamValue::Choice(0));
let o = run("bokeh", &e, &img, &cx());
assert!(o.get(31, 24)[0] > 1e-4);
// Focus Blur: the focus point stays sharp
let pic = picture(64, 64);
let mut e = inst("focus_blur");
setf(&mut e, "size", 20.0);
setf(&mut e, "feather", 10.0);
setf(&mut e, "amount", 20.0);
let o = run("focus", &e, &pic, &cx());
assert!(max_diff(&o, &pic) > 0.01);
let (a, b) = (o.get(32, 32), pic.get(32, 32));
assert!((0..4).all(|k| close(a[k], b[k], 1e-4)));
}
#[test]
fn echo_combines_operators() {
let a = Image::filled(1, 1, [0.2, 0.4, 0.1, 1.0]);
let b = Image::filled(1, 1, [0.6, 0.1, 0.3, 1.0]);
let layers = vec![(a.clone(), 1.0), (b.clone(), 0.5)];
let add = temporal::combine_echoes(&layers, 0);
assert!(close(add.get(0, 0)[0], 0.5, 1e-6) && close(add.get(0, 0)[3], 1.0, 1e-6));
let max = temporal::combine_echoes(&layers, 1);
assert!(close(max.get(0, 0)[0], 0.3, 1e-6) && close(max.get(0, 0)[1], 0.4, 1e-6));
let back = temporal::combine_echoes(&layers, 4);
assert_eq!(back.get(0, 0), a.get(0, 0), "the current frame stays in front");
}
/// A clip environment over a synthetic moving picture (for the analysis effects).
struct ClipEnv {
w: usize,
h: usize,
fps: f64,
secs: f64,
now: f64,
key: u64,
seq: (u32, u32),
pic: fn(f64, f64, f64) -> [f32; 3],
}
impl ClipEnv {
fn render(&self, t: f64, scale: f32) -> Image {
let (w, h) = (((self.w as f32 * scale).round() as usize).max(1), ((self.h as f32 * scale).round() as usize).max(1));
let k = self.w as f64 / w as f64;
let mut img = Image::new(w, h);
img.px.par_chunks_mut(w * 4).enumerate().for_each(|(y, row)| {
for x in 0..w {
let c = (self.pic)((x as f64 + 0.5) * k, (y as f64 + 0.5) * k, t);
row[x * 4..x * 4 + 4].copy_from_slice(&[c[0], c[1], c[2], 1.0]);
}
});
img
}
}
impl FxEnv for ClipEnv {
fn frame(&self, dt: f64, _: &EffectInstance) -> Option<Image> {
Some(self.render(((self.now + dt) * self.fps).round() / self.fps, 1.0))
}
fn source_frame(&self, dt: f64, scale: f32) -> Option<Image> {
Some(self.render(((self.now + dt) * self.fps).round() / self.fps, scale))
}
fn track(&self, _: usize) -> Option<Image> {
None
}
fn layer_to_output(&self) -> Affine {
Affine::IDENTITY
}
fn clip_seconds(&self) -> f64 {
self.secs
}
fn clip_offset(&self) -> f64 {
self.now
}
fn frame_rate(&self) -> f64 {
self.fps
}
fn media_timecode(&self) -> String {
String::new()
}
fn file_name(&self) -> String {
String::new()
}
fn sequence_name(&self) -> String {
String::new()
}
fn sequence_size(&self) -> (u32, u32) {
self.seq
}
fn source_size(&self) -> (u32, u32) {
(self.w as u32, self.h as u32)
}
fn clip_key(&self) -> u64 {
self.key
}
}
fn texture(x: f64, y: f64) -> [f32; 3] {
let v = 0.5 + 0.45 * fbm(x as f32 / 9.0, y as f32 / 9.0, 0.3, 4.0, 11);
let l = filmcraft_color::srgb_to_linear(v.clamp(0.0, 1.0));
[l, l, l]
}
/// Camera jitter at time t: a few pixels, changing every frame.
fn jitter(t: f64) -> (f64, f64) {
let f = (t * 24.0).round() as i64;
((hash01(f, 0, 0, 3) as f64 - 0.5) * 8.0, (hash01(f, 1, 0, 3) as f64 - 0.5) * 8.0)
}
fn shaky(x: f64, y: f64, t: f64) -> [f32; 3] {
let (dx, dy) = jitter(t);
texture(x + dx, y + dy)
}
#[test]
fn posterize_time_and_echo_read_other_frames() {
fn moving(x: f64, _y: f64, t: f64) -> [f32; 3] {
let v = if (x - t * 100.0).abs() < 4.0 { 1.0 } else { 0.0 };
[v, v, v]
}
let mk = |now: f64| ClipEnv { w: 64, h: 8, fps: 24.0, secs: 1.0, now, key: 77, seq: (64, 8), pic: moving };
// 12 fps: frames 0.0 and 1/24 show the same picture; 2/24 is new
let e = inst("posterize_time");
let at = |now: f64| {
let env = mk(now);
let mut img = env.render(now, 1.0);
crate::effects::apply(&mut img, &e, &FxCtx { seconds: now, env: Some(&env), ..cx() });
img
};
assert_eq!(at(0.0).px, at(1.0 / 24.0).px);
assert_ne!(at(0.0).px, at(2.0 / 24.0).px);
// Echo (one echo a frame back, Add): two bars
let env = mk(0.5);
let mut img = env.render(0.5, 1.0);
let mut e = inst("echo");
setf(&mut e, "time", -0.1);
setf(&mut e, "decay", 0.5);
crate::effects::apply(&mut img, &e, &FxCtx { seconds: 0.5, env: Some(&env), ..cx() });
assert!(close(img.get(50, 4)[0], 1.0, 1e-5), "current bar {:?}", img.get(50, 4));
assert!(close(img.get(40, 4)[0], 0.5, 1e-5), "echo bar {:?}", img.get(40, 4));
}
#[test]
fn warp_stabilizer_removes_camera_jitter() {
let (w, h) = (192, 128);
let mk = |now: f64| ClipEnv { w, h, fps: 24.0, secs: 1.0, now, key: 0xC0FFEE, seq: (w as u32, h as u32), pic: shaky };
let mut e = inst("warp_stabilizer");
set(&mut e, "result", ParamValue::Choice(1)); // No Motion
set(&mut e, "framing", ParamValue::Choice(0)); // Stabilize Only
clear_stabilizer_cache();
let frames: Vec<(Image, Image)> = [3usize, 9, 15, 21]
.iter()
.map(|&f| {
let now = f as f64 / 24.0;
let env = mk(now);
let raw = env.render(now, 1.0);
let mut st = raw.clone();
crate::effects::apply(&mut st, &e, &FxCtx { seconds: now, env: Some(&env), ..cx() });
(raw, st)
})
.collect();
// compare the interior of every pair of frames: stabilised frames agree, raw ones don't
let interior = |a: &Image, b: &Image| {
let mut s = 0.0f64;
let mut n = 0;
for y in 24..h - 24 {
for x in 24..w - 24 {
s += (a.get(x, y)[0] - b.get(x, y)[0]).abs() as f64;
n += 1;
}
}
s / n as f64
};
let (mut raw_err, mut st_err) = (0.0, 0.0);
for i in 1..frames.len() {
raw_err += interior(&frames[0].0, &frames[i].0);
st_err += interior(&frames[0].1, &frames[i].1);
}
assert!(st_err < raw_err * 0.25, "stabilised {st_err} vs raw {raw_err}");
// Auto-scale framing hides the borders
set(&mut e, "framing", ParamValue::Choice(2));
let env = mk(9.0 / 24.0);
let mut st = env.render(9.0 / 24.0, 1.0);
crate::effects::apply(&mut st, &e, &FxCtx { seconds: 9.0 / 24.0, env: Some(&env), ..cx() });
assert!(st.px.as_chunks::<4>().0.iter().all(|p| p[3] > 0.99), "auto-scale leaves no border");
// the path cache is reused
assert!(stabilizer_path(&env, 0.0, filmcraft_project::TrackMethod::Position, false).is_some());
}
#[test]
fn auto_reframe_follows_the_subject() {
fn subject(x: f64, y: f64, _t: f64) -> [f32; 3] {
// a textured blob on the right third of a flat frame
let d = ((x - 150.0).powi(2) + (y - 45.0).powi(2)).sqrt();
if d < 20.0 { texture(x, y) } else { [0.2, 0.2, 0.2] }
}
// 16:9 source in a 9:16 sequence → zoom to fill the height and pan to the subject
let env = ClipEnv { w: 160, h: 90, fps: 24.0, secs: 1.0, now: 0.5, key: 0xBEEF, seq: (90, 160), pic: subject };
let mut img = env.render(0.5, 1.0);
crate::effects::apply(&mut img, &inst("auto_reframe"), &FxCtx { seconds: 0.5, env: Some(&env), ..cx() });
// the layer centre now shows the subject side of the frame (texture, not the flat grey)
let c = img.get(80, 45);
assert!((c[0] - 0.2).abs() > 1e-3 || (img.get(85, 40)[0] - 0.2).abs() > 1e-3, "{c:?}");
// same aspect → nothing to do
let env = ClipEnv { seq: (160, 90), key: 0xBEF0, ..env };
let raw = env.render(0.5, 1.0);
let mut img = raw.clone();
crate::effects::apply(&mut img, &inst("auto_reframe"), &FxCtx { seconds: 0.5, env: Some(&env), ..cx() });
assert_eq!(img.px, raw.px);
}
// ------------------------------------------------------------------ through the compositor
mod pipeline {
use super::*;
use crate::{RenderOptions, SourceMap, render_sequence};
use filmcraft_media::generators::GeneratorSource;
use filmcraft_media::{DemoScene, Generator, MediaSource, SharedSource};
use filmcraft_project::{ItemId, ItemKind, Label, MediaClip, MediaRef, SequenceSettings, TrackKind};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, TimeRange};
fn add(p: &mut Project, map: &mut SourceMap, g: GeneratorSource) -> ItemId {
let info = g.info().clone();
let generator = g.generator.clone();
let id = p.add_item(
&info.name.clone(),
Label::Iris,
ItemKind::Media(MediaClip {
media: MediaRef::Generator(generator),
info,
interpret: Default::default(),
mark_in: None,
mark_out: None,
markers: vec![],
offline: false,
proxy: None,
identity: None,
}),
None,
);
map.0.insert(id, Arc::new(g) as SharedSource);
id
}
fn place(p: &mut Project, seq: ItemId, track: usize, item: ItemId, fx: Vec<EffectInstance>) {
let r = FrameRate::FPS_24;
let mut ti = p.make_track_item(item, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, r.tick_of(48)), r).unwrap();
ti.scale_to_frame = true;
for (i, e) in fx.into_iter().enumerate() {
ti.effects.insert(i, e);
}
p.sequence_mut(seq).unwrap().video_tracks[track].items.push(ti);
}
#[test]
fn temporal_and_track_effects_render_through_the_sequence() {
let mut p = Project::new("vfx");
let mut map = SourceMap::default();
let ocean = add(&mut p, &mut map, GeneratorSource::demo(DemoScene::OceanSunset));
let white = add(
&mut p,
&mut map,
GeneratorSource::new(Generator::ColorMatte { color: [1.0, 1.0, 1.0, 1.0] }, 64, 36, FrameRate::FPS_24, Tick(10 * TICKS_PER_SECOND)),
);
let seq = p.new_sequence("s", SequenceSettings { width: 64, height: 36, frame_rate: FrameRate::FPS_24, ..Default::default() }, 2, 0, None);
let mut pt = inst("posterize_time");
setf(&mut pt, "rate", 6.0);
place(&mut p, seq, 0, ocean, vec![pt]);
let r = FrameRate::FPS_24;
let a = render_sequence(&p, seq, r.tick_of(1), RenderOptions::default(), &map);
let b = render_sequence(&p, seq, r.tick_of(3), RenderOptions::default(), &map);
assert_eq!(a.px, b.px, "frames 1 and 3 share the 6 fps frame");
let c0 = render_sequence(&p, seq, r.tick_of(0), RenderOptions::default(), &map);
assert_eq!(a.px, c0.px);
// Track Matte Key on V2 (white) using V1's luma
let mut tm = inst("track_matte");
set(&mut tm, "matte", ParamValue::Choice(1));
set(&mut tm, "composite", ParamValue::Choice(1));
place(&mut p, seq, 1, white, vec![tm]);
let img = render_sequence(&p, seq, r.tick_of(0), RenderOptions::default(), &map);
assert!(img.px.iter().all(|v| v.is_finite()));
assert!(max_diff(&img, &c0) > 1e-3, "the matted white layer shows over V1");
// Echo through the compositor stays finite and differs from the plain frame
let mut p2 = Project::new("echo");
let mut map2 = SourceMap::default();
let ocean2 = add(&mut p2, &mut map2, GeneratorSource::demo(DemoScene::OceanSunset));
let seq2 = p2.new_sequence("s", SequenceSettings { width: 64, height: 36, frame_rate: FrameRate::FPS_24, ..Default::default() }, 1, 0, None);
let mut echo = inst("echo");
setf(&mut echo, "time", -0.5);
setf(&mut echo, "count", 2.0);
set(&mut echo, "operator", ParamValue::Choice(6));
place(&mut p2, seq2, 0, ocean2, vec![echo]);
let e = render_sequence(&p2, seq2, r.tick_of(30), RenderOptions::default(), &map2);
assert!(e.px.iter().all(|v| v.is_finite()));
}
}
#[test]
fn transform_shutter_uses_the_sequence_frame_rate_without_env() {
// An adjustment layer has no env: the shutter must still span one 60 fps frame at 360°, so
// the frames just before the motion starts and just after it ends stay sharp (#1088).
let mut e = inst("transform");
set(&mut e, "shutter_override", ParamValue::Bool(true));
setf(&mut e, "shutter_angle", 360.0);
let mut a = Keyframe::new(Tick::from_seconds_f64(1.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)));
a.interp = Interpolation::Linear;
let mut b = Keyframe::new(Tick::from_seconds_f64(2.0), ParamValue::Vec2(filmcraft_geom::Vec2::new(40.0, 16.0)));
b.interp = Interpolation::Linear;
e.params.insert("position".into(), Param { value: ParamValue::Vec2(filmcraft_geom::Vec2::new(8.0, 16.0)), keyframes: vec![a, b] });
let img = picture(32, 32);
let mut off = e.clone();
set(&mut off, "shutter_override", ParamValue::Bool(false));
let delta = |frame: i32| {
let s = f64::from(frame) / 60.0;
let cx = FxCtx { t: Tick::from_seconds_f64(s), seconds: s, frame_rate: 60.0, ..cx() };
max_diff(&run("transform", &off, &img, &cx), &run("transform", &e, &img, &cx))
};
for frame in [59, 121] {
assert!(delta(frame) < 1e-4, "frame {frame} is blurred outside the motion ({})", delta(frame));
}
for frame in [60, 90, 120] {
assert!(delta(frame) > 1e-3, "frame {frame} moves but is sharp");
}
}