Files
effectcraft/crates/project/src/ae_order.rs
T
Sean Hawkey 24df676af5 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.
2026-11-12 13:40:56 +05:30

140 lines
6.3 KiB
Rust

//! 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<AeChild<'_>> {
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<usize> {
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<usize> {
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<usize> {
ae_slots(&layer.source)?.iter().position(|s| s.match_name == match_name).map(|i| i + 1)
}