From 38ed20f569b11ebb446f9eb24480477e9c2feb12 Mon Sep 17 00:00:00 2001 From: marshiyar <57055687+marshiyar@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:30:34 -0700 Subject: [PATCH] feat(layer): layer.addItem takes in, out and anchor (#2026) --- crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/cmd/layer/create.rs | 60 +++++++++++++++ crates/engine/src/tests.rs | 1 + crates/engine/src/tests/add_item_options.rs | 84 +++++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/tests/add_item_options.rs diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 97fb0f160..4b6527f67 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -79,7 +79,7 @@ pub fn specs() -> Vec { "Add Footage to Comp", ["File"], Some("Cmd+/"), - "{item: id|name, time? (s, the In point), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still)}", + "{item: id|name, time? (s, where the layer starts), index? (1-based stack position; default above the selected layer), position? ([x, y] comp px; default the centre), duration? (s, for a still), in? (s, the In point in comp time; default the start), out? (s, the Out point; default the end of the footage or the comp; in must be before out), anchor? ([x, y] layer px; default the centre)}", has_comp, add_item ), diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index ccc4810a9..64b850558 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -258,6 +258,47 @@ pub(super) fn new_null(s: &mut Session, p: &Value) -> Result { new_simple(s, p, LayerSource::Null, "Null 1", "New Null Object") } +/// Largest `in` / `out` (s) and `anchor` coordinate (px) a command accepts. +const MAX_PLACEMENT: f64 = 1.0e6; + +/// An `in` / `out` parameter: comp time in seconds, finite and within `0..=MAX_PLACEMENT`. +fn time_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|t| t.is_finite() && (0.0..=MAX_PLACEMENT).contains(t)) + .map(|t| Some(Tick::from_seconds_f64(t))) + .ok_or_else(|| bad(cmd, format!("{key}: seconds in the composition, 0 to {MAX_PLACEMENT}"))), + } +} + +/// An `anchor` parameter: `[x, y]` in layer pixels (finite, within `MAX_PLACEMENT`). +fn anchor_param(p: &Value, cmd: &str) -> Result> { + match p.get("anchor") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_array() + .and_then(|a| Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])) + .filter(|c| c.iter().all(|x| x.is_finite() && x.abs() <= MAX_PLACEMENT)) + .map(Some) + .ok_or_else(|| bad(cmd, "anchor: [x, y] in layer pixels")), + } +} + +/// Put a new layer's Anchor Point at `pos` (layer pixels), keeping its z. +fn set_anchor(l: &mut Layer, pos: Option<[f64; 2]>) { + if let Some([x, y]) = pos + && let Some(pr) = l.props.prop_mut("transform/anchor") + { + let z = match pr.value { + KV::Vec3(v) => v[2], + _ => 0.0, + }; + pr.value = KV::Vec3([x, y, z]); + } +} + pub(super) fn add_item(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; let item = match p.get("item") { @@ -270,6 +311,13 @@ pub(super) fn add_item(s: &mut Session, p: &Value) -> Result { } let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); let it = s.project.item(item).ok_or_else(|| bad("layer.addItem", "no such item"))?.clone(); + let (in_p, out_p) = (time_param(p, "in", "layer.addItem")?, time_param(p, "out", "layer.addItem")?); + if let (Some(i), Some(o)) = (in_p, out_p) + && i >= o + { + return Err(bad("layer.addItem", "in must be before out")); + } + let anchor = anchor_param(p, "layer.addItem")?; let (src, size, dur) = match &it.kind { ItemKind::Comp(c) => (LayerSource::Comp { item }, (c.width, c.height), Some(c.duration)), ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Still => { @@ -285,6 +333,9 @@ pub(super) fn add_item(s: &mut Session, p: &Value) -> Result { return Err(bad("layer.addItem", "data files can't be layers; read them in expressions with footage(\"name\").sourceData")); } ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => { + if in_p.is_some() || out_p.is_some() || anchor.is_some() { + return Err(bad("layer.addItem", "in, out and anchor aren't supported for 3D models")); + } return crate::cmd::model3d::new_model( s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time"), "index": p.get("index"), "position": p.get("position")}), @@ -308,7 +359,16 @@ pub(super) fn add_item(s: &mut Session, p: &Value) -> Result { // without a duration (solids, comp-length stills) end with the comp. let end = dur.map_or((start + comp.duration).min(comp.duration), |d| start + d); l.out_point = fr.snap_nearest(end).max(start + fr.frame_duration()); + // `in` / `out` are comp times like `layer.timing`'s; each keeps at least one frame. + let fd = fr.frame_duration(); + if let Some(o) = out_p { + l.out_point = fr.snap_nearest(o).max(l.in_point + fd); + } + if let Some(i) = in_p { + l.in_point = fr.snap_nearest(i).min(l.out_point - fd); + } place(&mut l, position); + set_anchor(&mut l, anchor); insert_layer_at(proj, st, cid, l, index) })?; Ok(json!({"layer": id.0})) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e6ab33bd6..013d2f24c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,3 +1,4 @@ +mod add_item_options; mod anim_presets; mod anim_tools; mod blender; diff --git a/crates/engine/src/tests/add_item_options.rs b/crates/engine/src/tests/add_item_options.rs new file mode 100644 index 000000000..76e27c65c --- /dev/null +++ b/crates/engine/src/tests/add_item_options.rs @@ -0,0 +1,84 @@ +//! `layer.addItem {in, out, anchor}` (#2026): place a layer's In / Out points and Anchor Point in +//! the same call, so one command does what addItem + layer.timing + prop.set did. + +use effectcraft_project::{Layer, LayerId, LayerSource}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::Session; + +/// A 10 s, 30 fps comp with a 100 x 50 solid item; returns the session, the item id and the solid's first layer. +fn setup() -> (Session, u64, u64) { + let mut s = Session::default(); + s.execute_checked("comp.new", json!({"width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + let l = s.execute_checked("layer.newSolid", json!({"name": "S", "color": "#fff", "width": 100, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + let item = match &s.active_comp().unwrap().layer(LayerId(l)).unwrap().source { + LayerSource::Solid { item } => item.0, + other => panic!("not a solid: {other:?}"), + }; + (s, item, l) +} + +fn layer(s: &Session, id: u64) -> &Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap() +} + +fn anchor(s: &Session, id: u64) -> [f64; 3] { + layer(s, id).props.prop("transform/anchor").unwrap().value.as_vec3() +} + +fn secs(t: Tick) -> f64 { + t.seconds() +} + +#[test] +fn in_out_and_anchor_in_one_call() { + let (mut s, item, _) = setup(); + let id = s.execute_checked("layer.addItem", json!({"item": item, "time": 1.0, "in": 2.0, "out": 4.0, "anchor": [10.0, 20.0]})).unwrap()["layer"] + .as_u64() + .unwrap(); + let l = layer(&s, id); + assert_eq!((secs(l.start_time), secs(l.in_point), secs(l.out_point)), (1.0, 2.0, 4.0)); + assert_eq!(anchor(&s, id)[..2], [10.0, 20.0]); +} + +#[test] +fn only_in_or_only_out_keeps_the_other_default() { + let (mut s, item, _) = setup(); + let a = s.execute_checked("layer.addItem", json!({"item": item, "in": 3.0})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!((secs(layer(&s, a).in_point), secs(layer(&s, a).out_point)), (3.0, 10.0)); + let b = s.execute_checked("layer.addItem", json!({"item": item, "out": 5.0})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!((secs(layer(&s, b).in_point), secs(layer(&s, b).out_point)), (0.0, 5.0)); +} + +#[test] +fn omitted_params_keep_the_default_placement() { + let (mut s, item, first) = setup(); + let before = anchor(&s, first); + let id = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + let l = layer(&s, id); + assert_eq!((secs(l.in_point), secs(l.out_point)), (0.0, 10.0)); + assert_eq!(anchor(&s, id), before); +} + +#[test] +fn invalid_values_are_errors_and_change_nothing() { + let (mut s, item, _) = setup(); + let n = s.active_comp().unwrap().layers.len(); + let bad: [Value; 8] = [ + json!({"in": 5.0, "out": 5.0}), + json!({"in": 6.0, "out": 2.0}), + json!({"in": -1.0}), + json!({"in": "soon"}), + json!({"out": 1e300}), + json!({"anchor": [1.0]}), + json!({"anchor": [1.0, "x"]}), + json!({"anchor": [1e300, 0.0]}), + ]; + for extra in bad { + let mut p = json!({"item": item}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + assert!(s.execute("layer.addItem", p.clone()).is_err(), "{p}"); + } + assert_eq!(s.active_comp().unwrap().layers.len(), n); +}