Expressions, Scripting: a layer's property numbers and match names follow After Effects (#3528) (#3529)

* Expressions, Scripting: a layer's property numbers follow After Effects

In After Effects a layer's top-level number reaches the same group on every layer of a kind:
Marker is 1, and slots the layer has not used yet (Time Remap, Motion Trackers, Layer Styles,
Data, Sets...) keep their place. EffectCraft numbered only the groups a layer holds, without
Marker, so `layer(n)` and `layer.property(n)` reached a different group, and the numbers moved
as groups were added. Rigs that read a palette by number, such as
`comp("Palette").layer(1)(4)(n)(1)` on a shape layer, read Transform instead of Effects; their
`try` fell back to the stored value and every linked colour rendered near-black, with no
expression error.

`effectcraft_project::ae_order` holds After Effects' slot order per layer kind, observed in
After Effects 26.3 from a script (`layer.property(i).matchName`) and from an expression
(`thisComp.layer(..)(i).name`) on new layers of each kind. The expression host and the
scripting model resolve a layer's numbers through it: 1 is the markers, a used slot is its
group, an unused one is an empty group (never null, so loops over `numProperties` are safe).
`numProperties` at layer level and `propertyIndex` of top-level groups are After Effects'
numbers too. 3D model and primitive layers keep EffectCraft's own numbers.

* Expressions: a layer's top-level groups by After Effects match name

`thisLayer("ADBE Effect Parade")`, as scripts such as Karton write their expressions, reaches the
group through the same table as the layer's numbers: Effects, or an empty group for a slot the
layer has not used, and `("ADBE Marker")` the markers.

* Expressions, Scripting: review follow-ups for After Effects layer numbers

- An enabled Time Remap (a top-level property) reports After Effects' number 2 as its propertyIndex.
- In scripting, an unused slot resolves to the same empty group by match name
  (`property("ADBE Layer Styles")`) as by number (`property(7)`).
- The empty group is callable like any group: `layer.property(7)(1)` is null, not a TypeError.
This commit is contained in:
Sean Hawkey authored and GitHub committed 2026-11-12 13:40:56 +05:30
1 parent 9a4d02a827
commit 24df676af5
10 files changed
+472 -12

No files matched your search

+24 -2
View File
@@ -5,6 +5,7 @@
use effectcraft_engine::Session;
use effectcraft_engine::color::Label;
use effectcraft_keyframe::{Interp, TextDoc, Value as KV};
use effectcraft_project::ae_order::{self, AeChild};
use effectcraft_project::{Comp, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, PropGroup};
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
@@ -318,7 +319,8 @@ fn indexed_parade(g: &PropGroup) -> bool {
fn node_json(c: &Comp, l: &Layer, uid: u64) -> R {
if uid == 0 || uid == l.props.uid {
let n = l.props.children.len();
// After Effects' count (every slot, used or not); 3D model layers count their own groups.
let n = ae_order::ae_count(l).unwrap_or(l.props.children.len());
return Ok(json!({
"uid": l.props.uid, "kind": "group", "name": l.name, "matchName": matchnames::layer(&l.source), "matchId": "layer",
"index": c.index_of(l.id).unwrap_or(0), "depth": 0, "parent": null, "isLayer": true, "numProperties": n,
@@ -329,7 +331,8 @@ fn node_json(c: &Comp, l: &Layer, uid: u64) -> R {
let ch = chain(l, uid).ok_or("the property no longer exists (it was deleted)")?;
let node = *ch.last().ok_or("bad property")?;
let parent_group: &PropGroup = if ch.len() >= 2 { ch[ch.len() - 2].as_group().ok_or("bad tree")? } else { &l.props };
let index = parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0);
let top = if ch.len() == 1 { ae_order::ae_index(l, node.match_id()) } else { None };
let index = top.unwrap_or_else(|| parent_group.children.iter().position(|x| x.uid() == uid).map(|i| i + 1).unwrap_or(0));
let parent_uid = if ch.len() >= 2 { ch[ch.len() - 2].uid() } else { l.props.uid };
let match_name = matchnames::node(&ch);
let instance_parent = indexed_parade(parent_group) && ch.len() >= 2 || (ch.len() == 2 && ch[0].match_id() == "contents");
@@ -475,6 +478,25 @@ fn child(s: &Session, a: &J) -> R {
let uid = u(a, "uid").unwrap_or(0);
let g: &PropGroup = if uid == 0 || uid == l.props.uid { &l.props } else { l.props.find_group(uid).ok_or("not a property group")? };
let key = a.get("key").cloned().unwrap_or(J::Null);
// A layer's numbers and match names are After Effects' (Marker is 1, unused groups keep their
// slot and resolve to the same empty group by number or by match name).
let slot = match &key {
J::Number(n) => Some(n.as_u64().and_then(|i| usize::try_from(i).ok())),
J::String(name) => ae_order::ae_index_of_match_name(l, name).map(Some),
_ => None,
};
if std::ptr::eq(g, &l.props)
&& ae_order::ae_slots(&l.source).is_some()
&& let Some(slot) = slot
{
let Some(i) = slot else { return Ok(J::Null) };
return match ae_order::ae_child(l, i) {
Some(AeChild::Node(n)) => node_json(c, l, n.uid()),
Some(AeChild::Marker) => Ok(json!({"marker": true})),
Some(AeChild::Absent(s)) => Ok(json!({"absent": true, "name": s.name, "matchName": s.match_name, "index": i})),
None => Ok(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) => find_child(l, g, k, NESTED_CHILD_DEPTH),
+36
View File
@@ -653,10 +653,46 @@ TextDocument.prototype = {
function __propOf(layer, info) {
if (!info) return null;
if (info.isLayer) return layer;
if (info.marker) return layer.marker;
if (info.absent) return __absentGroup(layer, info);
if (info.kind === "group") return info.isMask ? new MaskPropertyGroup(layer, info.uid) : new PropertyGroup(layer, info.uid);
return new Property(layer, info.uid);
}
// A top-level slot After Effects always has and this layer does not use yet (Layer Styles before
// any style, Data, Sets…): an empty group, so loops over `numProperties` meet no null.
function __absentGroup(layer, info) {
var g = {
__kind: "PropertyGroup",
get name() { return info.name; },
get matchName() { return info.matchName; },
get propertyIndex() { return info.index; },
get propertyDepth() { return 1; },
get propertyType() { return PropertyType.NAMED_GROUP; },
get parentProperty() { return layer; },
get numProperties() { return 0; },
get enabled() { return true; },
get active() { return layer.enabled; },
get canSetEnabled() { return false; },
get isModified() { return false; },
get isEffect() { return false; },
get isMask() { return false; },
get elided() { return false; },
get selected() { return false; },
property: function () { return null; },
propertyGroup: function (n) { return n === undefined || n === 1 ? layer : null; },
canAddProperty: function () { return false; },
addProperty: function (n) { throw __err("Can not add \"" + n + "\" to \"" + info.name + "\""); },
toString: function () { return "[object PropertyGroup]"; },
};
// Callable like any group: `layer.property(7)(1)` is `.property(1)`, null.
var f = function (k) { return g.property(k); };
delete f.name;
delete f.length;
for (const k of Object.getOwnPropertyNames(g)) Object.defineProperty(f, k, Object.getOwnPropertyDescriptor(g, k));
return f;
}
function PropertyBase() {}
PropertyBase.prototype = {
__info: function () { return __get("node", { comp: this.__layer.__comp, layer: this.__layer.__id, uid: this.__uid }); },
+1
View File
@@ -10,6 +10,7 @@ use serde_json::{Value, json};
use crate::{Outcome, run_code};
mod ae_layer_order;
mod callable;
mod ease_presets;
mod evalfile;
+121
View File
@@ -0,0 +1,121 @@
//! `layer.property(n)` and the layer's `numProperties` use After Effects' top-level numbers
//! (`project::ae_order`): Marker is 1 and unused slots keep their place as empty groups, so a loop
//! over `numProperties` meets every slot and never a null.
use std::sync::Arc;
use effectcraft_engine::Session;
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 layer_numbers_follow_after_effects() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("M", 200, 200, 1, 2, 30);
var solid = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1);
solid.Effects.addProperty("ADBE Slider Control");
var shape = c.layers.addShape();
var text = c.layers.addText("t");
var cam = c.layers.addCamera("Cam", [100, 100]);
function names(l) { var o = []; for (var i = 1; i <= l.numProperties; i++) o.push(l.property(i).matchName); return o; }
[names(solid), names(shape), names(text), names(cam),
solid.property(5)(1)(1).value, solid.property(5).propertyIndex, solid.property("Effects").propertyIndex,
solid.property(1) === solid.marker || solid.property(1).matchName, solid.property(16)]
"#,
);
let av = [
"ADBE Marker",
"ADBE Time Remapping",
"ADBE MTrackers",
"ADBE Mask Parade",
"ADBE Effect Parade",
"ADBE Transform Group",
"ADBE Layer Styles",
"ADBE Plane Options Group",
"ADBE Extrsn Options Group",
"ADBE Material Options Group",
"ADBE Audio Group",
"ADBE Data Group",
"ADBE Layer Overrides",
"ADBE Layer Sets",
"ADBE Source Options Group",
];
let vector = |first: &str| {
json!([
"ADBE Marker",
first,
"ADBE Mask Parade",
"ADBE Effect Parade",
"ADBE Transform Group",
"ADBE Layer Styles",
"ADBE Extrsn Options Group",
"ADBE Material Options Group",
"ADBE Audio Group",
"ADBE Layer Sets"
])
};
let r = o.result.as_array().unwrap();
assert_eq!(r[0], json!(av), "solid");
assert_eq!(r[1], vector("ADBE Root Vectors Group"), "shape");
assert_eq!(r[2], vector("ADBE Text Properties"), "text");
assert_eq!(r[3], json!(["ADBE Marker", "ADBE Transform Group", "ADBE Camera Options Group"]), "camera");
assert_eq!(r[4], json!(0), "the slider's value through property(5)");
assert_eq!(r[5], json!(5));
assert_eq!(r[6], json!(5), "propertyIndex by name is After Effects' too");
assert_eq!(r[7], json!("ADBE Marker"));
assert_eq!(r[8], json!(null), "past the last slot");
}
#[test]
fn walking_every_property_meets_no_null() {
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.addShape();
var n = 0;
function walk(g) {
for (var i = 1; i <= g.numProperties; i++) {
var p = g.property(i);
if (p === null) throw new Error("null at " + i + " of " + g.name);
if (p.propertyType === PropertyType.PROPERTY) n++; else walk(p);
}
}
walk(l);
n > 0
"#,
);
assert_eq!(o.result, json!(true));
}
#[test]
fn an_unused_slot_is_the_same_by_number_and_match_name_and_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);
var byName = l.property("ADBE Layer Styles"), byNumber = l.property(7);
[byName !== null && byName.matchName, byName !== null && byName.numProperties, byNumber.propertyIndex, l.property(7)(1), l(12).name]
"#,
);
assert_eq!(o.result, json!(["ADBE Layer Styles", 0, 7, null, "Data"]));
}