diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index d1c9568c..a4f835a6 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -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; diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index f50a968c..76b4239e 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -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) { diff --git a/crates/script/src/tests_callable.rs b/crates/script/src/tests_callable.rs new file mode 100644 index 00000000..13d6f787 --- /dev/null +++ b/crates/script/src/tests_callable.rs @@ -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" })); +} diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index fef88efb..36ff294b 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -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 |