From 4d105f02d4700bdbaf824d048adc70db9469068d Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 19:19:11 +0200 Subject: [PATCH] #809: layer.source and a Layer Control's layer work in expressions layer.source was undefined in expressions, and a layer from a Layer Control had no transform. Both now work. Co-Authored-By: Claude Haiku 5.5 --- crates/expr/src/host.rs | 26 ++++++++++++++++++++++++++ crates/expr/src/prelude.js | 20 ++++++++++++++++++-- crates/expr/src/runtime.rs | 1 + crates/expr/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index 70093790..87d2d8be 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -63,6 +63,10 @@ pub enum Req { Sample { comp: u64, layer: u64, x: Secs, y: Secs, rx: Secs, ry: Secs, post: bool, t: Secs }, /// `thisProject`: `[bitsPerChannel, linearBlending, workingSpace]`. Project, + /// A layer's source item (AE's `Layer.source`): `[kind, id, name, width, height, duration, + /// frameDuration, pixelAspect]`, kind `"comp"` (id = the precomp's composition) or `"footage"` + /// (footage, solids and models), or null for layers without an item (text, shape, null…). + SourceInfo { comp: u64, layer: u64 }, } /// Parse CSV/TSV text (RFC 4180 quoting) into rows of cells. @@ -457,6 +461,28 @@ impl<'a> Resolver<'a> { Resp::Bool(project.settings.blend_linear || project.settings.linearize), Resp::Str(project.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default()), ]), + Req::SourceInfo { comp, layer } => { + let c = self.comp(*comp)?; + let l = self.layer(*comp, *layer)?; + let it = project.item(l.source.item()?)?; + let (kind, aspect, frame, duration) = match &it.kind { + ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()), + ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()), + ItemKind::Solid(s) => ("footage", s.pixel_aspect, c.frame_duration().seconds(), c.duration.seconds()), + ItemKind::Folder => return None, + }; + let (w, h) = it.dimensions().unwrap_or((0, 0)); + Resp::List(vec![ + Resp::Str(kind.into()), + Resp::Num(it.id.0 as f64), + Resp::Str(it.name.clone()), + Resp::Num(w as f64), + Resp::Num(h as f64), + Resp::Num(duration), + Resp::Num(frame), + Resp::Num(aspect), + ]) + } Req::Sample { comp, layer, x, y, rx, ry, post, t } => { let l = self.layer(*comp, *layer)?; let f = |b: &Secs| f64::from_bits(*b); diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 5756365a..5f6dd015 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -18,7 +18,7 @@ var selectorValue = [100, 100, 100]; // Request ops (must match runtime.rs). var __COMP = 0, __COMPNAME = 1, __LAYER = 2, __LINFO = 3, __CHILD = 4, __PINFO = 5, __VALUE = 6, __XFORM = 7, __RECT = 8, __MARKERS = 9, __DOC = 10; -var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14; +var __FOOT = 11, __FDATA = 12, __SAMPLE = 13, __PROJ = 14, __SOURCE = 15; function __begin(t, v, c, l, path, uid, idx, fd, ti, tt, sv) { time = t; @@ -569,6 +569,14 @@ Layer.prototype = { get enabled() { return this.__info[11]; }, get active() { return this.__info[11] && time >= this.inPoint && time < this.outPoint; }, get containingComp() { return new Comp(this.__c); }, + // The source item (AE's Layer.source): a Comp for a precomp layer, a Footage for footage, solid + // and model layers, null for text, shape, null, camera and light layers (#809). + get source() { + var s = __h(__SOURCE, this.__c, this.__l); + if (s === null) return null; + if (s[0] === 'comp') return new Comp(s[1]); + return new Footage([s[2], s[3], s[4], s[5], s[6], s[7], s[0], null, null]); + }, __group: function (k) { var g = __child(this.__c, this.__l, '', k); if (g === undefined) __err('Layer "' + this.name + '" has no property "' + k + '"'); @@ -810,10 +818,18 @@ Prop.prototype = { var __propHandler = { get: function (t, k) { - if (typeof k !== 'string' || k in t) return t[k]; + if (typeof k !== 'string') return t[k]; + // A layer-valued property (a Layer Control's Layer) is named as its layer (#809). + if (k === 'name' && t.__info[2] === 'layer') { + var lv = t.value; + if (lv !== null && lv !== undefined && lv.__isLayer) return lv.name; + } + if (k in t) return t[k]; var v = t.value; if (v === null || v === undefined) return undefined; var x = v[k]; + // Property groups are callable proxies: hand them over as they are, not bound like methods (#809). + if (x !== null && x !== undefined && x.__isGroup) return x; return typeof x === 'function' ? x.bind(v) : x; }, }; diff --git a/crates/expr/src/runtime.rs b/crates/expr/src/runtime.rs index 142658b1..9ec8172a 100644 --- a/crates/expr/src/runtime.rs +++ b/crates/expr/src/runtime.rs @@ -185,6 +185,7 @@ fn native_host(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Req::Project, + 15 => Req::SourceInfo { comp: id(args, 1, ctx)?, layer: id(args, 2, ctx)? }, _ => return Err(JsNativeError::typ().with_message("unknown host request").into()), }; let found = FRAMES.with(|f| { diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 93a25562..d71db390 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1251,3 +1251,37 @@ fn pending_text_style_baseline_setters_are_visible_to_expression_getters() { assert_eq!(x.num("thisComp.layer('Title').text.sourceText.style.setBaselineOption('superscript').isSuperscript ? 1 : 0", 0.0), 1.0); assert_eq!(x.text("var s = text.sourceText.style.setSuperscript(true); s.setText(s.isSuperscript ? 'super' : 'normal')", 0.0), "super"); } + +#[test] +fn layer_source_is_the_layers_source_item() { + let mut f = fx(); + // A solid's source is an item of the solid's size and name (#809). + assert_close!(f.num("thisComp.layer('A').source.width", 0.0), 20.0); + assert_close!(f.num("thisComp.layer('A').source.height", 0.0), 20.0); + assert_close!(f.num("thisComp.layer('A').source.name == 'A' ? 1 : 0", 0.0), 1.0); + // Text and shape layers have no source item. + assert_close!(f.num("thisComp.layer('Title').source == null ? 1 : 0", 0.0), 1.0); + // A precomp layer's source is its composition. + let main = f.p.comp(f.cid).unwrap().clone(); + let other = f.p.items.values().find(|i| i.name == "Other").unwrap().id; + let pre = build::layer(&mut f.p, &main, "Pre", LayerSource::Comp { item: other }, (64, 32), None); + f.p.comp_mut(f.cid).unwrap().layers.push(pre); + assert_close!(f.num("thisComp.layer('Pre').source.width", 0.0), 64.0); + assert_close!(f.num("thisComp.layer('Pre').source.numLayers", 0.0), 1.0); +} + +#[test] +fn layer_control_property_stands_for_its_layer() { + let mut f = fx(); + // Layer A gets a Layer Control ("Map") that points at layer B (#809). + let spec = effectcraft_effects::find("ec.control.layer").unwrap(); + let mut map = group_push(&mut f.p, |ids| effectcraft_effects::instantiate(spec, ids, "Map", [20.0, 20.0])); + map.prop_mut("layer").unwrap().value = Value::Layer(Some(f.b.0)); + f.p.comp_mut(f.cid).unwrap().layers[0].props.sub_mut("effects").unwrap().children.push(map.into()); + // effect("Map")(1) is the layer itself: its name, index, transform and value. + assert_close!(f.num("effect('Map')(1).name == 'B' ? 1 : 0", 0.0), 1.0); + assert_close!(f.num("effect('Map')(1).index", 0.0), 2.0); + assert_close!(f.num("effect('Map')(1).transform.position[0]", 0.0), 100.0); + assert_close!(f.num("effect('Map')(1).transform.scale[0]", 0.0), 100.0); + assert_close!(f.num("effect('Map')(1).value.name == 'B' ? 1 : 0", 0.0), 1.0); +}