Files
effectcraft/crates/render/src/tests.rs
bflatasticandClaude Opus 5.5 940bea3ba7 Effects: effect points on shape layers start at the layer centre (#227)
Shape and text layers have comp-sized effect bounds centred on their
origin, but effects measured positions from the origin: a point at its
default "layer centre" sat at the comp's bottom-right corner, so Twirl
on a centred shape changed nothing and Bulge only its bottom-right
quarter. As in After Effects, effects now work in effect space,
measured from the top-left of the layer's effect bounds.

The renderer moves the layer buffer into effect space for the whole
stack (Buf::rebase, CPU and GPU alike) and hands effects everything
else in it: masks, other layers' pixels for layer parameters (each in
its own effect space, so Set Matte and displacement maps see a whole
shape layer), the layer's own frames (layer_input, also what Puppet,
Roto Brush and the Layer panel read) and the comp camera
(CompScene::rebased). Layer bounds are now right for every effect, so
pin_rect and EffectEnv::bounds_origin go and layer_rect serves edge
pinning too. Adjustment layers still run on the comp below as it is.

effect_bounds / EvalCtx::effect_to_comp replace the repeated "comp size
when there is no source" code; the viewer's effect point handles and
eyedroppers, Puppet pins, the Layer panel (paint, Roto Brush) and the
tracker's Corner Pin output use them.

Project schema 2: opening a schema 1 project moves positions held by
effects on shape and text layers by half the comp size, so set points
keep their place (point parameters with keyframes, Paint paths and
clone positions, Puppet seeds and pins, Liquify and Roto Brush
strokes). Points still at their default become the layer centre;
expression controls, adjustment layers and expressions are left as
they are (docs/architecture.md). The disk cache's render version is
bumped.

Tests: default_effect_points_sit_at_the_centre_of_a_shape_layer and
layer_parameters_see_a_whole_shape_layer (render, both fail before),
effect_points_on_a_shape_layer (GPU vs CPU),
opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space
(engine), and the effects migration tests.

Part of #227

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:33:03 +07:00

524 lines
24 KiB
Rust
Raw Permalink 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;
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 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);
}
#[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:?}");
}
/// 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);
}
// ------------------------------------------------------------------------------ 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)
}
/// [`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];
(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");
}
#[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];
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];
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:?}");
}
}
/// 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];
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));
}
}