mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 19:22:08 +00:00
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:
+49
-10
@@ -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::<u64>().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<Resp> {
|
||||
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<Resp> {
|
||||
@@ -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()),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }); },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]));
|
||||
}
|
||||
Reference in New Issue
Block a user