G2: Fix effect input semantics and control activation

This commit is contained in:
cdinhoo 2026-10-09 17:16:36 +03:00
parent b9d1f79f7d
commit 5f1f346366
12 changed files with 663 additions and 25 deletions

View File

@ -171,8 +171,7 @@ const CC_COMPOSITE_MODES: [&str; 16] = [
"Stencil Alpha",
];
/// Composites the original over/under/with the effect result. The original is the image as it
/// enters this effect (the host does not expose the pre-effect source).
/// Composites the original stack input over/under/with the result of preceding effects.
fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
let op = (ctx.params.f("opacity") as f32 / 100.0).clamp(0.0, 1.0);
let m = ctx.params.e("compositeOriginal");
@ -194,9 +193,10 @@ fn cc_composite(ctx: &EffectCtx, mut b: Buf) -> Buf {
15 => BlendMode::StencilAlpha,
_ => BlendMode::Normal,
};
b.img.data.par_iter_mut().for_each(|px| {
let original = ctx.env.host.and_then(|h| h.original()).map(|o| crate::util::fit_buffer(o, b.img.width, b.img.height, b.offset, b.scale));
b.img.data.par_iter_mut().enumerate().for_each(|(i, px)| {
let result = *px;
let orig = result.map(|v| v * op);
let orig = original.as_ref().and_then(|o| o.data.get(i)).copied().unwrap_or(result).map(|v| v * op);
let mut o = if m == 1 { blend_pixel(BlendMode::Normal, orig, result, 0.5) } else { blend_pixel(mode, result, orig, 0.5) };
o[3] = o[3].clamp(0.0, 1.0);
if rgb_only {
@ -446,3 +446,53 @@ pub(crate) mod tests {
assert!((sc.img.get(4, 3)[0] - 0.75).abs() < 1e-5);
}
}
#[cfg(test)]
mod original_tests {
use super::*;
use crate::{EffectEnv, EffectHost, Image, LayerPixels};
struct Original(Buf);
impl EffectHost for Original {
fn original(&self) -> Option<&Buf> {
Some(&self.0)
}
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
}
#[test]
fn composite_preserves_alpha_opacity_and_padded_alignment() {
let spec = crate::find("ec.channel.cccomposite").unwrap();
let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
let host = Original(Buf { img: Image::filled(1, 1, [0.5, 0.0, 0.0, 0.5]), offset: [0.0; 2], scale: 1.0 });
for (mode, opacity, rgb, want) in [
(0, 100.0, false, [0.5, 0.0, 0.125, 0.625]),
(1, 100.0, false, [0.375, 0.0, 0.25, 0.625]),
(0, 50.0, false, [0.25, 0.0, 0.1875, 0.4375]),
(0, 100.0, true, [0.2, 0.0, 0.05, 0.25]),
] {
params.values.insert("compositeOriginal".into(), Value::Enum(mode));
params.values.insert("opacity".into(), num(opacity));
params.values.insert("rgbOnly".into(), Value::Bool(rgb));
let ctx = EffectCtx {
params: &params,
time: 0.0,
layer_size: [1.0; 2],
seed: 0,
adjustment: false,
env: EffectEnv { host: Some(&host), ..Default::default() },
};
let mut input = Buf { img: Image::filled(1, 1, [0.0, 0.0, 0.25, 0.25]), offset: [0.0; 2], scale: 1.0 };
input.pad(1);
let out = cc_composite(&ctx, input);
for (a, b) in out.img.get(1, 1).iter().zip(want) {
assert!((a - b).abs() < 1e-6, "{:?} vs {want:?}", out.img.get(1, 1));
}
assert_eq!(out.img.get(0, 0), [0.0; 4]);
assert_eq!(out.offset, [1.0; 2]);
}
}
}

View File

@ -287,8 +287,14 @@ pub struct LayerPixels {
/// Services the renderer offers effects that look beyond their own pixels.
pub trait EffectHost: Sync {
/// Pixels entering the effect stack, in effect space, before any effects. For an
/// adjustment layer this is the composition below it, in the effect working space.
fn original(&self) -> Option<&Buf> {
None
}
/// Layer `id` of the same composition at the current comp time. `masks_and_effects`
/// selects "Effects & Masks" over "Source". `None` for missing/self/recursive references.
/// selects "Effects & Masks" over "Source". `None` for missing/recursive references; own Source reads are supported.
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels>;
/// `frames` stereo sample frames (interleaved L R …) of layer `id`'s audio starting at comp
/// time `start` seconds, at `rate` Hz. `None` when the layer has no audio.

View File

@ -450,6 +450,17 @@ pub fn fit_layer(ctx: &crate::EffectCtx, b: &crate::Buf, other: &crate::LayerPix
})
}
/// Resample a buffer into another buffer's pixel grid without stretching its layer space.
/// Used to composite the pre-effect image after earlier effects expand or shift the bounds.
pub fn fit_buffer(source: &crate::Buf, width: u32, height: u32, offset: [f64; 2], scale: f64) -> Image {
let inv = 1.0 / scale.max(1e-9);
gen_image(width, height, |x, y| {
let sx = (x as f64 + 0.5 - offset[0]) * inv * source.scale + source.offset[0];
let sy = (y as f64 + 0.5 - offset[1]) * inv * source.scale + source.offset[1];
source.img.sample_bilinear(sx, sy)
})
}
/// The layer chosen in layer parameter `id` fitted to `b` (see [`fit_layer`]), or a copy of
/// `b`'s own pixels when none is chosen / available (After Effects' "self" behaviour).
pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and_effects: bool, stretch: bool) -> Image {

View File

@ -216,6 +216,7 @@ fn composite_mode(m: u32) -> BlendMode {
fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let mut p = Params::default();
let mut original = None;
match id {
"ec.utility.cineon" => {
// utility::Cineon's offset and knee, in the CPU's f32.
@ -246,6 +247,11 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
p.f[0] = pr.color("highlightColor");
}
"ec.channel.cccomposite" => {
if let Some(source) = ctx.env.host.and_then(|h| h.original()) {
let fitted = effectcraft_effects::util::fit_buffer(source, b.img.width, b.img.height, b.offset, b.scale);
original = Some(e.g.upload_image(&fitted)?);
}
p.u[1][0] = original.is_some() as u32;
let m = pr.e("compositeOriginal");
let mode = if m == 1 { BlendMode::Normal } else { composite_mode(m) };
p.u[0] = [6, (m == 1) as u32, mode_id(mode), pr.b("rgbOnly") as u32];
@ -267,7 +273,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
}
_ => return None,
}
run(e, "fp2_point", &p, b, None, None)
run(e, "fp2_point", &p, b, original.as_ref(), None)
}
// ---------------------------------------------------------------- CC Block Load (stylize3)

View File

@ -402,7 +402,9 @@ fn fp2_point(@builtin(global_invocation_id) gid: vec3<u32>) {
}
// u[0] = (_, composite original, mode, rgb only); f[0].x = opacity.
default: {
let orig = px * P.f[0].x;
var source = px;
if (P.u[1].x != 0u) { source = textureLoad(aux, p, 0); }
let orig = source * P.f[0].x;
if (P.u[0].y == 1u) {
o = blend_pixel(P.u[0].z, orig, px, 0.5);
} else {

View File

@ -103,3 +103,19 @@ fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() {
r.run_effects_on(&ctx, adj, true, &mut p);
assert_eq!((p.gpu, p.cpu), (3, 1));
}
#[test]
fn composite_reads_the_original_adjustment_input_on_gpu() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for mode in [0, 1, 2, 4, 15] {
for rgb in [false, true] {
let stack = vec![
("ec.channel.invert", vec![]),
("ec.channel.cccomposite", vec![("compositeOriginal", Value::Enum(mode)), ("opacity", n(65.0)), ("rgbOnly", Value::Bool(rgb))]),
];
let s = adjustment_scene(depth, &stack, true);
check(&format!("original adjustment {depth:?} mode {mode} rgb {rgb}"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
}

View File

@ -1,8 +1,9 @@
//! Layer render cache.
//!
//! The processed pixels of a layer (source → masks → effects, in layer space) depend only on the
//! layer's own non-transform properties, its source item and the render options — not on its
//! transform, opacity, blend mode or anything below it. [`LayerCache`] keeps those buffers keyed
//! layer's non-transform properties, its source item, selected-layer dependencies and the render
//! options. Its own transform is normally applied later; referenced layers' transforms can
//! affect their sampled pixels. [`LayerCache`] keeps those buffers keyed
//! by a content hash of exactly those inputs, *evaluated at the frame time*, so:
//!
//! * a static layer (or one whose only animation is in Transform) renders once and is reused on
@ -25,6 +26,8 @@ use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup};
use crate::eval::EvalCtx;
mod dependencies;
/// Thread-safe, memory-budgeted cache of processed layer buffers, optionally backed by the
/// persistent [`DiskCache`](crate::disk_cache::DiskCache): a memory miss reads the disk, and
/// buffers that were slow to render are written there (keys salted per project, see
@ -534,6 +537,9 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, f
#[allow(clippy::too_many_arguments)]
fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option<usize>) -> Option<u64> {
let mut h = KeyHasher(0xcbf2_9ce4_8422_2325);
ctx.comp.frame_rate.as_f64().to_bits().hash(&mut h);
ctx.comp.enable_frame_blending.hash(&mut h);
dependencies::hash(&mut h, ctx, layer, effects)?;
match &layer.source {
LayerSource::Solid { item } => {
let it = ctx.project.item(*item)?;

109
crates/render/src/cache/dependencies.rs vendored Normal file
View File

@ -0,0 +1,109 @@
//! Content selected by layer parameters participates in the owner's cache key. Keep the
//! ordinary layer cache (including static self reads), and conservatively decline only
//! cyclic/over-budget graphs or expressions sampled at unknown times.
use super::{KeyHasher, hash_debug, hash_values};
use crate::EvalCtx;
use effectcraft_project::{ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup};
use std::hash::Hash;
pub(super) fn hash(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, effects: Option<usize>) -> Option<()> {
let mut deps = Dependencies { h, path: vec![(ctx.comp_id, layer.id)], remaining: 1024, other_times: super::reads_other_times(layer) };
deps.effects(ctx, layer, effects)
}
struct Dependencies<'a> {
h: &'a mut KeyHasher,
path: Vec<(ItemId, LayerId)>,
remaining: usize,
other_times: bool,
}
impl Dependencies<'_> {
fn effects(&mut self, ctx: &EvalCtx, layer: &Layer, limit: Option<usize>) -> Option<()> {
let previous = self.other_times;
self.other_times |= super::reads_other_times(layer);
if layer.switches.effects
&& let Some(fx) = layer.effects()
{
for g in fx.groups().take(limit.unwrap_or(usize::MAX)).filter(|g| g.enabled) {
self.group(ctx, layer, g, 0)?;
}
}
self.other_times = previous;
Some(())
}
fn group(&mut self, ctx: &EvalCtx, layer: &Layer, g: &PropGroup, depth: usize) -> Option<()> {
if depth >= 32 {
return None;
}
for node in &g.children {
match node {
Node::Prop(p) => {
if let Some(id) = ctx.value(layer, p).as_layer() {
p.uid.hash(self.h);
id.hash(self.h);
let other = ctx.layer(LayerId(id));
other.is_some().hash(self.h);
if let Some(other) = other {
if other.id == layer.id {
// Source/Masks never enter the same stack again. The owner
// key already covers its source and mask properties.
hash_debug(self.h, &ctx.world_matrix(other));
} else {
self.layer(ctx, other)?;
}
}
}
}
Node::Group(sub) if sub.enabled => self.group(ctx, layer, sub, depth + 1)?,
_ => {}
}
}
Some(())
}
fn layer(&mut self, ctx: &EvalCtx, layer: &Layer) -> Option<()> {
let id = (ctx.comp_id, layer.id);
if self.path.len() > crate::MAX_FX_DEPTH || self.path.contains(&id) {
return None;
}
self.remaining = self.remaining.checked_sub(1)?;
if (self.other_times || super::reads_other_times(layer)) && super::has_expression(&layer.props) {
return None;
}
self.path.push(id);
// Include transforms/parents as well as pixels: selected-layer anchors and
// continuously rasterized sources can depend on them.
hash_debug(self.h, layer);
hash_values(self.h, ctx, layer, &layer.props);
hash_debug(self.h, &ctx.world_matrix(layer));
if super::time_dependent(layer) {
layer.layer_time(ctx.time).0.hash(self.h);
}
match layer.source {
LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => {
let source = ctx.project.item(item);
hash_debug(self.h, &source);
if let Some(source) = source {
match &source.kind {
ItemKind::Footage(_) => ctx.source_time(layer).0.hash(self.h),
ItemKind::Comp(comp) => {
let time = ctx.source_time(layer);
time.0.hash(self.h);
let nested = EvalCtx::new(ctx.project, item, comp, time);
for child in &comp.layers {
self.layer(&nested, child)?;
}
}
_ => {}
}
}
}
_ => {}
}
self.effects(ctx, layer, None)?;
self.path.pop();
Some(())
}
}

View File

@ -92,6 +92,7 @@ struct FxHost<'r, 'a, 'c> {
origin: [f64; 2],
/// Index of the effect being rendered (bounds `self_at` to effects before it).
index: std::sync::atomic::AtomicUsize,
original: Option<&'c Buf>,
}
impl FxHost<'_, '_, '_> {
@ -110,12 +111,16 @@ impl FxHost<'_, '_, '_> {
const MAX_FX_DEPTH: usize = 8;
impl EffectHost for FxHost<'_, '_, '_> {
fn original(&self) -> Option<&Buf> {
self.original
}
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
self.r.active_accel().and_then(|a| a.particles())
}
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
if (other.id == self.layer.id && masks_and_effects) || self.r.depth > MAX_FX_DEPTH {
return None;
}
let sub = self.r.nested();
@ -126,7 +131,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
fn layer_masks(&self, id: u64) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
if self.r.depth > MAX_FX_DEPTH {
return None;
}
let sub = self.r.nested();
@ -212,14 +217,17 @@ impl EffectHost for FxHost<'_, '_, '_> {
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
if (other.id == self.layer.id && masks_and_effects) || !comp_time.is_finite() || self.r.depth > MAX_FX_DEPTH {
return None;
}
let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time));
let sub = self.r.nested();
Some(if masks_and_effects {
self.pixels(other, (*sub.content_buf(&ctx, other)?).clone(), true)
} else if other.id == self.layer.id {
self.pixels(other, sub.source(&ctx, other)?, true)
} else {
// Preserve other-layer reads: time effects historically see source + masks.
self.pixels(other, (*sub.layer_input(&ctx, other, 0)?).clone(), false)
})
}
@ -940,7 +948,18 @@ impl<'a> Renderer<'a> {
*q = [q[0] - origin[0], q[1] - origin[1]];
}
}
let host = FxHost { r: self, ctx, layer, origin, index: Default::default() };
// Capture once, before preceding effects can expand or replace the input. An
// adjustment stack's original is the comp below, not its solid source.
let needs_original =
fx.groups().take(limit).any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.channel.cccomposite"));
let mut original = None;
if needs_original {
target.cpu(&mut |buf| {
original = Some(buf.clone());
buf
});
}
let host = FxHost { r: self, ctx, layer, origin, index: Default::default(), original: original.as_ref() };
let env = EffectEnv {
masks: &mask_shapes,
host: Some(&host),

View File

@ -541,3 +541,186 @@ fn layer_parameters_see_a_whole_shape_layer() {
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];
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()];
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];
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];
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];
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));
}

View File

@ -33,6 +33,21 @@ type Actions = Vec<(String, serde_json::Value)>;
const ROW: f32 = 24.0;
const DIAL_ROW: f32 = 64.0;
/// Selecting any descendant activates its owning effect without replacing the property
/// selection used by keyframe editing, copy and delete.
fn effect_active(g: &PropGroup, layer: effectcraft_engine::project::LayerId, selected: &[(effectcraft_engine::project::LayerId, u64)]) -> bool {
fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool {
if g.uid == uid {
return true;
}
if depth >= 16 {
return false;
}
g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(child) if contains(child, uid, depth + 1)))
}
selected.iter().any(|(l, uid)| *l == layer && contains(g, *uid, 0))
}
fn selected_layer(app: &EffectcraftApp) -> Option<Layer> {
let comp = app.session.active_comp()?;
let id = app.session.state.selected_layers.first()?;
@ -159,6 +174,10 @@ fn prop_row(
let t = app.tokens;
let cy = r.center().y;
let uid = prop.uid;
if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(r) {
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": add})));
}
let swr = Rect::from_center_size(pos2(r.min.x + indent, cy), vec2(14.0, 14.0));
if !prop.static_only && !matches!(prop.ui, ParamUi::Hidden) {
let resp = ui.interact(swr, egui::Id::new(("ec-sw", uid)), Sense::click());
@ -203,6 +222,8 @@ fn prop_row(
let name_clip =
Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(((r.min.x + r.width() * 0.48).max(swr.max.x + 124.0) - 4.0).min(vx - 6.0), r.max.y));
p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text);
ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click());
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
let merge = format!("ec-{uid}");
let set =
|actions: &mut Actions, v: serde_json::Value| actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge})));
@ -954,7 +975,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Clicking empty space deselects the effects.
let bg = ui.interact(body, egui::Id::new("ec-bg"), Sense::click());
if bg.clicked() {
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| g.uid == *u)));
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| effect_active(g, layer.id, &[(*l, *u)]))));
}
let has_clip = !app.session.state.effect_clipboard.is_empty();
let drag_id = egui::Id::new("ec-drag-effect");
@ -968,7 +989,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
y += 26.0;
let open = !app.ui.fx_closed.contains(&g.uid);
let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid);
bp.rect_filled(r, 0.0, if selected { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) });
let active = effect_active(g, layer.id, &app.session.state.selected_props);
bp.rect_filled(r, 0.0, if active { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) });
let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0));
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() {
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid})));
@ -1009,6 +1031,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
egui::Id::new(("ec-hdr", g.uid)),
Sense::click_and_drag(),
);
hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &g.name));
app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name);
if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() {
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
@ -1049,6 +1072,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh));
y += eh;
if er.max.y >= body.min.y && er.min.y <= body.max.y {
// Curves, Levels and other custom editors do not use prop_row.
// Clicking their controls must activate the owner as well.
if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(er) {
actions.push(("prop.select".into(), json!({"layer":layer.id.0,"prop":g.uid})));
}
match effect.as_str() {
"ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions),
effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions),
@ -1125,11 +1153,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---------------------------------------------------------------------------------------------
// Viewer hook
/// Collect point controls through visible parameter groups. Hidden optional slots do not
/// leave stray viewport handles. The depth cap also bounds malformed project trees.
fn visible_points<'a>(g: &'a PropGroup, scope: &FxScope<'_>, layer: &Layer, ectx: &EvalCtx, depth: usize, out: &mut Vec<&'a Property>) {
if depth > 16 {
return;
}
for node in &g.children {
match node {
Node::Prop(p) if matches!(p.ui, ParamUi::Point) && scope.shown(layer, ectx, &p.match_id) => out.push(p),
Node::Group(child) if child.enabled && scope.shown(layer, ectx, &child.match_id) => {
visible_points(child, &scope.sub(&child.match_id), layer, ectx, depth + 1, out)
}
_ => {}
}
}
}
/// Effect Controls' part of the Composition viewer, called by the viewer after its own
/// interaction (so these controls sit on top):
///
/// - while a crosshair / eyedropper pick is armed, the next click in the viewer sets the point
/// parameter (converted to the layer's space) or samples the frame's colour; Esc cancels;
/// - while a crosshair pick is armed, its handle previews the cursor position without editing
/// the property; the next click sets the point in layer space, and Esc cancels;
/// - an eyedropper pick samples the frame's colour on click;
/// - otherwise, the point parameters of the selected effects are drawn as draggable ⊕ controls.
///
/// `l2c` is the viewer's layer→comp matrix (it knows the 3D view).
@ -1144,6 +1190,7 @@ pub fn viewer_hook(
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut actions: Actions = vec![];
let mut point_preview = None;
if let Some(pick) = app.ui.fx_pick.clone() {
let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else {
app.ui.fx_pick = None;
@ -1169,6 +1216,9 @@ pub fn viewer_hook(
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
let c = map.to_comp(hp);
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() {
point_preview = Some(hp);
}
if pick.kind == "color"
&& keyer.is_none()
&& let Some(img) = &app.viewer_image
@ -1210,23 +1260,51 @@ pub fn viewer_hook(
app.ui.fx_pick = None;
app.ui.status.clear();
}
} else if app.ui.viewer.show_layer_controls {
}
let picking = app.ui.fx_pick.is_some();
let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point");
if app.ui.viewer.show_layer_controls || point_pick.is_some() {
// ⊕ controls for the point parameters of the selected effects.
let sel = app.session.state.selected_props.clone();
let mut sel = app.session.state.selected_props.clone();
if let Some(pick) = &point_pick {
sel.push((effectcraft_engine::project::LayerId(pick.layer), pick.prop));
}
let selected = app.session.state.selected_layers.clone();
for layer in ectx.comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(ectx.time)) {
for layer in ectx
.comp
.layers
.iter()
.filter(|l| (selected.contains(&l.id) || point_pick.as_ref().is_some_and(|p| p.layer == l.id.0)) && l.is_active_at(ectx.time))
{
let Some(fx) = layer.effects() else { continue };
let m = l2c(ectx, layer);
// Masks are in layer space, effect points in effect space.
let fm = super::viewer::from_effect_space(ectx, layer, m);
for g in fx.groups().filter(|g| g.enabled && sel.iter().any(|(l, u)| *l == layer.id && *u == g.uid)) {
if matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) {
let picked_prop = point_pick.as_ref().filter(|p| p.layer == layer.id.0).map(|p| p.prop);
for g in fx.groups().filter(|g| g.enabled && effect_active(g, layer.id, &sel)) {
// Puppet owns its pin handles and hit testing. Generic crosshairs would
// overlap the pins and steal their selection/rotation/scale gestures.
if matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_engine::effects::puppet::ID) {
continue;
}
if !picking && app.ui.viewer.show_layer_controls && matches!(&g.kind, GroupKind::Effect { effect } if effect == super::fx_editors::RESHAPE) {
super::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions);
}
let pts: Vec<&Property> = g.props().filter(|p| matches!(p.ui, ParamUi::Point)).collect();
let scope = FxScope { effect: &g.match_id, root: g, path: String::new() };
let mut pts = Vec::new();
visible_points(g, &scope, layer, ectx, 0, &mut pts);
// An explicit pick stays visible even when View > Show Layer Controls is off.
if !app.ui.viewer.show_layer_controls {
pts.retain(|pr| picked_prop == Some(pr.uid));
}
let screen: Vec<egui::Pos2> = pts
.iter()
.map(|pr| {
if picked_prop == Some(pr.uid)
&& let Some(pos) = point_preview
{
return pos;
}
let v = ectx.value(layer, pr).as_vec2();
map.to_screen(fw::layer_to_comp(&fm, v))
})
@ -1238,11 +1316,13 @@ pub fn viewer_hook(
for (pr, s) in pts.iter().zip(screen) {
let hr = Rect::from_center_size(s, vec2(16.0, 16.0));
let id = egui::Id::new(("viewer-fx-pt", pr.uid));
let resp = ui.interact(hr, id, Sense::drag());
let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE };
// The picker owns the click. Preview handles must not steal it or start a drag.
let resp = (!picking).then(|| ui.interact(hr, id, Sense::drag()));
let col = if picked_prop == Some(pr.uid) || resp.as_ref().is_some_and(|r| r.hovered() || r.dragged()) { t.accent } else { Color32::WHITE };
fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160));
fw::draw_crosshair(painter, s, 5.0, col);
app.auto.add(&format!("viewer.effectPoint.{}", pr.uid), hr, &pr.name);
let Some(resp) = resp else { continue };
if resp.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::Move);
resp.clone().on_hover_text(format!("{}: {}", g.name, pr.name));

View File

@ -424,3 +424,153 @@ fn extractor_offers_the_exr_layers_and_channels() {
assert_eq!(values(&h)[0], "depth.Z");
let _ = std::fs::remove_file(path);
}
#[test]
fn point_picker_handle_follows_hover_then_commits_once_across_effects() {
for (effect, parameter) in [("ec.control.point", "point"), ("ec.blur.ccradialfast", "center"), ("ec.generate.gradientramp", "start")] {
let mut s = Session::default();
s.execute("comp.new", json!({"width":640,"height":360,"duration":1})).unwrap();
let lid = s.execute("layer.newSolid", json!({"width":200,"height":100,"color":"#406080"})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer":lid,"path":"transform/scale","value":[200,200]})).unwrap();
let fx = s.execute("effect.apply", json!({"layer":lid,"effect":effect})).unwrap()["effects"][0].as_u64().unwrap();
let point = layer(&s, lid).effects().unwrap().find_group(fx).unwrap().get(parameter).unwrap().uid;
s.execute("prop.set", json!({"layer":lid,"prop":point,"value":[100,50]})).unwrap();
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
app.ui.viewer.zoom = Some(0.85);
app.ui.viewer.pan = [25.0, -10.0];
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
settle(&mut h);
let id = format!("viewer.effectPoint.{point}");
let original = rect_of(&h, &id).center();
let pick_button = rect_of(&h, &format!("effectControls.prop.{point}.crosshair")).center();
click(&mut h, pick_button);
assert!(h.state().ui.fx_pick.is_some(), "{effect}: crosshair arms the picker");
assert_eq!(rect_of(&h, &id).center(), original, "the handle remains visible before entering the viewer");
let undo = h.state().session.history.undo.len();
for comp in [[350.0, 200.0], [370.0, 220.0]] {
let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, comp).unwrap();
hover(&mut h, pos);
assert!(rect_of(&h, &id).center().distance(pos) < 0.1, "{effect}: handle must follow the pointer before clicking");
assert_eq!(layer(&h.state().session, lid).props.find(point).unwrap().value.as_vec2(), [100.0, 50.0], "hover is only a preview");
assert_eq!(h.state().session.history.undo.len(), undo, "hover must not add undo entries");
}
let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [370.0, 220.0]).unwrap();
click(&mut h, pos);
assert!(h.state().ui.fx_pick.is_none());
let after = layer(&h.state().session, lid).props.find(point).unwrap().value.as_vec2();
assert!((after[0] - 125.0).abs() < 1.0 && (after[1] - 70.0).abs() < 1.0, "{effect}: click uses layer space: {after:?}");
assert_eq!(h.state().session.history.undo.len(), undo + 1, "one click is one edit");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&h.state().session, lid).props.find(point).unwrap().value.as_vec2(), [100.0, 50.0]);
assert_eq!(layer(&h.state().session, lid).props.prop("transform/position").unwrap().value.as_vec2(), [320.0, 180.0]);
}
}
#[test]
fn point_picker_preview_cancels_and_works_with_layer_controls_hidden() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
settle(&mut h);
let value = layer(&h.state().session, x.small).props.find(x.point).unwrap().value.clone();
let undo = h.state().session.history.undo.len();
h.state_mut().ui.viewer.show_layer_controls = false;
let pick_button = rect_of(&h, &format!("effectControls.prop.{}.crosshair", x.point)).center();
click(&mut h, pick_button);
let id = format!("viewer.effectPoint.{}", x.point);
let original = rect_of(&h, &id).center();
let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [370.0, 220.0]).unwrap();
hover(&mut h, pos);
assert!(rect_of(&h, &id).center().distance(pos) < 0.1, "explicit picks show a handle even with layer controls hidden");
hover(&mut h, pick_button);
assert_eq!(rect_of(&h, &id).center(), original, "leaving the viewer restores the current position");
hover(&mut h, pos);
h.key_press(egui::Key::Escape);
h.run_steps(3);
assert!(h.state().ui.fx_pick.is_none());
assert!(!ids(&h).contains(&id), "cancel removes the temporary handle");
assert_eq!(layer(&h.state().session, x.small).props.find(x.point).unwrap().value, value);
assert_eq!(h.state().session.history.undo.len(), undo, "cancel leaves the project unchanged");
}
#[test]
fn radial_center_stays_draggable_when_its_property_is_selected() {
let (mut app, x) = app();
app.session.execute("effect.apply", json!({"layer":x.small,"effect":"ec.blur.ccradialfast"})).unwrap();
let l = layer(&app.session, x.small);
let effect = l.effects().unwrap().groups().find(|g| g.match_id == "ec.blur.ccradialfast").unwrap();
let point = effect.get("center").unwrap().uid;
app.session.state.selected_props = vec![(LayerId(x.small), point)];
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
settle(&mut h);
let id = format!("viewer.effectPoint.{point}");
assert!(ids(&h).contains(&id), "selected point must keep its handle");
let from = rect_of(&h, &id).center();
let to = from + egui::vec2(40.0, 20.0);
h.event(egui::Event::PointerMoved(from));
h.step();
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
for i in 1..=5 {
h.event(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 5.0)));
h.step();
}
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.step();
let after = layer(&h.state().session, x.small).props.find(point).unwrap().value.as_vec2();
assert!(after[0] > 100.0 && after[1] > 50.0, "drag updates Center: {after:?}");
let position = layer(&h.state().session, x.small).props.prop("transform/position").unwrap().value.as_vec3();
assert_eq!(position[..2], [320.0, 180.0], "dragging the effect must not move the layer");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&h.state().session, x.small).props.find(point).unwrap().value.as_vec2(), [100.0, 50.0], "one Undo reverses the gesture");
}
#[test]
fn clicking_an_effect_parameter_activates_its_header_and_handles() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
settle(&mut h);
let name = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center();
click(&mut h, name);
assert_eq!(h.state().session.state.selected_props, vec![(LayerId(x.small), x.point)]);
assert!(ids(&h).contains(&format!("viewer.effectPoint.{}", x.point)), "clicking the parameter activates the owner effect");
}
#[test]
fn non_point_parameters_activate_handles_across_effect_families() {
for (effect, parameter, points) in [
("ec.distort.cclens", "size", vec!["center"]),
("ec.generate.gradientramp", "startColor", vec!["start", "end"]),
("ec.generate.beam", "length", vec!["startPoint", "endPoint"]),
] {
let mut s = Session::default();
s.execute("comp.new", json!({"width":320,"height":180,"duration":1})).unwrap();
let lid = s.execute("layer.newSolid", json!({"color":"#406080"})).unwrap()["layer"].as_u64().unwrap();
let uid = s.execute("effect.apply", json!({"layer":lid,"effect":effect})).unwrap()["effects"][0].as_u64().unwrap();
let l = layer(&s, lid);
let g = l.effects().unwrap().find_group(uid).unwrap();
let prop = g.prop(parameter).unwrap().uid;
let point_ids: Vec<_> = points.iter().map(|name| g.prop(name).unwrap().uid).collect();
s.state.selected_props.clear();
let mut app = EffectcraftApp::new(s);
app.show_panel(PanelKind::EffectControls);
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
settle(&mut h);
let name = rect_of(&h, &format!("effectControls.prop.{prop}.name")).center();
click(&mut h, name);
assert_eq!(h.state().session.state.selected_props, vec![(LayerId(lid), prop)], "{effect}");
for point in point_ids {
assert!(ids(&h).contains(&format!("viewer.effectPoint.{point}")), "{effect}: all its point handles activate");
}
}
}
#[test]
fn custom_effect_editors_activate_their_owner() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
settle(&mut h);
let graph = rect_of(&h, &format!("effectControls.effect.{}.curves.graph", x.curves)).center();
click(&mut h, graph);
assert!(h.state().session.state.selected_props.contains(&(LayerId(x.small), x.curves)));
}