From d347a0efca0ff475368a22227f2135967507f896 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 05:31:49 +0200 Subject: [PATCH 1/2] Expressions: propertyGroup() and a group's propertyIndex (#1619) The After Effects stagger idiom id = thisProperty.propertyGroup(2).propertyIndex; failed with "not a callable function": the expression object model had no propertyGroup() and property groups had no propertyIndex. - Property.propertyGroup(countUp = 1) and PropertyGroup.propertyGroup() return the group countUp levels up; from a top-level group they reach the layer. A count below 1, not a number, or above the layer is an error naming propertyGroup, never a crash. - PropertyGroup.propertyIndex: the group's 1-based index among its siblings (Child requests now carry it). - One new host request (Ancestor) walks up from the node's full uid path, so it also works for the property being evaluated. - Tests for the stagger expression, effect parameters, reaching the layer, other layers' properties and groups and out-of-range counts; the fuzzer seeds and tokens include the new members. --- crates/expr/src/host.rs | 52 ++++++++-- crates/expr/src/prelude.js | 17 +++- crates/expr/src/runtime.rs | 1 + crates/expr/src/tests.rs | 2 + crates/expr/src/tests/fuzz.rs | 4 +- crates/expr/src/tests/property_group.rs | 124 ++++++++++++++++++++++++ docs/scripting-parity.md | 3 +- 7 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 crates/expr/src/tests/property_group.rs diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 87d2d8be..e87070f2 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -37,8 +37,12 @@ pub enum Req { LayerLookup { comp: u64, key: Key }, /// `[name, index, inPoint, outPoint, startTime, width, height, parentId|null, hasVideo, is3D, source, enabled]`. LayerInfo { comp: u64, layer: u64 }, - /// `[path, isGroup, name, matchName, numChildren]` or null. + /// `[path, isGroup, name, matchName, numChildren, propertyIndex]` or null. Child { comp: u64, layer: u64, group: String, key: Key }, + /// `propertyGroup(up)` of the property or group at `path`: the group `up` levels above it as + /// `[path, isGroup, name, matchName, numChildren, propertyIndex]`, `"layer"` when that is the + /// layer itself, or null above the layer. + Ancestor { comp: u64, layer: u64, path: String, up: i64 }, /// `[name, uid, kind, dims, keyTimes, keyValues, hasExpression, propertyIndex, matchName]`. PropInfo { comp: u64, layer: u64, path: String }, /// Value at comp time `t`; `pre` = keyframes only (no expression). @@ -256,6 +260,18 @@ fn alias(key: &str) -> &str { } } +/// A child node as expression data (see [`Req::Child`]); `index` is 1-based among its siblings. +fn node_resp(path: String, n: &Node, index: usize) -> Resp { + Resp::List(vec![ + Resp::Str(path), + Resp::Bool(n.as_group().is_some()), + Resp::Str(n.name().into()), + Resp::Str(n.match_id().into()), + Resp::Num(n.as_group().map_or(0, |g| g.children.len()) as f64), + Resp::Num(index as f64), + ]) +} + fn find_child<'g>(g: &'g PropGroup, key: &Key) -> Option<&'g Node> { match key { Key::Index(i) => usize::try_from(*i).ok().filter(|i| *i >= 1).and_then(|i| g.children.get(i - 1)), @@ -353,15 +369,33 @@ impl<'a> Resolver<'a> { let l = self.layer(*comp, *layer)?; let g = if group.is_empty() { &l.props } else { l.props.group(group)? }; let n = find_child(g, key)?; + let index = g.children.iter().position(|c| c.uid() == n.uid()).map_or(1, |i| i + 1); let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) }; - let count = n.as_group().map_or(0, |g| g.children.len()); - Resp::List(vec![ - Resp::Str(path), - Resp::Bool(n.as_group().is_some()), - Resp::Str(n.name().into()), - Resp::Str(n.match_id().into()), - Resp::Num(count as f64), - ]) + node_resp(path, n, index) + } + Req::Ancestor { comp, layer, path, up } => { + let l = self.layer(*comp, *layer)?; + // The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes. + let uid = match self.prop(*comp, *layer, path) { + Some((_, p)) => p.uid, + None => l.props.node(path)?.uid(), + }; + let full = l.props.path_of(uid)?; + let segs: Vec<&str> = full.split('/').collect(); + let up = usize::try_from(*up).ok().filter(|u| *u >= 1 && *u <= segs.len())?; + if up == segs.len() { + return Some(Resp::Str("layer".into())); + } + let keep = segs.len() - up; + let group_path = segs.get(..keep)?.join("/"); + let parent = match keep { + 1 => &l.props, + _ => l.props.group(&segs.get(..keep - 1)?.join("/"))?, + }; + let last = segs.get(keep - 1)?; + let index = parent.children.iter().position(|c| format!("@{}", c.uid()) == *last)?; + let n = parent.children.get(index)?; + node_resp(group_path, n, index + 1) } Req::PropInfo { comp, layer, path } => { let (l, p) = self.prop(*comp, *layer, path)?; diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 5f6dd015..ed66148d 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -18,7 +18,7 @@ var selectorValue = [100, 100, 100]; // Request ops (must match runtime.rs). var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10; -var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15; +var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15, __ANCESTOR = 16; function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { time = t; @@ -424,6 +424,18 @@ function __child(c, l, group, key) { return info[1] ? __mkGroup(c, l, info) : __mkProp(c, l, info[0]); } +// `propertyGroup(countUp)` of the property or group at `path`: the group `countUp` levels above +// it (default 1), the layer at the top (#1619). +function __ancestor(c, l, path, countUp) { + var n = countUp === undefined ? 1 : __v(countUp); + if (typeof n !== 'number' || !(n >= 1) || !isFinite(n)) __err('propertyGroup(' + countUp + '): the count must be a number of 1 or more'); + n = Math.floor(n); + var info = __h(__ANCESTOR, c, l, path, n); + if (info === 'layer') return new Layer(c, l); + if (info === null) __err('propertyGroup(' + n + ') goes above the layer'); + return __mkGroup(c, l, info); +} + // Comp ------------------------------------------------------------------------------------------ function Comp(c) { this.__c = c; this.__i = null; } @@ -642,6 +654,8 @@ var __GroupProto = { get name() { return this.__info[2]; }, get matchName() { return this.__info[3]; }, get numProperties() { return this.__info[4]; }, + get propertyIndex() { return this.__info[5]; }, + propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); }, get active() { return true; }, toString: function () { return '[object PropertyGroup]'; }, }; @@ -727,6 +741,7 @@ Prop.prototype = { get numKeys() { return this.__info[4].length; }, get propertyIndex() { return this.__info[7]; }, get matchName() { return this.__info[8]; }, + propertyGroup: function (n) { return __ancestor(this.__c, this.__l, this.__p, n); }, __wrap: function (v) { var kind = this.__info[2]; if (kind === 'layer') return v === null ? null : new Layer(this.__c, v); diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 9ec8172a..53c2960b 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -186,6 +186,7 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Req::Project, 15 => Req::SourceInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? }, + 16 => Req::Ancestor { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)?, path: string(args, 3, ctx)?, up: num(args, 4, ctx)? as i64 }, _ => return Err(JsNativeError::typ().with_message("unknown host request").into()), }; let found = FRAMES.with(|f| { diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index d71db390..990b6a27 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1285,3 +1285,5 @@ fn layer_control_property_stands_for_its_layer() { assert_close!(f.num("effect('Map')(1).transform.scale[0]", 0.0), 100.0); assert_close!(f.num("effect('Map')(1).value.name == 'B' ? 1 : 0", 0.0), 1.0); } + +mod property_group; diff --git a/crates/expr/src/tests/fuzz.rs b/crates/expr/src/tests/fuzz.rs index b9fb752b..1bfaf8c1 100644 --- a/crates/expr/src/tests/fuzz.rs +++ b/crates/expr/src/tests/fuzz.rs @@ -78,6 +78,8 @@ const SEEDS: &[&str] = &[ "mask('Mask 1').maskOpacity", "mask('Mask 1').maskPath.points()[0]", "content('Rectangle 1').content('Rectangle Path 1').size[0]", + "thisProperty.propertyGroup(1).propertyIndex * 10 + thisProperty.propertyGroup(1).numProperties", + "effect(1)(1).propertyGroup(1).propertyGroup(2).name", "marker.numKeys > 0 ? marker.key(1).time : 0", "marker.nearestKey(time).comment", "thisComp.marker.key(1).time", @@ -130,7 +132,7 @@ const TOKENS: &[&str] = &[ "catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n", "/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(", "content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText", - ".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments", + ".style", ".setText(", ".propertyGroup(", ".propertyIndex", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments", ]; /// Numbers swapped in for numeric literals. diff --git a/crates/expr/src/tests/property_group.rs b/crates/expr/src/tests/property_group.rs new file mode 100644 index 00000000..debe5e5a --- /dev/null +++ b/crates/expr/src/tests/property_group.rs @@ -0,0 +1,124 @@ +//! `propertyGroup(countUp)` on properties and property groups, and a group's `propertyIndex` +//! (#1619): name-independent references up the property tree, as in After Effects. + +use super::*; + +const SCALE_1: &str = "contents/#1/transform/scale"; +const SCALE_2: &str = "contents/#2/transform/scale"; + +/// The test comp with a second shape group "Rectangle 2" on layer "Shape", and "Rectangle 1"'s +/// Transform ▸ Scale keyed 0 → 100 over 0–1 s. +fn staggered() -> Fx { + let mut f = fx(); + let g = group_push(&mut f.p, |ids| { + let rect = build::shape_rect(ids, [20.0, 20.0], [0.0, 0.0], 0.0); + build::shape_group(ids, "Rectangle 2", vec![rect]) + }); + let shape = f.layer_mut(f.shape); + shape.props.sub_mut("contents").unwrap().children.push(g.into()); + shape.props.prop_mut(SCALE_1).unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec2([0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec2([100.0, 100.0]))]; + f +} + +impl Fx { + fn shape_num(&self, path: &str, text: &str) -> f64 { + match self.ev(self.shape, path, &format!("var r = ({text}); [r, r]"), 0.0) { + Ok(v) => v.as_vec2()[0], + Err(e) => panic!("{text}: {e}"), + } + } + fn shape_err(&self, path: &str, text: &str) -> String { + match self.ev(self.shape, path, text, 0.0) { + Ok(v) => panic!("{text}: expected an error, got {v:?}"), + Err(e) => e, + } + } +} + +#[test] +fn shape_group_stagger_from_the_adobe_community_answer() { + let f = staggered(); + // thisProperty = Scale, propertyGroup(1) = Transform, propertyGroup(2) = the shape group. + let text = "offset = .1;\nid = thisProperty.propertyGroup(2).propertyIndex;\ncontent(\"Rectangle 1\").transform.scale.valueAtTime(time-id*offset)"; + let v = f.ev(f.shape, SCALE_2, text, 0.5).unwrap().as_vec2(); + // "Rectangle 2" is the second group in Contents: Rectangle 1's scale 0.2 s earlier. + assert_close!(v[0], 30.0); + assert_close!(v[1], 30.0); +} + +#[test] +fn property_group_walks_up_from_the_evaluated_property() { + let f = staggered(); + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0"), 1.0); + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup().name == 'Transform' ? 1 : 0"), 1.0); + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).name == 'Rectangle 2' ? 1 : 0"), 1.0); + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).name == 'Contents' ? 1 : 0"), 1.0); + assert_close!(f.shape_num(SCALE_1, "thisProperty.propertyGroup(2).propertyIndex"), 1.0); + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).propertyIndex"), 2.0); + // A shape group holds Blend Mode, Contents and Transform, in After Effects' order. + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyIndex"), 3.0); + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2).numProperties"), 3.0); + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(3).numProperties"), 2.0); + // Groups walk on: Transform → group → Contents. + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(2).name == 'Contents' ? 1 : 0"), 1.0); + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(1).propertyGroup(1).propertyIndex"), 2.0); +} + +#[test] +fn property_group_reaches_the_layer() { + let f = fx(); + assert_close!(f.num("thisProperty.propertyGroup(1).name == 'Transform' ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("thisProperty.propertyGroup(2).name == 'A' ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("thisProperty.propertyGroup(2).index", 0.0), 1.0); + assert_close!(f.num("thisProperty.propertyGroup(1).propertyGroup(1).name == 'A' ? 1 : 0", 0.0), 1.0); + let s = staggered(); + assert_close!(s.shape_num(SCALE_2, "thisProperty.propertyGroup(4).name == 'Shape' ? 1 : 0"), 1.0); +} + +#[test] +fn property_group_of_an_effect_parameter_is_the_effect() { + let f = fx(); + let path = "effects/#1/slider"; + let ev = |text: &str| f.ev(f.a, path, text, 0.0).unwrap().as_f64(); + assert_close!(ev("thisProperty.propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0); + assert_close!(ev("thisProperty.propertyGroup(2).name == 'Effects' ? 1 : 0"), 1.0); + // The name-independent rig: read the effect this parameter belongs to by its own name. + assert_close!(ev("effect(thisProperty.propertyGroup(1).name)('Slider') + 1"), 43.0); + assert_close!(ev("thisProperty.propertyGroup(1).propertyIndex"), 1.0); +} + +#[test] +fn property_group_on_other_layers_properties_and_groups() { + let f = staggered(); + // From layer B: another layer's property and group, reached by name. + let ev = |text: &str| f.ev(f.b, ROT, text, 0.0).unwrap().as_f64(); + assert_close!(ev("thisComp.layer('A').effect(1)('Slider').propertyGroup(1).name == 'Slider Control' ? 1 : 0"), 1.0); + assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').propertyIndex"), 2.0); + assert_close!(ev("thisComp.layer('Shape').content('Rectangle 2').transform.scale.propertyGroup(2).propertyIndex"), 2.0); + assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').content('Fill 1').propertyGroup(2).name == 'Rectangle 1' ? 1 : 0"), 1.0); + assert_close!(ev("thisComp.layer('Shape').content('Rectangle 1').propertyGroup(2).name == 'Shape' ? 1 : 0"), 1.0); + assert_close!(ev("thisComp.layer('A').effect('Slider Control').propertyIndex"), 1.0); + assert_close!(ev("thisComp.layer('A').mask('Mask 1').propertyGroup(1).numProperties"), 1.0); +} + +#[test] +fn property_group_out_of_range_is_an_error_not_a_crash() { + let f = staggered(); + for text in [ + "thisProperty.propertyGroup(0)", + "thisProperty.propertyGroup(-1)", + "thisProperty.propertyGroup(5)", + "thisProperty.propertyGroup(1e300)", + "thisProperty.propertyGroup(NaN)", + "thisProperty.propertyGroup('x')", + "thisProperty.propertyGroup(1).propertyGroup(4)", + ] { + let e = f.shape_err(SCALE_2, text); + assert!(e.contains("propertyGroup"), "{text}: {e}"); + } + let e = f.err("thisProperty.propertyGroup(3)"); + assert!(e.contains("propertyGroup"), "{e}"); + // A fractional count rounds down, as an index does. + assert_close!(f.shape_num(SCALE_2, "thisProperty.propertyGroup(2.7).propertyIndex"), 2.0); +} diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 607be9e9..7ef7e908 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -15,7 +15,7 @@ our own scripts; no real-world script library has been run against both apps. | Feature | After Effects 2026 | EffectCraft | Status | Evidence / gap | |---|---|---|---|---| | Expression language | JavaScript engine (default) and Legacy ExtendScript | JavaScript (boa) with legacy array maths (`value + [10, 0]`) | good | `crates/expr` tests; no separate Legacy ExtendScript engine mode | -| Expression object model | `thisComp`, `thisLayer`, `wiggle`, `loopOut`, `valueAtTime`, `sampleImage`, `footage()`, text style API… | Same members, from the reference | good | `crates/expr/src/tests.rs`; behaviour of edge cases unmeasured (G1) | +| Expression object model | `thisComp`, `thisLayer`, `wiggle`, `loopOut`, `valueAtTime`, `sampleImage`, `footage()`, text style API… | Same members, from the reference, including `propertyGroup(countUp)` and a group's `propertyIndex` for name-independent rigs (#1619) | good | `crates/expr/src/tests.rs`, `tests/property_group.rs`; behaviour of edge cases unmeasured (G1) | | Expression editing | Inline field, error bar, pick whip, Expression Language menu, external editors | Inline field that grows, error bar, pick whips (property and value), language menu | partial | External editor support (#364) | | Expression performance | Cached, multi-frame | Per-thread boa contexts | unknown | No benchmark | | Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) | @@ -42,5 +42,6 @@ performance benchmark 4–8 h. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Expression object model row names `propertyGroup()` and a group's `propertyIndex` (#1619) | | 2026-10-12 | minor | Estimate no longer counts `#include` / `#target` (#759, `tests_include.rs`); the table row was already current | | 2026-10-10 | major | First expressions and scripting checklist | From 71a0f8855ad1e8081c62e3db9bcfc70ed40845f9 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Fri, 16 Oct 2026 05:45:47 +0200 Subject: [PATCH 2/2] Expressions: find an ancestor's index by uid, not by formatted path --- crates/expr/src/host.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index e87070f2..f98b895a 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -369,7 +369,7 @@ impl<'a> Resolver<'a> { let l = self.layer(*comp, *layer)?; let g = if group.is_empty() { &l.props } else { l.props.group(group)? }; let n = find_child(g, key)?; - let index = g.children.iter().position(|c| c.uid() == n.uid()).map_or(1, |i| i + 1); + let index = g.children.iter().position(|c| c.uid() == n.uid()).unwrap_or(0) + 1; let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) }; node_resp(path, n, index) } @@ -392,8 +392,8 @@ impl<'a> Resolver<'a> { 1 => &l.props, _ => l.props.group(&segs.get(..keep - 1)?.join("/"))?, }; - let last = segs.get(keep - 1)?; - let index = parent.children.iter().position(|c| format!("@{}", c.uid()) == *last)?; + let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::().ok()?; + let index = parent.children.iter().position(|c| c.uid() == uid)?; let n = parent.children.get(index)?; node_resp(group_path, n, index + 1) }