diff --git a/crates/effects/src/channel2.rs b/crates/effects/src/channel2.rs index 25984984..5be3c94e 100644 --- a/crates/effects/src/channel2.rs +++ b/crates/effects/src/channel2.rs @@ -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 { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + 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: ¶ms, + 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]); + } + } +} diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index daa9b078..4113c8d1 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -299,8 +299,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; /// `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. diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index f0894182..c3416618 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -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 { diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index 57c2037f..1b1b42be 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -216,6 +216,7 @@ fn composite_mode(m: u32) -> BlendMode { fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { 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 { 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 { } _ => return None, } - run(e, "fp2_point", &p, b, None, None) + run(e, "fp2_point", &p, b, original.as_ref(), None) } // ---------------------------------------------------------------- CC Block Load (stylize3) diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index c2ff0c87..21bb8b5d 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -402,7 +402,9 @@ fn fp2_point(@builtin(global_invocation_id) gid: vec3) { } // 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 { diff --git a/crates/gpu/src/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs index 7d6d47bf..81bfaf10 100644 --- a/crates/gpu/src/tests_adjust.rs +++ b/crates/gpu/src/tests_adjust.rs @@ -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); + } + } + } +} diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 6da17284..88fa5c7c 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -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) -> Option { 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)?; diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs new file mode 100644 index 00000000..8ddee7a9 --- /dev/null +++ b/crates/render/src/cache/dependencies.rs @@ -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) -> 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) -> 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(()) + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 641f7972..af6860c1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -100,6 +100,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<'_, '_, '_> { @@ -146,12 +147,16 @@ pub(crate) fn comp_scene(ctx: &EvalCtx, layer: &Layer) -> Option { + 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 { 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(); @@ -162,7 +167,7 @@ impl EffectHost for FxHost<'_, '_, '_> { fn layer_masks(&self, id: u64) -> Option { 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(); @@ -227,14 +232,17 @@ impl EffectHost for FxHost<'_, '_, '_> { fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option { 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) }) } @@ -961,7 +969,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), diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 93c9a1a5..c22df8ab 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -586,3 +586,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)); +} diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 99fe39e4..8560392a 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -37,6 +37,21 @@ const DIAL_ROW: f32 = 64.0; const PARAM_NAME_X: f32 = 58.0; const TREE_INDENT: f32 = 14.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 { let comp = app.session.active_comp()?; let id = app.session.state.selected_layers.first()?; @@ -165,6 +180,10 @@ fn prop_row( let mut dims = vec![]; 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()); @@ -211,6 +230,8 @@ fn prop_row( let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y)); app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); 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}))); @@ -972,7 +993,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"); @@ -986,7 +1007,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { y += ROW + 2.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}))); @@ -1028,6 +1050,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); @@ -1077,6 +1100,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), @@ -1153,11 +1181,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). @@ -1172,6 +1218,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; @@ -1197,6 +1244,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 @@ -1238,23 +1288,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 = 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)) }) @@ -1266,11 +1344,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)); diff --git a/crates/ui-egui/tests/ui_effect_controls.rs b/crates/ui-egui/tests/ui_effect_controls.rs index 9347ebe2..f6137f5f 100644 --- a/crates/ui-egui/tests/ui_effect_controls.rs +++ b/crates/ui-egui/tests/ui_effect_controls.rs @@ -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))); +}