diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 43400ea1d..fb0d1e07f 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -9,6 +9,7 @@ use effectcraft_geom::{Mat3, Mat4}; use effectcraft_keyframe::Value; +use effectcraft_project::ae_order::{self, AeChild}; use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property}; use effectcraft_render::EvalCtx; use effectcraft_time::Tick; @@ -272,6 +273,19 @@ fn node_resp(path: String, n: &Node, index: usize) -> Resp { ]) } +/// A top-level slot the layer has no node for, as group data: `@marker` (the markers, built by the +/// prelude) or `@absent` (an empty group, as After Effects shows an unused slot). +fn slot_resp(path: &str, s: &ae_order::AeSlot, index: usize) -> Resp { + Resp::List(vec![ + Resp::Str(path.into()), + Resp::Bool(true), + Resp::Str(s.name.into()), + Resp::Str(s.match_name.into()), + Resp::Num(0.0), + 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)), @@ -348,7 +362,36 @@ impl<'a> Resolver<'a> { 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)?; - Some(node_resp(group_path, n, index + 1)) + // A top-level group's number is After Effects' (see `ae_order`). + let number = if keep == 1 { ae_order::ae_index(l, n.match_id()) } else { None }.unwrap_or(index + 1); + Some(node_resp(group_path, n, number)) + } + + /// A child of a layer (`group` empty) or of one of its groups, by name or 1-based number. + fn child(&self, comp: u64, layer: u64, group: &str, key: &Key) -> Option { + let l = self.layer(comp, layer)?; + // A layer's numbers are After Effects' (Marker is 1, unused groups keep their slot), and so + // are its match names (`thisLayer("ADBE Effect Parade")`, as scripts such as Karton write). + let slot = match key { + Key::Index(i) => usize::try_from(*i).ok(), + Key::Name(name) => ae_order::ae_index_of_match_name(l, name), + }; + if group.is_empty() + && ae_order::ae_slots(&l.source).is_some() + && let Some(i) = slot + { + return Some(match ae_order::ae_child(l, i)? { + AeChild::Node(n) => node_resp(format!("@{}", n.uid()), n, i), + AeChild::Marker => slot_resp("@marker", &ae_order::MARKER, 1), + AeChild::Absent(s) => slot_resp("@absent", s, i), + }); + } + let g = if group.is_empty() { &l.props } else { l.props.group(group)? }; + let n = find_child(g, key)?; + let index = if group.is_empty() { ae_order::ae_index(l, n.match_id()) } else { None } + .unwrap_or_else(|| 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()) }; + Some(node_resp(path, n, index)) } fn try_resolve(&self, req: &Req) -> Option { @@ -403,14 +446,7 @@ impl<'a> Resolver<'a> { Resp::Bool(l.switches.video), ]) } - Req::Child { comp, layer, group, key } => { - 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()) }; - node_resp(path, n, index) - } + Req::Child { comp, layer, group, key } => return self.child(*comp, *layer, group, key), Req::Ancestor { comp, layer, path, up } => return self.ancestor(*comp, *layer, path, *up), Req::PropInfo { comp, layer, path } => { let (l, p) = self.prop(*comp, *layer, path)?; @@ -423,7 +459,10 @@ impl<'a> Resolver<'a> { Some(g) => l.props.group(g)?, None => &l.props, }; - g.children.iter().position(|n| format!("@{}", n.uid()) == last).map(|i| i + 1) + let i = g.children.iter().position(|n| format!("@{}", n.uid()) == last)?; + // A top-level property (an enabled Time Remap) has After Effects' number. + let top = if parent.is_none() { g.children.get(i).and_then(|n| ae_order::ae_index(l, n.match_id())) } else { None }; + Some(top.unwrap_or(i + 1)) }); Resp::List(vec![ Resp::Str(p.name.clone()), diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 9307973d8..a57145568 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -495,6 +495,8 @@ function __mod(a, d) { return ((a % d) + d) % d; } function __child(c, l, group, key) { var info = __h(__CHILD, c, l, group, key); if (info === null) return undefined; + // `thisLayer(1)` is the layer's markers, as in After Effects (see `ae_order`). + if (group === '' && info[0] === '@marker') return new Markers(c, l); return info[1] ? __mkGroup(c, l, info) : __mkProp(c, l, info[0]); } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index d16f1dd54..f206a4320 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -2025,6 +2025,7 @@ fn an_expression_cant_change_the_prelude_for_the_next() { } } +mod ae_layer_order; mod property_group; /// Timing harness for the prelude's cost on the evaluation path. Ignored: it is a measurement, not diff --git a/crates/expr/src/tests/ae_layer_order.rs b/crates/expr/src/tests/ae_layer_order.rs new file mode 100644 index 000000000..dc97a991c --- /dev/null +++ b/crates/expr/src/tests/ae_layer_order.rs @@ -0,0 +1,98 @@ +//! A layer's top-level numbers are After Effects' (`project::ae_order`): Marker is 1, and slots the +//! layer does not use (Time Remap, Motion Trackers, Layer Styles…) keep their place. Rigs that read +//! a palette by number, such as `comp("Palette").layer(1)(4)(n)(1)` on a shape layer, failed +//! silently before: `(4)` reached Transform, the expression's `try` fell back to the stored value and +//! every linked colour rendered near-black. + +use super::*; +use effectcraft_project::Property; + +/// The fixture with a Slider Control (value 7) on layer "Shape". +fn with_shape_slider() -> Fx { + let mut f = fx(); + let spec = effectcraft_effects::find("ec.control.slider").unwrap(); + let mut slider = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Swatch 1", [20.0, 20.0])); + slider.prop_mut("slider").unwrap().value = Value::Scalar(7.0); + f.layer_mut(f.shape).props.sub_mut("effects").unwrap().children.push(slider.into()); + f +} + +fn yes(f: &Fx, cond: &str) -> f64 { + f.num(&format!("({cond}) ? 1 : 0"), 0.0) +} + +#[test] +fn effects_by_number_on_a_shape_layer_is_four() { + let f = with_shape_slider(); + // The Ray Dynamic Color lookup: layer(1)(4)(swatch)(1). + assert_close!(f.num("thisComp.layer('Shape')(4)(1)(1)", 0.0), 7.0); + assert_close!(f.num("try { thisComp.layer('Shape')(4)(1)(1) } catch (e) { -1 }", 0.0), 7.0); +} + +#[test] +fn effects_by_number_on_a_solid_is_five() { + let f = fx(); + assert_close!(f.num("thisComp.layer('A')(5)(1)(1)", 0.0), 42.0); + assert_close!(f.num("thisComp.layer('A')(5)('Slider Control')('Slider')", 0.0), 42.0); +} + +#[test] +fn top_level_names_follow_after_effects() { + let f = fx(); + assert_close!(yes(&f, "thisComp.layer('A')(4).name == 'Masks'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('A')(5).name == 'Effects'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('A')(6).name == 'Transform'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('Shape')(2).name == 'Contents'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('Shape')(5).name == 'Transform'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('Title')(2).name == 'Text'"), 1.0); + assert_close!(yes(&f, "thisComp.layer('Title')(4).name == 'Effects'"), 1.0); + assert_close!(f.num("thisComp.layer('A')(6)('Position')[0]", 0.0), 50.0); +} + +#[test] +fn one_is_the_markers() { + let f = fx(); + assert_close!(f.num("thisComp.layer('A')(1).numKeys", 0.0), 2.0); + assert_close!(f.num("thisComp.layer('A')(1).key(2).time", 0.0), 4.0); +} + +#[test] +fn an_unused_slot_is_an_empty_group() { + let f = fx(); + // Layer Styles (7) and Data (12) exist in After Effects before they are used. + assert_close!(yes(&f, "thisComp.layer('A')(7).name == 'Layer Styles'"), 1.0); + assert_close!(f.num("thisComp.layer('A')(12).numProperties", 0.0), 0.0); + assert_close!(f.num("try { thisComp.layer('A')(12)(1); 1 } catch (e) { -1 }", 0.0), -1.0); + // Past the last slot there is nothing. + assert_close!(f.num("try { thisComp.layer('A')(16); 1 } catch (e) { -1 }", 0.0), -1.0); +} + +#[test] +fn property_index_of_a_top_level_group_is_after_effects() { + let f = fx(); + assert_close!(f.num("thisComp.layer('A').effect('Slider Control').propertyGroup(1).propertyIndex", 0.0), 5.0); + assert_close!(f.num("thisComp.layer('A')('Transform').propertyIndex", 0.0), 6.0); + assert_close!(f.num("thisComp.layer('Shape')('Contents').propertyIndex", 0.0), 2.0); +} + +#[test] +fn top_level_groups_by_after_effects_match_name() { + // Karton's expressions: thisLayer("ADBE Effect Parade")("…"). + let f = with_shape_slider(); + assert_close!(f.num("thisComp.layer('A')('ADBE Effect Parade')('Slider Control')('Slider')", 0.0), 42.0); + assert_close!(f.num("thisComp.layer('Shape')('ADBE Effect Parade')(1)(1)", 0.0), 7.0); + assert_close!(f.num("thisComp.layer('A')('ADBE Transform Group')('Position')[0]", 0.0), 50.0); + assert_close!(f.num("thisComp.layer('A')('ADBE Marker').numKeys", 0.0), 2.0); + assert_close!(f.num("thisComp.layer('A')('ADBE Effect Parade').propertyIndex", 0.0), 5.0); +} + +#[test] +fn an_enabled_time_remap_is_number_two() { + // Enabled Time Remap is a property at the top of the tree; After Effects numbers it 2. + let mut f = fx(); + let uid = f.p.next_id; + f.p.next_id += 1; + f.layer_mut(f.a).props.children.insert(0, Property::new(uid, "timeRemap", "Time Remap", Value::Scalar(0.0)).into()); + assert_close!(f.num("thisComp.layer('A')('Time Remap').propertyIndex", 0.0), 2.0); + assert_close!(f.num("thisComp.layer('A')(2).propertyIndex", 0.0), 2.0); +} diff --git a/crates/project/src/ae_order.rs b/crates/project/src/ae_order.rs new file mode 100644 index 000000000..b6dce1d44 --- /dev/null +++ b/crates/project/src/ae_order.rs @@ -0,0 +1,139 @@ +//! After Effects' numeric order of a layer's top-level property groups. +//! +//! In After Effects `thisLayer(4)` and `layer.property(4)` reach the same group on every layer of a +//! kind, whatever the layer holds: Marker is always 1, and the groups a layer has not used yet +//! (Time Remap, Motion Trackers, Layer Styles…) still take their slot. EffectCraft's tree holds only +//! the groups a layer has, without Marker, so its own positions differ and move as groups are +//! added. Rigs that address groups by number (`comp("Palette").layer(1)(4)(n)(1)`) read the wrong +//! group, or none, unless numbers go through this table. +//! +//! The order was observed in After Effects 26.3 by reading `layer.property(i).matchName` from a +//! script and `thisComp.layer(..)(i).name` from an expression on new layers of each kind; both +//! agree. 3D model and primitive layers have no table: their numbers stay EffectCraft's own. + +use crate::{Layer, LayerSource, Node}; + +/// One top-level slot in After Effects' order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AeSlot { + /// The group's match id in EffectCraft's tree, or `""` when EffectCraft has no such group. + pub match_id: &'static str, + /// The display name After Effects gives the slot. + pub name: &'static str, + /// After Effects' match name for the slot. + pub match_name: &'static str, +} + +const fn slot(match_id: &'static str, name: &'static str, match_name: &'static str) -> AeSlot { + AeSlot { match_id, name, match_name } +} + +/// The marker slot: number 1 on every layer. Markers are a field of [`Layer`], not a tree node. +pub const MARKER: AeSlot = slot("marker", "Marker", "ADBE Marker"); + +/// Footage, solid, adjustment, precomp and null layers. +const AV: &[AeSlot] = &[ + MARKER, + slot("timeRemap", "Time Remap", "ADBE Time Remapping"), + slot("motionTrackers", "Motion Trackers", "ADBE MTrackers"), + slot("masks", "Masks", "ADBE Mask Parade"), + slot("effects", "Effects", "ADBE Effect Parade"), + slot("transform", "Transform", "ADBE Transform Group"), + slot("layerStyles", "Layer Styles", "ADBE Layer Styles"), + slot("", "Geometry Options", "ADBE Plane Options Group"), + slot("geometryOptions", "Geometry Options", "ADBE Extrsn Options Group"), + slot("materialOptions", "Material Options", "ADBE Material Options Group"), + slot("audio", "Audio", "ADBE Audio Group"), + slot("", "Data", "ADBE Data Group"), + slot("", "Essential Properties", "ADBE Layer Overrides"), + slot("", "Sets", "ADBE Layer Sets"), + slot("", "Source Options", "ADBE Source Options Group"), +]; + +const TEXT: &[AeSlot] = &[ + MARKER, + slot("text", "Text", "ADBE Text Properties"), + slot("masks", "Masks", "ADBE Mask Parade"), + slot("effects", "Effects", "ADBE Effect Parade"), + slot("transform", "Transform", "ADBE Transform Group"), + slot("layerStyles", "Layer Styles", "ADBE Layer Styles"), + slot("geometryOptions", "Geometry Options", "ADBE Extrsn Options Group"), + slot("materialOptions", "Material Options", "ADBE Material Options Group"), + slot("audio", "Audio", "ADBE Audio Group"), + slot("", "Sets", "ADBE Layer Sets"), +]; + +const SHAPE: &[AeSlot] = &[ + MARKER, + slot("contents", "Contents", "ADBE Root Vectors Group"), + slot("masks", "Masks", "ADBE Mask Parade"), + slot("effects", "Effects", "ADBE Effect Parade"), + slot("transform", "Transform", "ADBE Transform Group"), + slot("layerStyles", "Layer Styles", "ADBE Layer Styles"), + slot("geometryOptions", "Geometry Options", "ADBE Extrsn Options Group"), + slot("materialOptions", "Material Options", "ADBE Material Options Group"), + slot("audio", "Audio", "ADBE Audio Group"), + slot("", "Sets", "ADBE Layer Sets"), +]; + +const CAMERA: &[AeSlot] = + &[MARKER, slot("transform", "Transform", "ADBE Transform Group"), slot("cameraOptions", "Camera Options", "ADBE Camera Options Group")]; + +const LIGHT: &[AeSlot] = &[MARKER, slot("transform", "Transform", "ADBE Transform Group"), slot("lightOptions", "Light Options", "ADBE Light Options Group")]; + +/// After Effects' top-level slots for a layer of this kind, or `None` for 3D model and primitive +/// layers, which keep EffectCraft's own numbers. +pub fn ae_slots(source: &LayerSource) -> Option<&'static [AeSlot]> { + match source { + LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. } | LayerSource::Null => Some(AV), + LayerSource::Text => Some(TEXT), + LayerSource::Shape => Some(SHAPE), + LayerSource::Camera => Some(CAMERA), + LayerSource::Light { .. } => Some(LIGHT), + LayerSource::Model { .. } | LayerSource::Primitive { .. } => None, + } +} + +/// What After Effects' top-level number `index` (1-based) reaches on `layer`. +#[derive(Clone, Copy, Debug)] +pub enum AeChild<'a> { + /// The layer's markers (number 1). + Marker, + /// A group or property the layer has. + Node(&'a Node), + /// A slot After Effects always has and this layer does not (an empty group there). + Absent(&'static AeSlot), +} + +/// The top-level node After Effects' number `index` (1-based) reaches, or `None` when the number is +/// past the last slot or the layer kind has no table ([`ae_slots`]). +pub fn ae_child(layer: &Layer, index: usize) -> Option> { + let s = ae_slots(&layer.source)?.get(index.checked_sub(1)?)?; + if *s == MARKER { + return Some(AeChild::Marker); + } + if s.match_id.is_empty() { + return Some(AeChild::Absent(s)); + } + Some(layer.props.children.iter().find(|n| n.match_id() == s.match_id).map_or(AeChild::Absent(s), AeChild::Node)) +} + +/// After Effects' 1-based number for the top-level node with this match id, when it has one. +pub fn ae_index(layer: &Layer, match_id: &str) -> Option { + if match_id.is_empty() { + return None; + } + ae_slots(&layer.source)?.iter().position(|s| s.match_id == match_id).map(|i| i + 1) +} + +/// How many top-level properties After Effects counts on `layer` (`numProperties`), or `None` for +/// layer kinds without a table. +pub fn ae_count(layer: &Layer) -> Option { + ae_slots(&layer.source).map(<[AeSlot]>::len) +} + +/// After Effects' 1-based number for the top-level slot with this After Effects match name +/// (`ADBE Effect Parade`), when the layer kind has one. +pub fn ae_index_of_match_name(layer: &Layer, match_name: &str) -> Option { + ae_slots(&layer.source)?.iter().position(|s| s.match_name == match_name).map(|i| i + 1) +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 6d0a72976..9f5f399e0 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod ae_order; pub mod build; pub mod essential; mod file_check; diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index e662c1cdc..62f3a8557 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -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), diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 5adab6eec..4b65a5298 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -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 }); }, diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 800ccdde7..8995b8202 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -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; diff --git a/crates/script/src/tests/ae_layer_order.rs b/crates/script/src/tests/ae_layer_order.rs new file mode 100644 index 000000000..869ce348a --- /dev/null +++ b/crates/script/src/tests/ae_layer_order.rs @@ -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"])); +}