#758: layers, property groups and properties are callable

Problem: After Effects scripts call layers and property groups as a
shorthand for .property(nameOrIndex) (layer("ADBE Effect Parade"),
fxGroup(1), effect("Slider Control")("Slider")); Duik and many other
scripts use it throughout. EffectCraft's objects were Proxies around
plain objects, so each such call failed with "TypeError: not a callable
function".

Fix: Layer, PropertyGroup, MaskPropertyGroup and Property objects are
now Proxies around an arrow function carrying the object's state and
prototype (own name/length removed so the object's getters show). The
apply trap returns self.property(arg), so a call matches .property():
null when absent, Property returns null, and a missing name is the same
script error. instanceof, attribute access and script results are
unchanged; new on these objects stays a TypeError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 12:22:03 +02:00
parent 9a7d3e7699
commit bfe384e400
4 changed files with 122 additions and 5 deletions

View File

@ -83,6 +83,8 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_callable;
#[cfg(test)]
mod tests_ease_presets;
#[cfg(test)]
mod tests_panels;

View File

@ -340,6 +340,25 @@ var __attr = {
},
};
// Layers, property groups and properties are callable, like After Effects: `layer("Effects")`,
// `fx(1)` and `effect("Slider Control")("Slider")` are `.property(...)`. A Proxy is callable
// only around a function, so the object's state moves onto an arrow function (no `prototype`;
// its own `name` and `length` are removed so the object's getters show) with the object's
// prototype; `instanceof` and attribute access are unchanged.
function __callable(o) {
var f = () => undefined;
delete f.name;
delete f.length;
Object.setPrototypeOf(f, Object.getPrototypeOf(o));
var keys = Object.keys(o);
for (var i = 0; i < keys.length; i++) f[keys[i]] = o[keys[i]];
var self = new Proxy(f, {
get: __attr.get,
apply: function (t, thisArg, args) { return self.property(args[0]); },
});
return self;
}
// 1-based collections with `[n]` access.
var __coll = {
get: function (t, k, r) {
@ -604,7 +623,7 @@ function PropertyGroup(layer, uid) {
this.__layer = layer;
this.__uid = uid;
this.__kind = "PropertyGroup";
return new Proxy(this, __attr);
return __callable(this);
}
PropertyGroup.prototype = Object.create(PropertyBase.prototype);
PropertyGroup.prototype.constructor = PropertyGroup;
@ -647,7 +666,7 @@ function MaskPropertyGroup(layer, uid) {
this.__layer = layer;
this.__uid = uid;
this.__kind = "MaskPropertyGroup";
return new Proxy(this, __attr);
return __callable(this);
}
MaskPropertyGroup.prototype = Object.create(PropertyGroup.prototype);
MaskPropertyGroup.prototype.constructor = MaskPropertyGroup;
@ -679,7 +698,7 @@ function Property(layer, uid) {
this.__layer = layer;
this.__uid = uid;
this.__kind = "Property";
return new Proxy(this, __attr);
return __callable(this);
}
Property.prototype = Object.create(PropertyBase.prototype);
Property.prototype.constructor = Property;
@ -981,7 +1000,7 @@ Layer.prototype = {
this.__comp = comp;
this.__id = id;
this.__kind = kind;
return new Proxy(this, __attr);
return __callable(this);
},
__info: function () { return __get("layer", { comp: this.__comp, layer: this.__id }); },
__p: function (o) {

View File

@ -0,0 +1,96 @@
//! Layers, property groups and properties are callable like After Effects (#758):
//! `layer("ADBE Effect Parade")`, `fxGroup(1)` and `fx("Slider")` are `.property(...)`.
use std::sync::Arc;
use effectcraft_engine::Session;
use effectcraft_engine::keyframe::Value as KV;
use serde_json::json;
use crate::{Outcome, run_code};
fn session() -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() };
crate::install(&mut s);
s
}
fn ok(s: &mut Session, code: &str) -> Outcome {
let o = run_code(s, code, "test.jsx");
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
o
}
#[test]
fn layers_groups_and_properties_are_callable() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("M", 200, 200, 1, 2, 30);
var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1);
l("ADBE Effect Parade").addProperty("ADBE Slider Control");
var fxGroup = l("Effects");
var fx = fxGroup(1);
fx("Slider").setValue(42);
l("ADBE Mask Parade").addProperty("ADBE Mask Atom");
var pos = l("ADBE Transform Group")("ADBE Position");
[
l("ADBE Effect Parade").matchName, fxGroup.numProperties, fx.matchName, fx.name,
l("Effects")("Slider Control")("Slider").value, fxGroup(1)(1).value,
l("Masks")(1).matchName, l("Masks")(1)("ADBE Mask Shape").name,
pos.matchName, pos.name, pos.propertyIndex, pos.parentProperty("Opacity").value,
pos.parentProperty.parentProperty(1).matchName === l.property(1).matchName,
l("Nope") === null, fxGroup(9) === null, pos("anything") === null,
l instanceof AVLayer, fxGroup instanceof PropertyGroup, pos instanceof Property, l.name,
l.transform.position.matchName, l.Effects(1).matchName
]
"#,
);
assert_eq!(
o.result,
json!([
"ADBE Effect Parade",
1,
"ADBE Slider Control",
"Slider Control",
42,
42,
"ADBE Mask Atom",
"Mask Path",
"ADBE Position",
"Position",
2,
100,
true,
true,
true,
true,
true,
true,
true,
"S",
"ADBE Position",
"ADBE Slider Control"
])
);
let c = s.project.items.values().find_map(|i| i.as_comp()).expect("comp");
let l = c.layers.first().expect("layer");
assert_eq!(l.props.prop("effects/#1/slider").map(|p| p.value.clone()), Some(KV::Scalar(42.0)));
}
#[test]
fn calling_without_a_name_is_a_script_error() {
let mut s = session();
for code in [
"var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l();",
"var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l('Transform')(null);",
"var c = app.project.items.addComp('M', 100, 100, 1, 1, 25); var l = c.layers.addNull(); new l('Transform');",
] {
let o = run_code(&mut s, code, "test.jsx");
assert!(o.error.is_some(), "expected an error for {code}");
}
// A called layer still reports itself as a layer, and its JSON result is unchanged.
let o = ok(&mut s, "var c = app.project.items.addComp('N', 100, 100, 1, 1, 25); var l = c.layers.addNull(); l.name = 'Ctl'; l");
assert_eq!(o.result, json!({ "type": "[object AVLayer]", "name": "Ctl" }));
}

View File

@ -18,7 +18,7 @@ our own scripts; no real-world script library has been run against both apps.
| 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 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 | good | `crates/script` tests (≈ 10 test files) |
| 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) |
| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` |
| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) |
| Startup / Shutdown folders | yes | not run | missing | plugins.md |