Merge pull request #423 from cdinhoo/codex/effect-input-control-fixes
This commit is contained in:
commit
91d37dd449
|
|
@ -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: ¶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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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<effectcraft_eff
|
|||
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();
|
||||
|
|
@ -162,7 +167,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();
|
||||
|
|
@ -227,14 +232,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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Layer> {
|
||||
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<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))
|
||||
})
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue