Merge pull request #1620

This commit is contained in:
Brandon Thomas
2026-10-18 09:35:34 -04:00
7 changed files with 191 additions and 12 deletions
+43 -9
View File
@@ -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()).unwrap_or(0) + 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 uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::<u64>().ok()?;
let index = parent.children.iter().position(|c| c.uid() == uid)?;
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)?;
+16 -1
View File
@@ -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;
@@ -427,6 +427,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; }
@@ -645,6 +657,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]'; },
};
@@ -730,6 +744,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);
+1
View File
@@ -186,6 +186,7 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
},
14 => 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| {
+2
View File
@@ -1362,3 +1362,5 @@ fn time_to_frames_preserves_coercion_and_small_durations() {
assert_eq!(f.num(expression, 0.0), expected, "{expression}");
}
}
mod property_group;
+3 -1
View File
@@ -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.
+124
View File
@@ -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);
}
+2 -1
View File
@@ -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 |