From dac6421ec353f4c8ba7e587d1ec03bcbfe443f8f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:23:52 -0400 Subject: [PATCH] Clipboard: effect properties in twirl-down groups, numbered as After Effects does (#1908) --- crates/engine/src/keyframe_text.rs | 40 ++++++++++++++++++++++-- crates/engine/src/tests_keyframe_text.rs | 24 +++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 6fa625fb5..59367b19b 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -181,16 +181,50 @@ fn mask(chain: &[&Node], pr: &Property) -> Option { } /// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, -/// then the property's name and place, counting the effect's hidden input layer as the first. +/// then the property's name and After Effects index. Properties in twirl-down groups are +/// written without their group, as After Effects does. fn effect(chain: &[&Node], pr: &Property) -> Option { - let [Node::Group(effects), Node::Group(fx), _] = chain else { return None }; + let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; let GroupKind::Effect { effect } = &fx.kind else { return None }; let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); let (labels, values) = by_ui(pr)?; - let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, place(fx, pr.uid)? + 1); + let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(fx, pr.uid)?); Some(Channel { header, labels, values }) } +/// After Effects' index of an effect property: the effect's hidden input layer is #1, and a +/// twirl-down group takes an index where it opens and another where it closes. The hidden +/// source choice EffectCraft keeps beside a layer parameter has none. +fn effect_index(fx: &PropGroup, uid: Uid) -> Option { + fn count(g: &PropGroup, uid: Uid, n: &mut usize) -> bool { + for c in &g.children { + match c { + Node::Prop(p) if is_layer_source(g, p) => {} + Node::Prop(p) => { + *n += 1; + if p.uid == uid { + return true; + } + } + Node::Group(sub) => { + *n += 1; + if count(sub, uid, n) { + return true; + } + *n += 1; + } + } + } + false + } + let mut n = 1; + count(fx, uid, &mut n).then_some(n) +} + +fn is_layer_source(g: &PropGroup, p: &Property) -> bool { + p.ui == ParamUi::Hidden && g.props().any(|l| l.ui == ParamUi::Layer && effectcraft_effects::layer_source_id(&l.match_id) == p.match_id) +} + /// 1-based position of a child in its group. fn place(group: &PropGroup, uid: Uid) -> Option { group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index d1f9ddb9b..dfdbe23a1 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -186,7 +186,8 @@ fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String { #[test] fn effect_properties_are_written_as_after_effects_does() { - // The blocks After Effects 2019 wrote for the same effects and values. + // The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown + // Menu Control: that block follows its other popups, written 1-based with no unit). let (mut s, l) = session(); for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] { s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); @@ -216,6 +217,27 @@ fn effect_properties_are_written_as_after_effects_does() { ); } +#[test] +fn effect_properties_have_after_effects_indices() { + // After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9, + // Complexity (after it) #17 and Evolution #25: a group takes an index where it opens and + // closes, and its properties are written without it. Set Matte's Use For Matte is #3: the + // layer parameter before it has no extra index for its source choice. + let (mut s, l) = session(); + for fx in ["Fractal Noise", "Set Matte"] { + s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap(); + } + let text = copy_props(&mut s, l, &["effects/#1/transform/rotation", "effects/#1/complexity", "effects/#1/evolution", "effects/#2/takeMatteFrom"]); + for header in [ + "Effects\tFractal Noise #1\tRotation #9\n", + "Effects\tFractal Noise #1\tComplexity #17\n", + "Effects\tFractal Noise #1\tEvolution #25\n", + "Effects\tSet Matte #2\tUse For Matte #3\n", + ] { + assert!(text.contains(header), "{header}in\n{text}"); + } +} + #[test] fn mask_properties_are_written_as_after_effects_does() { // The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not