Merge remote-tracking branch 'upstream/main' into fix/3087-aep-effect-placeholders

# Conflicts:
#	crates/expr/src/host.rs
This commit is contained in:
arnav-hiwarkar committed 2026-11-12 13:42:21 +05:30
commit b762b4800d
10 files changed
+472 -12

No files matched your search

+49 -10
View File
@@ -9,6 +9,7 @@
use effectcraft_geom::{Mat3, Mat4};
use effectcraft_keyframe::Value;
use effectcraft_project::ae_order::{self, AeChild};
use effectcraft_project::{Comp, GroupKind, 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)),
@@ -351,7 +365,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> {
@@ -406,14 +449,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)?;
@@ -426,7 +462,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()),
+2
View File
@@ -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]);
}
+1
View File
@@ -2046,6 +2046,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
+98
View File
@@ -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);
}