From 08d024e8866f0b1751fb323772511d1daf1d0006 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:00:52 +0200 Subject: [PATCH 1/3] #1063: find a Levels alpha control on the effect by its name Pro Levels2 keeps its Alpha controls one group down, under Alpha, and a script's effect.property("Alpha Input White") only searched the effect's direct children, so it returned null. The lookup now searches the groups nested below the direct children, up to three levels, after the direct match fails. Direct children keep their priority. A regression test checks the name on the effect and the same control through the Alpha group. Co-Authored-By: Claude Haiku 5.5 --- crates/script/src/model.rs | 49 +++++++++++++++++++++++++------------- crates/script/src/tests.rs | 18 ++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..dfdf458c9 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -402,6 +402,38 @@ fn alias(k: &str) -> Option<&'static str> { }) } +/// How many groups below a property group a string key may search (#1063: Pro Levels2's Alpha +/// controls sit one group down, under Alpha). +const NESTED_CHILD_DEPTH: u8 = 3; + +/// The child of `g` that a string key names. The direct children are searched first, then the +/// groups nested below them, up to `depth` levels. +fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option<&'a Node> { + let chain_of = |n: &Node| -> String { + let mut ch = if g.uid == l.props.uid { vec![] } else { chain(l, g.uid).unwrap_or_default() }; + ch.push(n); + matchnames::node(&ch) + }; + let lk = loose(k); + g.children + .iter() + .find(|n| chain_of(n) == k) + .or_else(|| g.children.iter().find(|n| n.name() == k)) + .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) + .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) + .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) + .or_else(|| { + if depth == 0 { + return None; + } + g.children.iter().find_map(|n| match n { + Node::Group(sub) => find_child(l, sub, k, depth - 1), + Node::Prop(_) => None, + }) + }) +} + fn child(s: &Session, a: &J) -> R { let (_, c, l) = layer_of(s, a)?; let uid = u(a, "uid").unwrap_or(0); @@ -409,22 +441,7 @@ fn child(s: &Session, a: &J) -> R { let key = a.get("key").cloned().unwrap_or(J::Null); let found = match &key { J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)), - J::String(k) => { - let chain_of = |n: &Node| -> String { - let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() }; - ch.push(n); - matchnames::node(&ch) - }; - let lk = loose(k); - g.children - .iter() - .find(|n| chain_of(n) == *k) - .or_else(|| g.children.iter().find(|n| n.name() == k)) - .or_else(|| g.children.iter().find(|n| n.match_id() == k)) - .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) - .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) - .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) - } + J::String(k) => find_child(l, g, k, NESTED_CHILD_DEPTH), _ => None, }; match found { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..27812b825 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1010,3 +1010,21 @@ fn scripted_text_range_selector_resolves_and_is_the_only_selector() { ); assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); } + +/// Pro Levels2's Alpha controls sit one group down (under Alpha), so a script finds them by their +/// name on the effect as well as through the group (#1063). +#[test] +fn scripted_levels_alpha_controls_resolve_from_the_effect() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Levels", 320, 180, 1, 3, 30); + var l = c.layers.addSolid([1, 0, 0], "S", 320, 180, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Pro Levels2"); + fx.property("Alpha Input White").setValue(0.5); + [fx.property("Alpha").property("Alpha Input White").value, fx.property("Alpha Input White").value] + "#, + ); + assert_eq!(o.result, json!([0.5, 0.5])); +} From 55ce6017107557ab0c8cc2a26e070a70e9636648 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:48:16 +0200 Subject: [PATCH 2/3] #1063: return the text animator property a script asked for addProperty("ADBE Text Tracking Amount") returned the first property added to the animator, which is the Tracking Type companion, so the script changed the wrong value. The engine now reports the requested property's position in the Properties group, and the script returns that property. An animator's Properties group also finds its properties by their After Effects names, such as ADBE Text Tracking Amount, which the name table already had in the other direction. A regression test checks the property returned, its value, and the lookup by name. Not covered: the Advanced and Wiggly selector children and the Expression amount, which still keep their own names. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/text_anim.rs | 6 +++++- crates/script/src/model.rs | 28 ++++++++++++++++++------- crates/script/src/prelude.js | 5 ++--- crates/script/src/tests.rs | 20 ++++++++++++++++++ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 228d48424..fff32c2ff 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -139,6 +139,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { } let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?; let auid = animator_uid(s, &layer, p, C)?; + let mut index = 0; s.edit("Add Text Animator Property", None, |proj, _| { let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -148,10 +149,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { for k in &kinds { add_props(&mut ids, anim, k, three); } + // The requested property's 1-based position in the Properties group. A companion (such as + // Tracking Type) is added before it, so the first new child is not always the one asked for. + index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1); proj.next_id = next; Ok(()) })?; - Ok(json!({"animator": auid})) + Ok(json!({"animator": auid, "index": index})) } pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result { diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index dfdf458c9..9df540b86 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -415,11 +415,14 @@ fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option< matchnames::node(&ch) }; let lk = loose(k); + // An animator's Properties group also answers to the After Effects names of its properties. + let animator_props = g.match_id == "properties"; g.children .iter() .find(|n| chain_of(n) == k) .or_else(|| g.children.iter().find(|n| n.name() == k)) .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| animator_props && animator_property_match_name(n.match_id()) == Some(k))) .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) @@ -671,10 +674,9 @@ fn resolve_add(s: &Session, a: &J) -> R { }) } -/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match -/// name or a display name. -fn text_animator_property(name: &str) -> Option<&'static str> { - const AE: &[(&str, &str)] = &[ +/// The After Effects match name and key of each text animator property. +fn animator_property_table() -> &'static [(&'static str, &'static str)] { + &[ ("ADBE Text Anchor Point 3D", "anchor"), ("ADBE Text Position 3D", "position"), ("ADBE Text Scale 3D", "scale"), @@ -698,12 +700,24 @@ fn text_animator_property(name: &str) -> Option<&'static str> { ("ADBE Text Character Offset", "characterOffset"), ("ADBE Text Character Replace", "characterValue"), ("ADBE Text Blur", "blur"), - ]; - if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { + ] +} + +/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match +/// name or a display name. +fn text_animator_property(name: &str) -> Option<&'static str> { + let table = animator_property_table(); + if let Some((_, k)) = table.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { return Some(k); } let n = loose(name); - AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) + table.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) +} + +/// The After Effects match name of a text animator property key (`tracking` is +/// `ADBE Text Tracking Amount`), for scripts that look a property up by that name (#1063). +fn animator_property_match_name(key: &str) -> Option<&'static str> { + animator_property_table().iter().find(|(_, k)| *k == key).map(|(m, _)| *m) } /// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9daac6ab7..dc159b878 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -718,9 +718,8 @@ PropertyGroup.prototype.addProperty = function (name) { } else if (r.kind === "animator") { uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; } else if (r.kind === "animatorProperty") { - var before = this.numProperties; - __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); - return this.property(before + 1); + var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(added.index); } else if (r.kind === "selector") { var n0 = this.numProperties; __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 27812b825..7170652bf 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1028,3 +1028,23 @@ fn scripted_levels_alpha_controls_resolve_from_the_effect() { ); assert_eq!(o.result, json!([0.5, 0.5])); } + +/// A text animator property added by its After Effects name is the one asked for, not its Tracking +/// Type companion, and it can be found again by that name (#1063). +#[test] +fn scripted_text_animator_property_is_the_one_asked_for() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator"); + var props = a.property("ADBE Text Animator Properties"); + var added = props.addProperty("ADBE Text Tracking Amount"); + added.setValue(20); + [added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name] + "#, + ); + assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount"])); +} From 942b47847295ffb06ffdefdeb15c51b11ed8ba66 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:22:49 +0000 Subject: [PATCH 3/3] 1063: name the companion properties of text animators (Track Type, Skew Axis, X / Y Rotation) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/script/src/matchnames.rs | 25 ++++++++++++++++++++++++- crates/script/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 15c1201ea..3dd717aa8 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -118,6 +118,16 @@ pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[ ("blur", "ADBE Text Blur"), ]; +/// Companion properties that exist below an animator but are not kinds `addProperty` accepts: +/// they come with Tracking (Track Type), Skew (Skew Axis) and per-character 3D Rotation +/// (X and Y Rotation). Lookup and `.matchName` know them; `addProperty` does not. +pub const TEXT_ANIMATOR_COMPANIONS: &[(&str, &str)] = &[ + ("trackingType", "ADBE Text Track Type"), + ("skewAxis", "ADBE Text Skew Axis"), + ("rotationX", "ADBE Text Rotation X"), + ("rotationY", "ADBE Text Rotation Y"), +]; + const TEXT_SELECTORS: &[(&str, &str)] = &[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")]; @@ -516,7 +526,7 @@ pub fn node(chain: &[&Node]) -> String { "text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own), "text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own), "text" if pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => { - find(TEXT_ANIMATOR_PROPERTIES, id).map(str::to_string).unwrap_or_else(own) + find(TEXT_ANIMATOR_PROPERTIES, id).or_else(|| find(TEXT_ANIMATOR_COMPANIONS, id)).map(str::to_string).unwrap_or_else(own) } "text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own), "cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own), @@ -579,6 +589,19 @@ mod tests { } } + /// #1063: the companions an animator property brings (Track Type, Skew Axis, X / Y Rotation) + /// have their After Effects names too, though `addProperty` does not accept them. + #[test] + fn text_animator_companions_have_after_effects_names() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_COMPANIONS { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert!(!TEXT_ANIMATOR_PROPERTIES.iter().any(|(k, m)| k == id || m == name), "{id} is not an addProperty kind"); + } + } + #[test] fn text_range_selector_names() { let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 786dec561..875885c11 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1740,6 +1740,29 @@ fn scripted_text_animator_property_is_the_one_asked_for() { assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2])); } +/// The companion properties added with Tracking and Skew are found by their After Effects names, +/// but cannot be added on their own (#1063). +#[test] +fn text_animator_companion_properties_resolve_by_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var props = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator").property("ADBE Text Animator Properties"); + props.addProperty("ADBE Text Tracking Amount"); + props.addProperty("ADBE Text Skew"); + var type = props.property("ADBE Text Track Type"); + var axis = props.property("ADBE Text Skew Axis"); + var added = ""; + try { props.addProperty("ADBE Text Track Type"); added = "added"; } catch (e) { added = "refused"; } + [type ? type.matchName : null, axis ? axis.matchName : null, added] + "#, + ); + assert_eq!(o.result, json!(["ADBE Text Track Type", "ADBE Text Skew Axis", "refused"])); +} + /// #1936: an array whose length doesn't match the property's dimension is an error in After /// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal. #[test]