Files
effectcraft/crates/render/src/tests.rs
T

1544 lines
78 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.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, MatteKind, Project, Solid, TrackMatte};
use effectcraft_time::{FrameRate, Tick};
use crate::render_frame;
mod audio_fx;
mod auto;
mod aux_channels;
mod collapse;
mod color;
mod footage_size;
mod frame_blend;
mod guides;
mod mask_blur;
mod mask_expansion;
mod mask_feather;
mod motion_tile;
mod nested;
mod paint;
mod remap;
mod roi;
mod styles;
mod time;
fn setup() -> (Project, ItemId, Comp) {
let mut p = Project::default();
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32) -> effectcraft_project::Layer {
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
}
#[test]
fn solid_fills_comp() {
let (mut p, cid, comp) = setup();
let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert_eq!(img.get(10, 10), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(img.get(199, 99), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn position_keyframes_move_layer() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 1.0, 0.0], 20, 20);
let pos = l.props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 50.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([180.0, 50.0, 0.0]))];
p.comp_mut(cid).unwrap().layers.push(l);
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(a.get(20, 50)[3] > 0.99 && a.get(100, 50)[3] < 0.01);
let b = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
assert!(b.get(100, 50)[3] > 0.99 && b.get(20, 50)[3] < 0.01);
}
#[test]
fn opacity_and_blend_modes() {
let (mut p, cid, comp) = setup();
let bottom = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
let mut top = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
top.blend_mode = BlendMode::Multiply;
let c = p.comp_mut(cid).unwrap();
c.layers.push(top);
c.layers.push(bottom);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!((img.get(50, 50)[0] - 0.25).abs() < 1e-4);
}
#[test]
fn half_resolution() {
let (mut p, cid, comp) = setup();
let l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 0.5);
assert_eq!((img.width, img.height), (100, 50));
assert!((img.get(50, 25)[3] - 1.0).abs() < 1e-4);
}
#[test]
fn mask_cuts_layer() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(50, 50)[3] > 0.99);
assert!(img.get(150, 50)[3] < 0.01);
}
#[test]
fn first_intersect_and_darken_keep_their_rectangle() {
let render = |modes: &[MaskMode], centers: &[[f64; 2]]| {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
for (i, (mode, center)) in modes.iter().zip(centers).enumerate() {
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), &format!("Mask {}", i + 1), ShapePath::rect(*center, 40.0, 40.0), *mode, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
}
p.comp_mut(cid).unwrap().layers.push(l);
render_frame(&p, cid, Tick::ZERO, 1.0)
};
// A first Intersect or Darken has nothing to combine with, so it covers like Add.
// Lighten and Difference already do; Subtract still starts from the whole layer.
for mode in [MaskMode::Intersect, MaskMode::Darken, MaskMode::Add, MaskMode::Lighten, MaskMode::Difference] {
let img = render(&[mode], &[[50.0, 50.0]]);
assert!(img.get(50, 50)[3] > 0.99, "{mode:?} keeps the rectangle");
assert!(img.get(150, 50)[3] < 0.01, "{mode:?} stays outside it");
}
let hole = render(&[MaskMode::Subtract], &[[50.0, 50.0]]);
assert!(hole.get(50, 50)[3] < 0.01, "subtract still cuts its rectangle");
assert!(hole.get(150, 50)[3] > 0.99, "subtract leaves the rest of the layer");
// Add, then Intersect with a shifted rectangle: only the overlap remains.
let overlap = render(&[MaskMode::Add, MaskMode::Intersect], &[[50.0, 50.0], [70.0, 50.0]]);
assert!(overlap.get(50, 50)[3] > 0.99, "overlap stays");
assert!(overlap.get(35, 50)[3] < 0.01, "the first rectangle's exclusive side is cut");
assert!(overlap.get(85, 50)[3] < 0.01, "the second rectangle's exclusive side is cut");
let darkened = render(&[MaskMode::Add, MaskMode::Darken], &[[50.0, 50.0], [70.0, 50.0]]);
assert!(darkened.get(50, 50)[3] > 0.99 && darkened.get(35, 50)[3] < 0.01 && darkened.get(85, 50)[3] < 0.01);
// A later disjoint Add still contributes after a first Intersect.
let both = render(&[MaskMode::Intersect, MaskMode::Add], &[[50.0, 50.0], [150.0, 50.0]]);
assert!(both.get(50, 50)[3] > 0.99 && both.get(150, 50)[3] > 0.99);
assert!(both.get(100, 50)[3] < 0.01);
}
#[test]
fn track_matte_alpha() {
let (mut p, cid, comp) = setup();
let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
matte.switches.video = false;
let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
fill.track_matte = Some(TrackMatte { layer: matte.id, kind: MatteKind::Alpha });
let c = p.comp_mut(cid).unwrap();
c.layers.push(matte);
c.layers.push(fill);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(50, 50)[2] > 0.99);
assert!(img.get(150, 50)[3] < 0.01);
}
/// Every matte kind with an opaque white 50×50 matte at the left of a 200×100 comp: the plain
/// kinds keep the fill only inside the matte, the inverted kinds only outside it (#864).
#[test]
fn track_matte_kinds_keep_and_invert() {
for (kind, inside) in [(MatteKind::Alpha, true), (MatteKind::AlphaInverted, false), (MatteKind::Luma, true), (MatteKind::LumaInverted, false)] {
let (mut p, cid, comp) = setup();
let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
matte.switches.video = false;
let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
fill.track_matte = Some(TrackMatte { layer: matte.id, kind });
let c = p.comp_mut(cid).unwrap();
c.layers.push(matte);
c.layers.push(fill);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let (a_in, a_out) = (img.get(50, 50)[3], img.get(150, 50)[3]);
if inside {
assert!(a_in > 0.99 && a_out < 0.01, "{kind:?}: {a_in} inside, {a_out} outside");
assert!(img.get(50, 50)[2] > 0.99, "{kind:?} keeps the fill's colour");
} else {
assert!(a_in < 0.01 && a_out > 0.99, "{kind:?}: {a_in} inside, {a_out} outside");
assert!(img.get(150, 50)[2] > 0.99, "{kind:?} keeps the fill's colour");
}
}
}
/// An adjustment layer with a track matte applies its effects only where the matte allows: an
/// Invert over a 0.25 grey with an opaque white 50×50 matte at the left of the comp (#1201).
#[test]
fn track_matte_limits_an_adjustment_layer() {
for (kind, inside) in [(MatteKind::Alpha, true), (MatteKind::AlphaInverted, false), (MatteKind::Luma, true), (MatteKind::LumaInverted, false)] {
let (mut p, cid, comp) = setup();
let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
matte.switches.video = false;
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
adj.switches.adjustment = true;
add_effect_200(&mut p, &mut adj, "ec.channel.invert", &[]);
adj.track_matte = Some(TrackMatte { layer: matte.id, kind });
let bg = solid(&mut p, &comp, [0.25, 0.25, 0.25], 200, 100);
let c = p.comp_mut(cid).unwrap();
c.layers.push(matte);
c.layers.push(adj);
c.layers.push(bg);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let (g_in, g_out) = (img.get(50, 50)[0], img.get(150, 50)[0]);
let (want_in, want_out) = if inside { (0.75, 0.25) } else { (0.25, 0.75) };
assert!((g_in - want_in).abs() < 1e-4 && (g_out - want_out).abs() < 1e-4, "{kind:?}: {g_in} inside, {g_out} outside");
assert_eq!(img.get(150, 50)[3], 1.0, "{kind:?} keeps the comp opaque");
}
}
/// An adjustment layer's blend mode blends the adjusted comp onto the comp below, inside its
/// footprint: with no effects, Screen screens a 0.25 grey with itself, Multiply darkens it,
/// Normal leaves it, and Opacity mixes toward the result (#2618).
#[test]
fn adjustment_layer_blends_in_its_mode() {
for (mode, opacity, want) in
[(BlendMode::Normal, 100.0, 0.25), (BlendMode::Screen, 100.0, 0.4375), (BlendMode::Multiply, 100.0, 0.0625), (BlendMode::Screen, 50.0, 0.34375)]
{
let (mut p, cid, comp) = setup();
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 100, 100);
adj.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
adj.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(opacity);
adj.switches.adjustment = true;
adj.blend_mode = mode;
let bg = solid(&mut p, &comp, [0.25, 0.25, 0.25], 200, 100);
let c = p.comp_mut(cid).unwrap();
c.layers.push(adj);
c.layers.push(bg);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let (inside, outside) = (img.get(50, 50), img.get(150, 50));
assert!((inside[0] - want).abs() < 1e-4, "{mode:?} {opacity}: {inside:?}");
assert!((outside[0] - 0.25).abs() < 1e-4, "{mode:?} {opacity}: outside the footprint {outside:?}");
assert_eq!(inside[3], 1.0, "{mode:?} keeps the comp opaque");
}
}
#[test]
fn shape_layer_draws_fill_and_stroke() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [40.0, 40.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]);
let stroke = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 4.0);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, stroke, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let centre = img.get(100, 50);
assert!(centre[0] > 0.99 && centre[1] < 0.01, "{centre:?}");
let edge = img.get(80, 50);
assert!(edge[1] > 0.5, "stroke on top {edge:?}");
}
#[test]
fn text_layer_renders_glyphs() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 60.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 80.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let covered = img.data.iter().filter(|px| px[3] > 0.5).count();
assert!(covered > 300, "{covered}");
}
#[test]
fn precomp_and_3d_render() {
let (mut p, cid, comp) = setup();
let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let s = solid(&mut p, &inner, [1.0, 0.5, 0.0], 100, 100);
p.comp_mut(iid).unwrap().layers.push(s);
let mut pre = build::layer(&mut p, &comp, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
pre.switches.three_d = true;
p.comp_mut(cid).unwrap().layers.push(pre);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
// A 3D layer at z = 0 with the default camera looks exactly like 2D.
let c = img.get(100, 50);
assert!((c[0] - 1.0).abs() < 1e-3 && (c[1] - 0.5).abs() < 1e-3, "{c:?}");
assert!(img.get(10, 50)[3] < 0.01);
}
#[test]
fn effects_run_in_pipeline() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
let spec = effectcraft_effects::find("ec.color.tint").unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), "Tint", [50.0, 50.0]);
p.next_id = next;
if let Some(pr) = g.prop_mut("white") {
pr.value = Value::Color([0.0, 1.0, 0.0, 1.0]);
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let c = img.get(100, 50);
assert!(c[1] > 0.99 && c[0] < 0.01, "{c:?}");
}
/// After Effects 2026, sRGB working space (not linearised), Tint at its defaults on full-frame
/// solids: the effect sees the encoded solid colour and weights it Rec. 601.
#[test]
fn tint_in_an_srgb_working_space_matches_after_effects_greys() {
for (col, want) in [([1.0, 0.0, 0.0], 76.0), ([0.0, 1.0, 0.0], 150.0), ([0.0, 0.0, 1.0], 29.0), ([0.5, 0.5, 0.5], 128.0)] {
let (mut p, cid, comp) = setup();
p.settings.working_space = Some(effectcraft_color::ColorSpace::Srgb);
let mut l = solid(&mut p, &comp, col, 200, 100);
add_effect(&mut p, &mut l, "ec.color.tint", [200.0, 100.0], &[]);
p.comp_mut(cid).unwrap().layers.push(l);
let o = render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 50);
let want = want / 255.0;
assert!(o[..3].iter().all(|v| (v - want).abs() <= 1.0 / 255.0), "{col:?}: {o:?} vs {want}");
}
}
/// Card Dance's Comp Camera through the default comp camera reproduces a 2D layer in place.
#[test]
fn card_dance_comp_camera_matches_the_default_view() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.2, 0.6, 1.0], 50, 50);
add_effect(&mut p, &mut l, "ec.sim.carddance", [50.0, 50.0], &[("cameraSystem", Value::Enum(2))]);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let c = img.get(100, 50);
assert!((c[0] - 0.2).abs() < 0.02 && (c[2] - 1.0).abs() < 0.02 && c[3] > 0.99, "{c:?}");
assert!(img.get(10, 50)[3] < 0.01);
}
/// A black 2D solid with CC Particle World, under a camera layer panned right by `pan` px.
fn particle_world_scene(pan: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
let vals = [("birthRate", Value::Scalar(2.0)), ("particle/particleType", Value::Enum(2)), ("particle/birthSize", Value::Scalar(0.1))];
add_effect(&mut p, &mut l, "ec.sim.ccparticleworld", [200.0, 100.0], &vals);
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None);
for path in ["transform/position", "transform/poi"] {
let pr = cam.props.prop_mut(path).unwrap();
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
}
p.comp_mut(cid).unwrap().layers = vec![cam, l].into();
(p, cid)
}
/// The red-weighted centre of the particles (x).
fn particles_x(img: &crate::Image) -> f32 {
let (mut m, mut sx) = (0.0, 0.0);
for y in 0..img.height {
for x in 0..img.width {
let r = img.get(x as i64, y as i64)[0];
m += r;
sx += r * x as f32;
}
}
assert!(m > 1.0, "particles drawn");
sx / m
}
/// #397: CC Particle World on a 2D layer is seen through the comp's camera layer, and the layer
/// cache notices the camera moving.
#[test]
fn particle_world_follows_the_comp_camera() {
let t = Tick::from_seconds_f64(1.0);
let (p, cid) = particle_world_scene(0.0);
let (q, _) = particle_world_scene(40.0);
let cache = crate::LayerCache::default();
let here = render_cached(&p, cid, t, Some(&cache));
let panned = render_cached(&q, cid, t, Some(&cache));
assert_same(&panned, &render_cached(&q, cid, t, None), "cached render after the camera moved");
let (a, b) = (particles_x(&here), particles_x(&panned));
assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}");
}
/// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a
/// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %.
fn burst_scene(cut: Option<f64>, start: f64, stretch: f64) -> (Project, ItemId) {
burst_scene_keys(cut, start, stretch, false)
}
/// [`burst_scene`]; `hold`: Hold keys, 3 until `cut` and 0 from `cut` exactly.
fn burst_scene_keys(cut: Option<f64>, start: f64, stretch: f64, hold: bool) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
let vals = [("birthRate", Value::Scalar(3.0)), ("longevity", Value::Scalar(1.0)), ("particle/particleType", Value::Enum(2))];
add_effect(&mut p, &mut l, "ec.sim.ccparticleworld", [200.0, 100.0], &vals);
if let Some(cut) = cut {
let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c {
effectcraft_project::Node::Group(g) => Some(g),
_ => None,
});
let fx = fx.unwrap();
let rate = fx.prop_mut("birthRate").unwrap();
let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v));
rate.keys = if hold { vec![key(0.0, 3.0).hold(), key(cut, 0.0).hold()] } else { vec![key(0.0, 3.0), key(cut, 3.0), key(cut + 1.0 / 30.0, 0.0)] };
}
l.start_time = Tick::from_seconds_f64(start);
l.in_point = l.start_time;
l.out_point = Tick::from_seconds_f64(4.0);
l.stretch = stretch;
p.comp_mut(cid).unwrap().layers = vec![l].into();
(p, cid)
}
/// Total light in a frame.
fn light(img: &crate::Image) -> f32 {
img.data.iter().map(|p| p[0] + p[1] + p[2]).sum()
}
/// #1373: a Birth Rate keyed down to 0 stops new births; the particles already born live on
/// until their Longevity runs out (they vanished the frame the rate reached 0).
#[test]
fn particle_world_keeps_a_burst_after_birth_rate_drops_to_zero() {
let (keyed, cid) = burst_scene(Some(0.5), 0.0, 100.0);
let (constant, _) = burst_scene(None, 0.0, 100.0);
let at = Tick::from_seconds_f64;
assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), "during the burst");
let (k, c) = (light(&render_cached(&keyed, cid, at(0.9), None)), light(&render_cached(&constant, cid, at(0.9), None)));
assert!(k > 1.0 && k < c, "the burst is still drawn, with no new births: {k} vs {c}");
// Longevity 1 s (± 20 %): gone by 1.9 s.
assert_eq!(light(&render_cached(&keyed, cid, at(1.9), None)), 0.0);
}
/// The burst's history: moving an earlier key changes a later frame whose Birth Rate is the
/// same (0), cached or not, and offset or stretched layers sample their own layer times.
#[test]
fn particle_world_birth_rate_history_with_the_layer_cache_and_time_mapping() {
let at = Tick::from_seconds_f64;
let (long, cid) = burst_scene(Some(0.5), 0.0, 100.0);
let (short, _) = burst_scene(Some(0.25), 0.0, 100.0);
let cache = crate::LayerCache::default();
let a = render_cached(&long, cid, at(0.9), Some(&cache));
let b = render_cached(&short, cid, at(0.9), Some(&cache));
assert_same(&b, &render_cached(&short, cid, at(0.9), None), "cached render after an earlier key moved");
assert!(light(&b) > 1.0 && light(&b) < light(&a), "a shorter burst: {} vs {}", light(&b), light(&a));
let plain = render_cached(&long, cid, at(0.9), None);
let (late, _) = burst_scene(Some(0.5), 0.5, 100.0);
assert_same(&render_cached(&late, cid, at(1.4), None), &plain, "a layer starting at 0.5 s");
let (slow, _) = burst_scene(Some(0.5), 0.0, 200.0);
assert_same(&render_cached(&slow, cid, at(1.8), None), &plain, "a layer stretched 200 %");
}
/// The Birth Rate history is read at exact layer times: under a fractional stretch, mapping a
/// layer time to comp time and back rounds down a tick, so a Hold cut at 0.5 s read its
/// previous value at the step starting there (six extra births).
#[test]
fn particle_world_reads_a_hold_cut_at_its_exact_layer_time_under_any_stretch() {
use crate::{EffectHost, FxHost, NoFootage, RenderOpts, Renderer};
let plain = {
let (p, cid) = burst_scene_keys(Some(0.5), 0.0, 100.0, true);
render_cached(&p, cid, Tick::from_seconds_f64(0.9), None)
};
assert!(light(&plain) > 1.0, "the burst is drawn");
for stretch in [33.3, 133.33, 200.0] {
let (p, cid) = burst_scene_keys(Some(0.5), 0.0, stretch, true);
let c = p.comp(cid).unwrap();
let l = &c.layers[0];
let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO);
let renderer = Renderer::new(&p, &NoFootage, RenderOpts::default());
let host = FxHost { r: &renderer, ctx: &ctx, layer: l, origin: [0.0; 2], index: Default::default(), original: None };
let rate = |t: f64| host.param_at(0, "birthRate", t).unwrap().as_f64();
assert_eq!((rate(0.5 - 1.0 / 60.0), rate(0.5)), (3.0, 0.0), "stretch {stretch} %");
assert!(!host.param_is_static(0, "birthRate"));
let t = l.comp_time(Tick::from_seconds_f64(0.9));
assert_same(&render_cached(&p, cid, t, None), &plain, &format!("stretch {stretch} %"));
}
}
/// An expression that reads a Controller layer's Opacity (÷ 25) before layer time 0.5 s and is
/// 0 after (a stand-in for the expression engine).
struct ControllerRate;
impl crate::eval::ExprHost for ControllerRate {
fn eval(&self, ctx: &crate::eval::EvalCtx, layer: &effectcraft_project::Layer, _: &effectcraft_project::Property, _: &Value) -> Result<Value, String> {
if layer.layer_time(ctx.time).seconds() >= 0.5 {
return Ok(Value::Scalar(0.0));
}
let c = ctx.comp.layers.iter().find(|l| l.name == "Controller").ok_or("no controller")?;
let opacity = c.props.prop("transform/opacity").ok_or("no opacity")?;
Ok(Value::Scalar(ctx.value(c, opacity).as_f64() / 25.0))
}
}
/// A Birth Rate expression reading another layer: changing that layer changes the history but
/// not the current rate (0) nor this layer's properties, which is all the layer cache key sees.
/// CC Particle World layers with expressions therefore aren't layer cached.
#[test]
fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cache() {
let scene = |opacity: f64| {
let (mut p, cid) = burst_scene(None, 0.0, 100.0);
let comp = p.comp(cid).unwrap().clone();
let mut ctl = solid(&mut p, &comp, [0.0, 0.0, 0.0], 10, 10);
ctl.name = "Controller".into();
ctl.switches.video = false;
ctl.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(opacity);
let c = p.comp_mut(cid).unwrap();
let fx = c.layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|n| match n {
effectcraft_project::Node::Group(g) => Some(g),
_ => None,
});
fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "controller".into(), enabled: true });
c.layers.push(ctl);
(p, cid)
};
let render = |p: &Project, cid: ItemId, cache: Option<&crate::LayerCache>| {
let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default());
r.expr = Some(&ControllerRate);
r.cache = cache;
r.comp_frame(cid, Tick::from_seconds_f64(0.9))
};
let ((many, cid), (few, _)) = (scene(75.0), scene(25.0));
let cache = crate::LayerCache::default();
let a = render(&many, cid, Some(&cache));
let b = render(&few, cid, Some(&cache));
assert_same(&b, &render(&few, cid, None), "cached render after the controller changed");
assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a));
}
/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems
/// II, CC Mr. Mercury): Birth Rate `rate`,
/// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s.
fn system_scene(id: &str, rate: Option<f64>) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
let vals = [("birthRate", Value::Scalar(rate.unwrap_or(3.0))), ("longevity", Value::Scalar(1.0))];
add_effect(&mut p, &mut l, id, [200.0, 100.0], &vals);
if rate.is_none() {
let fx = l.props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c {
effectcraft_project::Node::Group(g) => Some(g),
_ => None,
});
let key = |t: f64, v: f64| Keyframe::new(Tick::from_seconds_f64(t), Value::Scalar(v));
fx.unwrap().prop_mut("birthRate").unwrap().keys = vec![key(0.0, 3.0), key(0.5, 3.0), key(0.5 + 1.0 / 30.0, 0.0)];
}
p.comp_mut(cid).unwrap().layers = vec![l].into();
(p, cid)
}
/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst
/// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate
/// of 0 throughout), and draws as nothing born once Longevity has run out.
#[test]
fn particle_systems_ii_and_mr_mercury_keep_a_burst_after_birth_rate_drops_to_zero() {
let at = Tick::from_seconds_f64;
for id in ["ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] {
let (keyed, cid) = system_scene(id, None);
let (constant, _) = system_scene(id, Some(3.0));
let (none, _) = system_scene(id, Some(0.0));
assert_same(&render_cached(&keyed, cid, at(0.4), None), &render_cached(&constant, cid, at(0.4), None), &format!("{id}: during the burst"));
let (k, z) = (render_cached(&keyed, cid, at(0.9), None), render_cached(&none, cid, at(0.9), None));
assert!((light(&k) - light(&z)).abs() > 1.0, "{id}: the burst is still drawn: {} vs {}", light(&k), light(&z));
assert_same(&render_cached(&keyed, cid, at(1.9), None), &render_cached(&none, cid, at(1.9), None), &format!("{id}: gone after Longevity"));
}
}
/// A Birth Rate expression on any of the stepped particle systems keeps the layer out of the
/// layer cache (its key can't see what the expression returned at earlier times); without one
/// the layer is cached.
#[test]
fn stepped_particle_layers_with_a_birth_rate_expression_are_not_layer_cached() {
for id in ["ec.sim.ccparticleworld", "ec.sim.ccparticlesystems2", "ec.sim.ccmrmercury"] {
let (mut p, cid) = system_scene(id, Some(2.0));
let key = |p: &Project| {
let c = p.comp(cid).unwrap();
crate::cache::layer_key(&crate::eval::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(0.9)), &c.layers[0], 1.0, false, false)
};
assert!(key(&p).is_some(), "{id}: cached without an expression");
let fx = p.comp_mut(cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.iter_mut().find_map(|c| match c {
effectcraft_project::Node::Group(g) => Some(g),
_ => None,
});
fx.unwrap().prop_mut("birthRate").unwrap().expr = Some(effectcraft_project::Expression { text: "time < 0.5 ? 3 : 0".into(), enabled: true });
assert!(key(&p).is_none(), "{id}: not cached with a Birth Rate expression");
}
}
// ------------------------------------------------------------------------------ layer cache
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default());
r.cache = cache;
r.comp_frame(cid, t)
}
/// #329: a render whose footage reads were called off keeps nothing in the layer cache: a layer
/// made from a frame that came back empty (an effect's layer parameter) would be reused by the
/// next render of that time.
#[test]
fn a_called_off_render_keeps_nothing_in_the_layer_cache() {
let (p, cid) = cache_scene();
let t = Tick::from_seconds_f64(0.5);
let cache = crate::LayerCache::default();
let off = crate::CancellableFootage { inner: &crate::NoFootage, cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) };
let mut r = crate::Renderer::new(&p, &off, crate::RenderOpts::default());
r.cache = Some(&cache);
r.comp_frame(cid, t);
assert_eq!(cache.stats().entries, 0, "a called-off render kept layers");
render_cached(&p, cid, t, Some(&cache));
assert!(cache.stats().entries > 0, "a render that runs to the end keeps its layers");
}
/// [`add_effect`] for a 200×100 layer.
fn add_effect_200(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, vals: &[(&str, Value)]) {
add_effect(p, l, id, [200.0, 100.0], vals);
}
fn add_effect(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, size: [f64; 2], vals: &[(&str, Value)]) {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = p.next_id;
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, size);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap().value = v.clone();
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
/// A comp with a static effected solid, an animated-trim shape, a moving text layer with a blur.
fn cache_scene() -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut bg = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100);
add_effect_200(&mut p, &mut bg, "ec.color.tint", &[("white", Value::Color([1.0, 0.5, 0.0, 1.0]))]);
let mut shape = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
{
let mut ids = Ids(&mut next);
let e = build::shape_ellipse(&mut ids, [60.0, 60.0], [0.0, 0.0]);
let tr = build::shape_trim(&mut ids, 0.0, 0.0, 0.0);
let s = build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], 4.0);
let g = build::shape_group(&mut ids, "Ring", vec![e, tr, s]);
shape.props.sub_mut("contents").unwrap().children.push(g.into());
}
p.next_id = next;
shape.props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(10.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(100.0))];
let mut text = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
text.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "AB".into(), size: 40.0, ..Default::default() }));
text.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([20.0, 70.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([120.0, 70.0, 0.0]))];
add_effect_200(&mut p, &mut text, "ec.blur.gaussian", &[("blurriness", Value::Scalar(6.0))]);
let c = p.comp_mut(cid).unwrap();
c.layers = vec![text, shape, bg].into();
(p, cid)
}
fn assert_same(a: &crate::Image, b: &crate::Image, what: &str) {
assert_eq!((a.width, a.height), (b.width, b.height), "{what}");
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
for c in 0..4 {
assert!((p[c] - q[c]).abs() < 1e-6, "{what}: pixel {i}: {p:?} vs {q:?}");
}
}
}
#[test]
fn cached_frames_match_uncached() {
let (p, cid) = cache_scene();
let cache = crate::LayerCache::default();
for f in [0.0, 0.25, 0.5, 0.5, 1.2, 1.5, 0.25] {
let t = Tick::from_seconds_f64(f);
assert_same(&render_cached(&p, cid, t, Some(&cache)), &render_cached(&p, cid, t, None), &format!("t={f}"));
}
// bg is static, text only moves (transform), the shape is static after 1 s: lots of reuse.
let st = cache.stats();
assert!(st.hits >= 10, "{st:?}");
}
#[test]
fn static_and_transform_only_layers_are_reused() {
let (p, cid) = cache_scene();
let cache = crate::LayerCache::default();
render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache));
let before = cache.stats();
render_cached(&p, cid, Tick::from_seconds_f64(0.4), Some(&cache));
let after = cache.stats();
// bg (static) + text (only its position animates) hit; the shape's trim animates: miss.
assert_eq!(after.hits - before.hits, 2, "{before:?} {after:?}");
assert_eq!(after.misses - before.misses, 1, "{before:?} {after:?}");
}
type Edit = Box<dyn Fn(&mut Project, ItemId)>;
/// Every kind of edit re-renders the affected layer: the cached render of the edited project
/// equals a fresh uncached render, and differs from the unedited frame.
#[test]
fn edits_invalidate_cached_layers() {
let (p, cid) = cache_scene();
let t = Tick::from_seconds_f64(0.5);
let edits: Vec<(&str, Edit)> = vec![
(
"effect param value",
Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.prop_mut("effects/#1/white").unwrap().value = Value::Color([0.0, 1.0, 0.0, 1.0])),
),
(
"effect param keyframes",
Box::new(|p, cid| {
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("effects/#1/blurriness").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(30.0))]
}),
),
("effect disabled", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false)),
("effects switch off", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[0].switches.effects = false)),
(
"effect added",
Box::new(|p, cid| {
let mut l = p.comp_mut(cid).unwrap().layers[2].clone();
add_effect_200(p, &mut l, "ec.channel.invert", &[]);
p.comp_mut(cid).unwrap().layers[2] = l;
}),
),
(
"shape keyframe moved",
Box::new(|p, cid| {
p.comp_mut(cid).unwrap().layers[1].props.prop_mut("contents/group#1/contents/trim/end").unwrap().keys[1].time = Tick::from_seconds_f64(2.0)
}),
),
(
"text changed",
Box::new(|p, cid| {
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("text/sourceText").unwrap().value =
Value::Text(Box::new(TextDoc { text: "XYZ".into(), size: 40.0, ..Default::default() }))
}),
),
(
"solid colour",
Box::new(|p, cid| {
let LayerSource::Solid { item } = p.comp(cid).unwrap().layers[2].source else { panic!("not a solid layer") };
if let ItemKind::Solid(s) = &mut p.item_mut(item).unwrap().kind {
s.color = [0.9, 0.1, 0.1];
}
p.comp_mut(cid).unwrap().layers[2].props.group_mut("effects/#1").unwrap().enabled = false;
}),
),
(
"mask added",
Box::new(|p, cid| {
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
p.comp_mut(cid).unwrap().layers[2].props.sub_mut("masks").unwrap().children.push(m.into());
}),
),
("layer time shifted", Box::new(|p, cid| p.comp_mut(cid).unwrap().layers[1].start_time = Tick::from_seconds_f64(0.3))),
];
for (what, edit) in edits {
let cache = crate::LayerCache::default();
let orig = render_cached(&p, cid, t, Some(&cache));
let mut q = p.clone();
edit(&mut q, cid);
let cached = render_cached(&q, cid, t, Some(&cache));
let fresh = render_cached(&q, cid, t, None);
assert_same(&cached, &fresh, what);
assert!(orig.data.iter().zip(&fresh.data).any(|(a, b)| (a[0] - b[0]).abs() + (a[3] - b[3]).abs() > 1e-3), "{what}: edit had no visible effect");
}
}
#[test]
fn time_dependent_effects_rerender_every_frame() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100);
add_effect_200(&mut p, &mut l, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]);
p.comp_mut(cid).unwrap().layers.push(l);
let cache = crate::LayerCache::default();
let a = render_cached(&p, cid, Tick::from_seconds_f64(0.1), Some(&cache));
let b = render_cached(&p, cid, Tick::from_seconds_f64(0.2), Some(&cache));
assert_same(&b, &render_cached(&p, cid, Tick::from_seconds_f64(0.2), None), "noise at 0.2");
assert!(a != b, "noise must animate");
}
#[test]
fn effects_see_layer_masks() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::None, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
add_effect(&mut p, &mut l, "ec.generate.stroke", [200.0, 100.0], &[("brushSize", Value::Scalar(6.0))]);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(30, 50)[0] > 0.5, "stroke on the mask edge: {:?}", img.get(30, 50));
assert!(img.get(50, 50)[0] < 0.01, "inside untouched");
assert!(img.get(150, 50)[3] > 0.99, "mode None mask does not cut the layer");
}
/// An open mask (the Pen still drawing it) leaves the layer whole; closed, it masks (#290).
#[test]
fn open_masks_make_no_transparency() {
let render = |closed: bool| {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
let path = ShapePath { closed, ..ShapePath::rect([50.0, 50.0], 40.0, 40.0) };
let mut next = p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", path, MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(m.into());
p.comp_mut(cid).unwrap().layers.push(l);
render_frame(&p, cid, Tick::ZERO, 1.0)
};
let open = render(false);
assert!(open.get(50, 50)[3] > 0.99 && open.get(150, 50)[3] > 0.99, "the open mask cut the layer");
let closed = render(true);
assert!(closed.get(50, 50)[3] > 0.99 && closed.get(150, 50)[3] < 0.01, "the closed mask masks");
}
#[test]
fn effects_read_layer_params() {
let (mut p, cid, comp) = setup();
let mut src = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
src.switches.video = false;
let src_id = src.id.0;
let mut top = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
add_effect(&mut p, &mut top, "ec.channel.blend", [200.0, 100.0], &[("blendWithLayer", Value::Layer(Some(src_id)))]);
let c = p.comp_mut(cid).unwrap();
c.layers.push(top);
c.layers.push(src);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let px = img.get(100, 50);
assert!(px[2] > 0.99 && px[0] < 0.01, "{px:?}");
}
/// Records whether the GPU compositor was asked to draw a frame.
struct ProbeAccel(std::sync::atomic::AtomicBool);
impl crate::Accelerator for ProbeAccel {
fn name(&self) -> String {
"probe".into()
}
fn comp_frame(&self, _r: &crate::Renderer, _comp: ItemId, _t: Tick) -> Option<crate::Image> {
self.0.store(true, std::sync::atomic::Ordering::SeqCst);
None // fall back to the CPU result
}
fn supports_effect(&self, _id: &str) -> bool {
false
}
fn effects(&self, _chain: &[crate::FxStep], _buf: &crate::Buf, _levels: Option<f32>) -> Option<crate::Buf> {
None
}
}
#[test]
fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() {
let (mut p, cid, comp) = setup();
p.settings.gpu_acceleration = true;
let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 50, 50);
p.comp_mut(cid).unwrap().layers.push(l);
let asked = |p: &Project, backend: crate::Backend| {
let probe = ProbeAccel(Default::default());
let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts { backend, ..Default::default() });
r.accel = Some(&probe);
let _ = r.comp_frame(cid, Tick::ZERO);
probe.0.load(std::sync::atomic::Ordering::SeqCst)
};
// 2D comp: Auto uses the GPU compositor.
assert!(asked(&p, crate::Backend::Auto));
// A 3D layer: Classic 3D runs on the GPU too (M12.7), so Auto still asks it.
p.comp_mut(cid).unwrap().layers[0].switches.three_d = true;
assert!(asked(&p, crate::Backend::Auto));
assert!(asked(&p, crate::Backend::Gpu));
// Software Only: never.
p.settings.gpu_acceleration = false;
assert!(!asked(&p, crate::Backend::Auto));
}
/// #227: a shape layer's content surrounds its origin, so edge pinning (Turbulent Displace's
/// default Pin All, Wave Warp's All Edges) must measure from comp-sized bounds centred on it;
/// bounds starting at the origin pinned all but the bottom-right quadrant.
#[test]
fn edge_pinning_displaces_all_of_a_centred_shape_layer() {
for (id, vals) in [("ec.distort.turbulentdisplace", vec![]), ("ec.distort.wavewarp", vec![("pinning", Value::Enum(1))])] {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [180.0, 80.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l.clone());
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
add_effect_200(&mut p, &mut l, id, &vals);
p.comp_mut(cid).unwrap().layers = vec![l].into();
let warped = render_frame(&p, cid, Tick::ZERO, 1.0);
// Alpha change per comp quadrant (TL, TR, BL, BR): the rectangle's edges move in each.
let mut q = [0.0f32; 4];
for y in 0..100 {
for x in 0..200 {
q[(x >= 100) as usize + 2 * (y >= 50) as usize] += (plain.get(x, y)[3] - warped.get(x, y)[3]).abs();
}
}
assert!(q.iter().all(|&d| d > 20.0), "{id}: per-quadrant change {q:?}");
}
}
/// Effect points on a shape layer are measured from the top-left of its comp-sized bounds
/// (#227), so the default "layer centre" is where the content's origin is. Measured from the
/// origin they sat at the comp's bottom-right corner: Twirl changed nothing and Bulge only
/// the bottom-right quadrant.
#[test]
fn default_effect_points_sit_at_the_centre_of_a_shape_layer() {
let cases: [(&str, Vec<(&str, Value)>); 2] =
[("ec.distort.twirl", vec![("angle", Value::Scalar(90.0))]), ("ec.distort.bulge", vec![("height", Value::Scalar(2.0))])];
for (id, vals) in cases {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [180.0, 80.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l.clone());
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
add_effect_200(&mut p, &mut l, id, &vals);
p.comp_mut(cid).unwrap().layers = vec![l].into();
let warped = render_frame(&p, cid, Tick::ZERO, 1.0);
// Alpha change per comp quadrant (TL, TR, BL, BR): the top and bottom edges move in each.
let mut q = [0.0f32; 4];
for y in 0..100 {
for x in 0..200 {
q[(x >= 100) as usize + 2 * (y >= 50) as usize] += (plain.get(x, y)[3] - warped.get(x, y)[3]).abs();
}
}
assert!(q.iter().all(|&d| d > 5.0), "{id}: per-quadrant change {q:?}");
// Symmetric about the centre: opposite quadrants change alike.
assert!((q[0] - q[3]).abs() < 0.05 * q[0].max(q[3]) && (q[1] - q[2]).abs() < 0.05 * q[1].max(q[2]), "{id}: {q:?}");
}
}
/// #687: an adjustment layer's footprint (its source through its masks) must not be keyed by
/// its effect stack. Noise on a finishing adjustment changes every frame, and letting that
/// reach the key re-rendered and re-uploaded a full-frame buffer every frame. A moving mask
/// must still re-key it, and the cached buffer must follow the mask.
#[test]
fn adjustment_footprint_key_ignores_the_effect_stack() {
let (mut p, cid, comp) = setup();
let mut adj = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
adj.switches.adjustment = true;
add_effect_200(&mut p, &mut adj, "ec.noise.noise", &[("amount", Value::Scalar(50.0))]);
let key = |p: &Project, t: f64| {
let c = p.comp(cid).unwrap();
crate::cache::footprint_key(&crate::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(t)), &c.layers[0], 1.0, false, false).unwrap()
};
let mut next = p.next_id;
let mut m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::rect([50.0, 50.0], 40.0, 40.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
m.prop_mut("path").unwrap().keys = vec![
Keyframe::new(Tick::ZERO, Value::Path(ShapePath::rect([50.0, 50.0], 40.0, 40.0))),
Keyframe::new(Tick::from_seconds_f64(1.0), Value::Path(ShapePath::rect([150.0, 50.0], 40.0, 40.0))),
];
// Effects alone must not put the shutter in the footprint key. Masks, added below, may.
p.comp_mut(cid).unwrap().layers.push(adj.clone());
let bare = key(&p, 0.0);
p.comp_mut(cid).unwrap().shutter_angle = 90.0;
assert_eq!(bare, key(&p, 0.0), "the comp shutter must not re-key a footprint");
p.comp_mut(cid).unwrap().shutter_angle = 180.0;
p.comp_mut(cid).unwrap().layers[0].props.sub_mut("masks").unwrap().children.push(m.into());
let foot = |p: &Project, t: f64| {
let c = p.comp(cid).unwrap();
let mut r = crate::Renderer::new(p, &crate::NoFootage, crate::RenderOpts::default());
let cache = crate::LayerCache::default();
r.cache = Some(&cache);
let layer = c.layers[0].clone();
let b = r.adjustment_footprint(&crate::EvalCtx::new(p, cid, c, Tick::from_seconds_f64(t)), &layer).unwrap();
b.img.get(50, 50)
};
assert_ne!(key(&p, 0.0), key(&p, 0.5), "an animated mask path must re-key the footprint");
assert_ne!(key(&p, 0.0), key(&p, 1.0));
assert_ne!(foot(&p, 0.0), foot(&p, 1.0), "the moved mask must reach the cached footprint");
assert_eq!(key(&p, 1.0), key(&p, 1.5), "a per-frame effect on the adjustment re-keyed its footprint");
assert_eq!(key(&p, 1.0), key(&p, 3.5), "the footprint key followed the clock past the mask");
assert_eq!(foot(&p, 1.0), foot(&p, 3.5));
let before = key(&p, 0.0);
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("effects/#1/amount").unwrap().value = Value::Scalar(90.0);
assert_eq!(before, key(&p, 0.0), "editing an effect parameter must not re-key the footprint");
}
/// #659: a tiling effect on a text layer tiles across the layer's whole region. The glyphs
/// were cut off at their own bounding box: the effect computed over the comp-sized layer rect
/// but wrote into the painted (glyph-bounds) buffer alone. Tiling shrinks each copy, so the
/// probe is the x-span of the painted pixels, not their count.
#[test]
fn effects_on_text_reach_past_the_text_bounds() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 60.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l.clone());
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
let painted = plain.data.iter().filter(|px| px[3] > 0.5).count();
add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]);
p.comp_mut(cid).unwrap().layers = vec![l].into();
let tiled = render_frame(&p, cid, Tick::ZERO, 1.0);
let (mut lo, mut hi) = (u32::MAX, 0u32);
for y in 0..100 {
for x in 0..200 {
if tiled.get(x as i64, y as i64)[3] > 0.5 {
lo = lo.min(x);
hi = hi.max(x);
}
}
}
assert!(
painted > 50 && hi.saturating_sub(lo) > 100,
"CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}"
);
}
/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past
/// the painted rectangle used to fall off its edge, so the liquid trail never appeared.
#[test]
fn cc_smear_trails_text_past_its_bounds() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 60.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l.clone());
let right = |img: &effectcraft_raster::Image| (0..100).map(|y| (0..200).filter(|x| img.get(*x, y)[3] > 0.5).max().unwrap_or(0)).max().unwrap_or(0);
let plain = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
add_effect_200(
&mut p,
&mut l,
"ec.distort.ccsmear",
&[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))],
);
p.comp_mut(cid).unwrap().layers = vec![l].into();
let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}");
}
/// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the
/// effect's own masks are in effect space too: Set Matte with a centred shape layer as the
/// matte keeps the centre of a full-comp solid (#227).
#[test]
fn layer_parameters_see_a_whole_shape_layer() {
let (mut p, cid, comp) = setup();
let mut shape = build::layer(&mut p, &comp, "Matte", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [100.0, 50.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
shape.props.sub_mut("contents").unwrap().children.push(g.into());
shape.switches.video = false;
let matte_id = shape.id;
let mut s = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
add_effect_200(&mut p, &mut s, "ec.channel.setmatte", &[("takeMatteFromLayer", Value::Layer(Some(matte_id.0)))]);
p.comp_mut(cid).unwrap().layers = vec![s, shape].into();
let f = render_frame(&p, cid, Tick::ZERO, 1.0);
// The matte covers x 50..150, y 25..75: kept inside (all four quadrants), gone outside.
for (x, y) in [(60, 30), (140, 30), (60, 70), (140, 70)] {
assert!(f.get(x, y)[3] > 0.99, "inside at ({x}, {y}): {:?}", f.get(x, y));
}
for (x, y) in [(20, 10), (180, 90), (190, 50)] {
assert!(f.get(x, y)[3] < 0.01, "outside at ({x}, {y}): {:?}", f.get(x, y));
}
}
#[test]
fn set_matte_self_source_and_masks_precede_effects() {
for source in [0, 1] {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
let mut next = p.next_id;
let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(mask.into());
let id = l.id.0;
add_effect_200(&mut p, &mut l, "ec.channel.invert", &[("channel", Value::Enum(effectcraft_effects::INVERT_ALPHA))]);
add_effect_200(
&mut p,
&mut l,
"ec.channel.setmatte",
&[
("takeMatteFromLayer", Value::Layer(Some(id))),
("takeMatteFromLayerSource", Value::Enum(source)),
("compositeMatteWithOriginal", Value::Bool(false)),
],
);
p.comp_mut(cid).unwrap().layers.push(l);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
assert!(img.get(50, 50)[3] > 0.99, "original alpha must survive preceding alpha inversion");
assert!((img.get(150, 50)[3] - if source == 0 { 1.0 } else { 0.0 }).abs() < 0.01, "Source and Masks must remain distinct");
}
}
#[test]
fn cc_composite_restores_stack_input_on_layers_and_adjustments() {
for adjustment in [false, true] {
for mode in [0, 1] {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
l.switches.adjustment = adjustment;
add_effect_200(&mut p, &mut l, "ec.channel.invert", &[]);
add_effect_200(&mut p, &mut l, "ec.channel.cccomposite", &[("compositeOriginal", Value::Enum(mode))]);
p.comp_mut(cid).unwrap().layers.push(l);
if adjustment {
let bg = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
p.comp_mut(cid).unwrap().layers.push(bg);
}
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let want = if mode == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 1.0, 1.0, 1.0] };
assert_eq!(img.get(50, 50), want, "adjustment={adjustment} mode={mode}");
}
}
}
#[test]
fn self_inputs_share_source_semantics_across_effects_and_times() {
use crate::{EffectHost, FxHost, NoFootage, RenderOpts, Renderer};
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
let mut next = p.next_id;
let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
l.props.sub_mut("masks").unwrap().children.push(mask.into());
p.comp_mut(cid).unwrap().layers.push(l.clone());
let c = p.comp(cid).unwrap();
let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO);
let renderer = Renderer::new(&p, &NoFootage, RenderOpts::default());
let host = FxHost { r: &renderer, ctx: &ctx, layer: &l, origin: [0.0; 2], index: Default::default(), original: None };
assert_eq!(host.layer(l.id.0, false).unwrap().buf.img.get(150, 50)[3], 1.0);
assert_eq!(host.layer_masks(l.id.0).unwrap().buf.img.get(150, 50)[3], 0.0);
assert_eq!(host.layer_at(l.id.0, 0.5, false).unwrap().buf.img.get(150, 50)[3], 1.0);
assert!(host.layer(l.id.0, true).is_none(), "full-stack self references must not recurse");
assert!(host.layer_at(l.id.0, 0.5, true).is_none());
assert!(host.layer_at(l.id.0, f64::NAN, false).is_none());
// A second built-in using the shared selector: Invert(red) then Blend(original red).
l.props.sub_mut("masks").unwrap().children.clear();
add_effect_200(&mut p, &mut l, "ec.channel.invert", &[]);
let id = l.id.0;
add_effect_200(
&mut p,
&mut l,
"ec.channel.blend",
&[("blendWithLayer", Value::Layer(Some(id))), ("blendWithLayerSource", Value::Enum(0)), ("blendWithOriginal", Value::Scalar(0.0))],
);
p.comp_mut(cid).unwrap().layers = vec![l].into();
let c = p.comp(cid).unwrap();
let ctx = crate::eval::EvalCtx::new(&p, cid, c, Tick::ZERO);
assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 1).is_some());
assert!(crate::cache::input_key(&ctx, &c.layers[0], 1.0, false, false, 2).is_some());
assert_eq!(render_frame(&p, cid, Tick::ZERO, 1.0).get(50, 50), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn timed_source_reads_preserve_other_layer_masks() {
use effectcraft_effects::EffectHost;
let (mut p, cid, comp) = setup();
let owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
let mut other = solid(&mut p, &comp, [0.0, 1.0, 0.0], 200, 100);
let mut next = p.next_id;
let mask = build::mask(&mut Ids(&mut next), "Left", ShapePath::rect([50.0, 50.0], 80.0, 80.0), MaskMode::Add, [255, 255, 0]);
p.next_id = next;
other.props.sub_mut("masks").unwrap().children.push(mask.into());
p.comp_mut(cid).unwrap().layers = vec![owner.clone(), other.clone()].into();
let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO);
let renderer = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default());
let host = crate::FxHost { r: &renderer, ctx: &ctx, layer: &owner, origin: [0.0; 2], index: Default::default(), original: None };
// Current Source and timed Source had different contracts before this fix. Keep
// the existing timed read (used by Clone/Time effects) masked for other layers.
assert_eq!(host.layer(other.id.0, false).unwrap().buf.img.get(150, 50)[3], 1.0);
let timed = host.layer_at(other.id.0, 0.5, false).unwrap();
assert_eq!(timed.buf.img.get(50, 50)[3], 1.0);
assert_eq!(timed.buf.img.get(150, 50)[3], 0.0);
}
#[test]
fn layer_dependencies_remain_cacheable_and_invalidate_only_related_edits() {
let (mut p, cid, comp) = setup();
let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
let mut source = solid(&mut p, &comp, [0.0, 1.0, 0.0], 200, 100);
let third = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
let unrelated = solid(&mut p, &comp, [0.2, 0.3, 0.4], 200, 100);
add_effect_200(&mut p, &mut source, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(third.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
p.comp_mut(cid).unwrap().layers = vec![owner, source, third, unrelated].into();
let key = |p: &Project, time: f64, limit| {
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx::new(p, cid, comp, Tick::from_seconds_f64(time));
crate::cache::input_key(&ctx, &comp.layers[0], 1.0, false, false, limit).unwrap()
};
let initial = key(&p, 0.0, 1);
let input = key(&p, 0.0, 0);
assert_eq!(initial, key(&p, 0.5, 1), "static dependencies reuse across frames");
let cache = crate::LayerCache::default();
let before = render_cached(&p, cid, Tick::ZERO, Some(&cache));
render_cached(&p, cid, Tick::ZERO, Some(&cache));
assert!(cache.stats().hits > 0, "layer parameters must not disable caching");
let LayerSource::Solid { item: unrelated } = p.comp(cid).unwrap().layers[3].source else { panic!() };
let ItemKind::Solid(s) = &mut p.item_mut(unrelated).unwrap().kind else { panic!() };
s.color = [0.9, 0.8, 0.7];
assert_eq!(initial, key(&p, 0.0, 1), "unrelated edits preserve the owner key");
let LayerSource::Solid { item: third } = p.comp(cid).unwrap().layers[2].source else { panic!() };
let ItemKind::Solid(s) = &mut p.item_mut(third).unwrap().kind else { panic!() };
s.color = [1.0, 0.0, 1.0];
assert_ne!(initial, key(&p, 0.0, 1), "transitive source edits invalidate the owner");
assert_eq!(input, key(&p, 0.0, 0), "effects after an input prefix do not affect its key");
let cached = render_cached(&p, cid, Tick::ZERO, Some(&cache));
let fresh = render_cached(&p, cid, Tick::ZERO, None);
assert_same(&cached, &fresh, "transitive dependency edit");
assert_ne!(before.get(50, 50), fresh.get(50, 50));
let owner_id = p.comp(cid).unwrap().layers[0].id;
p.comp_mut(cid).unwrap().layers[1].props.prop_mut("effects/#1/blendWithLayer").unwrap().value = Value::Layer(Some(owner_id.0));
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx::new(&p, cid, comp, Tick::ZERO);
assert!(crate::cache::layer_key(&ctx, &comp.layers[0], 1.0, false, false).is_none(), "cycles decline caching without recursion");
}
#[test]
fn precomp_dependencies_hash_nested_sources_and_evaluated_animation() {
let (mut p, cid, comp) = setup();
let mut child = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
let LayerSource::Solid { item: source } = child.source else { panic!() };
child.props.prop_mut("transform/opacity").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(50.0))];
let mut nested = comp.clone();
nested.layers = vec![child].into();
let nested_id = p.add_item("Nested", Label::Sandstone, None, ItemKind::Comp(nested.into()));
let pre = build::layer(&mut p, &comp, "Precomp", LayerSource::Comp { item: nested_id }, (200, 100), None);
let mut owner = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(pre.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
p.comp_mut(cid).unwrap().layers = vec![owner, pre].into();
let key = |p: &Project, time| {
let c = p.comp(cid).unwrap();
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, time), &c.layers[0], 1.0, false, false).unwrap()
};
let cache = crate::LayerCache::default();
let before = render_cached(&p, cid, Tick::ZERO, Some(&cache));
let initial = key(&p, Tick::ZERO);
let later = Tick::from_seconds_f64(0.5);
assert_ne!(initial, key(&p, later));
assert_same(&render_cached(&p, cid, later, Some(&cache)), &render_cached(&p, cid, later, None), "animated precomp dependency");
let ItemKind::Solid(s) = &mut p.item_mut(source).unwrap().kind else { panic!() };
s.color = [0.0, 1.0, 0.0];
assert_ne!(initial, key(&p, Tick::ZERO), "items used inside the precomp invalidate the owner");
let fresh = render_cached(&p, cid, Tick::ZERO, None);
assert_same(&render_cached(&p, cid, Tick::ZERO, Some(&cache)), &fresh, "precomp source edit");
assert_ne!(before.get(50, 50), fresh.get(50, 50));
}
/// #352: a shape layer's bounds (snapping, Align) are its visual edges: a 100×100 rectangle is
/// exactly ±50, and a 10 px stroke adds half its width, with no anti-aliasing padding.
#[test]
fn shape_content_bounds_are_the_visual_edges() {
for (stroke, half) in [(None, 50.0), (Some(10.0), 55.0)] {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Shape Layer 1", LayerSource::Shape, (200, 100), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let mut items = vec![build::shape_rect(&mut ids, [100.0, 100.0], [0.0, 0.0], 0.0), build::shape_fill(&mut ids, [1.0, 0.0, 0.0, 1.0])];
if let Some(w) = stroke {
items.push(build::shape_stroke(&mut ids, [1.0, 1.0, 1.0, 1.0], w));
}
let g = build::shape_group(&mut ids, "Rectangle 1", items);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l.clone());
let ctx = crate::EvalCtx::new(&p, cid, p.comp(cid).unwrap(), Tick::ZERO);
let b = crate::content_bounds(&ctx, &l).unwrap();
assert!(b.iter().zip([-half, -half, half, half]).all(|(a, e)| (a - e).abs() < 1e-6), "{stroke:?}: {b:?}");
}
}
/// Solo is not time-dependent (as in After Effects): while any layer is soloed, the others stay
/// excluded even when the playhead is outside the soloed layer's in-out range.
#[test]
fn solo_excludes_other_layers_outside_the_soloed_layers_range() {
let (mut p, cid, comp) = setup();
let a = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100);
let mut b = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
b.in_point = Tick::from_seconds_f64(1.0);
b.switches.solo = true;
let c = p.comp_mut(cid).unwrap();
c.layers.push(b);
c.layers.push(a);
let first = render_frame(&p, cid, Tick::from_seconds_f64(0.5), 1.0);
assert_eq!(first.get(100, 50), [0.0, 0.0, 0.0, 0.0], "neither A nor B before B's in point");
let second = render_frame(&p, cid, Tick::from_seconds_f64(1.5), 1.0);
assert_eq!(second.get(100, 50), [0.0, 0.0, 1.0, 1.0], "only B inside its range");
}
/// #1252: a layer blended with a CC Particle World layer, under a camera panned by `pan` px.
fn scene_reader_dependency(pan: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut source = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
let vals = [("birthRate", Value::Scalar(2.0)), ("particle/particleType", Value::Enum(2)), ("particle/birthSize", Value::Scalar(0.1))];
add_effect(&mut p, &mut source, "ec.sim.ccparticleworld", [200.0, 100.0], &vals);
source.switches.video = false;
let mut owner = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
add_effect_200(&mut p, &mut owner, "ec.channel.blend", &[("blendWithLayer", Value::Layer(Some(source.id.0))), ("blendWithOriginal", Value::Scalar(0.0))]);
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None);
for path in ["transform/position", "transform/poi"] {
let pr = cam.props.prop_mut(path).unwrap();
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
}
p.comp_mut(cid).unwrap().layers = vec![cam, owner, source].into();
(p, cid)
}
/// #1252: the owner's cache key covers the comp camera and lights that a layer it selects reads,
/// though the owner itself runs no scene-reading effect.
#[test]
fn a_selected_layer_reading_the_comp_scene_invalidates_its_owner() {
let t = Tick::from_seconds_f64(1.0);
let (p, cid) = scene_reader_dependency(0.0);
let (q, _) = scene_reader_dependency(40.0);
let key = |p: &Project| {
let c = p.comp(cid).unwrap();
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap()
};
assert_ne!(key(&p), key(&q), "moving the camera changes the owner's key");
let cache = crate::LayerCache::default();
let here = render_cached(&p, cid, t, Some(&cache));
let panned = render_cached(&q, cid, t, Some(&cache));
assert_same(&panned, &render_cached(&q, cid, t, None), "cached render after the camera moved");
let (a, b) = (particles_x(&here), particles_x(&panned));
assert!(b < a - 20.0, "the blended particles follow the camera: {a} -> {b}");
}
/// #1812: preview workers that render a layer at once end up with one buffer (the one kept
/// first), so the GPU compositor uploads and packs it once, not once per worker.
#[test]
fn workers_rendering_a_layer_at_once_share_one_buffer() {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.2, 0.5, 0.8], 1600, 1200);
add_effect(&mut p, &mut l, "ec.blur.gaussian", [1600.0, 1200.0], &[("blurriness", Value::Scalar(60.0))]);
p.comp_mut(cid).unwrap().layers.push(l);
let cache = crate::LayerCache::default();
let start = std::sync::Barrier::new(8);
let bufs: Vec<_> = std::thread::scope(|s| {
let hs: Vec<_> = (0..8)
.map(|_| {
s.spawn(|| {
let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default());
r.cache = Some(&cache);
let c = p.comp(cid).unwrap();
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
start.wait();
r.layer_buf(&ctx, &c.layers[0]).unwrap()
})
})
.collect();
hs.into_iter().map(|h| h.join().unwrap()).collect()
});
assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer");
assert_eq!(cache.stats().entries, 1);
}
// ------------------------------------------------------------------------------ 3D layer instances
/// A red triangle in the Z = 0 plane (the engine instance3d tests' glTF, buffer embedded as a
/// data URI), parsed in memory: a FootageSource that knows one model item.
const TRIANGLE: &str = r#"{
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}],
"materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}],
"buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 36}
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]},
{"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3}
]
}"#;
struct SoloModels;
impl crate::FootageSource for SoloModels {
fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<crate::Image>> {
None
}
fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option<std::sync::Arc<effectcraft_model::Model>> {
effectcraft_model::load(&f.path, TRIANGLE.as_bytes(), &|_| None).ok().map(std::sync::Arc::new)
}
}
/// A red triangle 3D model, a camera panned right by `pan` px and, on top of the stack, the
/// copy: a solid with a 3D Layer Instance effect referencing the model.
fn instance_scene(pan: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
// Models are Advanced 3D (layer.newModel switches a Classic comp over).
p.comp_mut(cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D;
let mid = p.add_item(
"Triangle",
Label::Red,
None,
ItemKind::Footage(effectcraft_project::Footage {
path: "Triangle.gltf".into(),
kind: effectcraft_project::FootageKind::Model,
frame_rate: FrameRate::FPS_30,
codec: "glTF".into(),
..Default::default()
}),
);
let model = build::layer(&mut p, &comp, "Triangle", LayerSource::Model { item: mid }, (200, 100), None);
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None);
for path in ["transform/position", "transform/poi"] {
let pr = cam.props.prop_mut(path).unwrap();
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
}
let mut owner = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100);
add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]);
p.comp_mut(cid).unwrap().layers = vec![cam, owner, model].into();
(p, cid)
}
fn render_models(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
let mut r = crate::Renderer::new(p, &SoloModels, crate::RenderOpts::default());
r.cache = cache;
r.comp_frame(cid, t)
}
/// The coverage-weighted centre of the triangle (x): where its drawn pixels are, lit or not.
fn cover_x(img: &crate::Image) -> f32 {
let (mut m, mut sx) = (0.0, 0.0);
for y in 0..img.height {
for x in 0..img.width {
let a = img.get(x as i64, y as i64)[3];
m += a;
sx += a * x as f32;
}
}
assert!(m > 1.0, "the triangle is drawn");
sx / m
}
/// Create 3D Layer Instance: the copy (the owner) is cached, and its key covers what the
/// referenced model's solo render depends on — the camera, and the model's in/out gate.
#[test]
fn an_instance_of_a_model_is_cached_and_follows_the_camera() {
let t = Tick::from_seconds_f64(1.0);
let (p, cid) = instance_scene(0.0);
let (q, _) = instance_scene(40.0);
let key_at = |p: &Project, t: Tick| {
let c = p.comp(cid).unwrap();
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap()
};
assert_ne!(key_at(&p, t), key_at(&q, t), "moving the camera changes the copy's key");
// The same layer at a later time, outside its in/out range: the read turns transparent.
let mut out = p.clone();
out.comp_mut(cid).unwrap().layers[2].out_point = Tick::from_seconds_f64(0.5);
assert_ne!(key_at(&out, Tick::from_seconds_f64(0.25)), key_at(&out, t), "leaving the model's in/out range changes the copy's key");
// Two renders of one time: the second is served from the cache.
let cache = crate::LayerCache::default();
let here = render_models(&p, cid, t, Some(&cache));
let again = render_models(&p, cid, t, Some(&cache));
assert_same(&again, &render_models(&p, cid, t, None), "the second render is the cached one");
assert!(cache.stats().hits > 0, "the copy was served from the cache");
// Panning the camera: the copy (and the model) move with it.
let panned = render_models(&q, cid, t, Some(&cache));
assert_same(&panned, &render_models(&q, cid, t, None), "cached render after the camera moved");
let (a, b) = (cover_x(&here), cover_x(&panned));
assert!(b < a - 20.0, "panning the camera right moves the copy left: {a} -> {b}");
}
/// The camera's depth of field blurs the solo render (focus, aperture, iris, highlights) —
/// none of it is in the projection matrix: changing any of it must change the copy's key, or
/// it would serve the un-blurred pixels.
#[test]
fn the_copy_key_covers_the_cameras_depth_of_field() {
let t = Tick::from_seconds_f64(1.0);
let (p, cid) = instance_scene(0.0);
let set_dof = |p: &Project, on: bool, focus: f64, aperture: f64| {
let mut q = p.clone();
let cam = &mut q.comp_mut(cid).unwrap().layers[0];
for (path, v) in
[("cameraOptions/dof", Value::Bool(on)), ("cameraOptions/focusDistance", Value::Scalar(focus)), ("cameraOptions/aperture", Value::Scalar(aperture))]
{
cam.props.prop_mut(path).unwrap().value = v;
}
q
};
let key_at = |p: &Project| {
let c = p.comp(cid).unwrap();
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap()
};
let ka = key_at(&set_dof(&p, true, 500.0, 20.0));
let kb = key_at(&set_dof(&p, true, 500.0, 40.0)); // aperture
let kc = key_at(&set_dof(&p, true, 700.0, 20.0)); // focus
let kd = key_at(&set_dof(&p, false, 500.0, 20.0)); // the switch off
assert!(ka != kb && ka != kc && ka != kd && kb != kc, "aperture, focus and the switch each change the key");
}
/// A model with an animation clip selected is posed at its layer time: every time is its own
/// entry. Without one (the default) the pose is the same at every time: one key inside the
/// in/out range, as before.
#[test]
fn the_copy_key_covers_an_animated_models_layer_time() {
let (p, cid) = instance_scene(0.0);
let (t0, t1) = (Tick::from_seconds_f64(0.5), Tick::from_seconds_f64(1.0));
let key_at = |p: &Project, t: Tick| {
let c = p.comp(cid).unwrap();
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[1], 1.0, false, false).unwrap()
};
assert_eq!(key_at(&p, t0), key_at(&p, t1), "a model without a clip keeps one key across times");
let mut q = p.clone();
q.comp_mut(cid).unwrap().layers[2].props.prop_mut("geometryOptions/animation").unwrap().value = Value::Enum(1);
assert_ne!(key_at(&q, t0), key_at(&q, t1), "an animated model's key follows its layer time");
// Two times and back: each is rendered fresh, none served another time's pixels.
let cache = crate::LayerCache::default();
let first = render_models(&q, cid, t0, Some(&cache));
let second = render_models(&q, cid, t1, Some(&cache));
assert_eq!(cache.stats().hits, 0, "the second time is not served the first time's pixels");
assert_same(&second, &render_models(&q, cid, t1, None), "the second time is a fresh render");
let again = render_models(&q, cid, t0, Some(&cache));
assert_eq!(cache.stats().hits, 1, "the first time is served on the way back");
assert_same(&again, &first, "the way back is the first time's pixels");
}
/// An Environment light's image lights Advanced 3D models — its source can be any layer's
/// render: with one on, the read is not cached, so changes to it cannot reuse stale pixels.
#[test]
fn an_environment_light_makes_the_copy_uncached() {
let (p, cid) = instance_scene(0.0);
let mut q = p.clone();
let comp = q.comp(cid).unwrap().clone();
let env = build::layer(&mut q, &comp, "Env", LayerSource::Light { kind: effectcraft_project::LightKind::Environment }, (200, 100), None);
q.comp_mut(cid).unwrap().layers.insert(0, env);
let t = Tick::from_seconds_f64(1.0);
let key_at = |p: &Project| {
let c = p.comp(cid).unwrap();
crate::cache::layer_key(&crate::EvalCtx::new(p, cid, c, t), &c.layers[2], 1.0, false, false)
};
assert!(key_at(&q).is_none(), "with an Environment light the read is not cached");
let cache = crate::LayerCache::default();
let a = render_models(&q, cid, t, Some(&cache));
let b = render_models(&q, cid, t, Some(&cache));
assert_same(&a, &b, "two fresh renders agree");
assert_eq!(cache.stats().hits, 0, "nothing is served from the cache");
// The light off (not lighting): the cache comes back.
q.comp_mut(cid).unwrap().layers[0].switches.video = false;
assert!(key_at(&q).is_some(), "the light off, the read is cached again");
}