From 9f53d9c6bc19610be049a659c4850c53817da38b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 21:47:15 +1100 Subject: [PATCH] Move separated Position when the composition frame is resized or cropped. Those edits offset the combined Position property, which a separated layer does not render. X and Y Position, including their keys, now move by the same amount. --- crates/engine/src/commands/comp.rs | 15 +--- crates/engine/src/commands/comp_more.rs | 11 +-- crates/engine/src/commands/layer.rs | 31 ++++++++ crates/engine/src/tests_menu_cmds.rs | 95 +++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index a3267fa16..769ed3627 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -36,19 +36,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; if dx != 0.0 || dy != 0.0 { for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { - let Some(pr) = l.props.prop_mut("transform/position") else { continue }; - let shift = |v: &mut effectcraft_keyframe::Value| { - if let effectcraft_keyframe::Value::Vec2(x) = v { - x[0] += dx; - x[1] += dy; - } else if let effectcraft_keyframe::Value::Vec3(x) = v { - x[0] += dx; - x[1] += dy; - } - }; - shift(&mut pr.value); - for k in &mut pr.keys { - shift(&mut k.value); + if let Some(tr) = l.transform_mut() { + super::layer::offset_position(tr, dx, dy); } } } diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 0f07fd67c..2c4ca409b 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -1,7 +1,6 @@ //! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File, //! Responsive Design — Time. -use effectcraft_keyframe::Value as KV; use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -24,14 +23,8 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: & c.width = w; c.height = h; for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { - let Some(pos) = l.transform_mut().and_then(|tr| tr.get_mut("position")) else { continue }; - let mv = |v: &KV| { - let a = v.as_vec3(); - KV::Vec3([a[0] - x, a[1] - y, a[2]]) - }; - pos.value = mv(&pos.value); - for k in &mut pos.keys { - k.value = mv(&k.value); + if let Some(tr) = l.transform_mut() { + super::layer::offset_position(tr, -x, -y); } } for g in &mut c.guides { diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index eaf70cf75..0c8472c8a 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1124,6 +1124,37 @@ fn set_text(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well +/// as the combined property the channels were split from. +pub(crate) fn offset_position(tr: &mut PropGroup, dx: f64, dy: f64) { + if dx == 0.0 && dy == 0.0 { + return; + } + if tr.get("positionX").is_some() { + for (name, d) in [("positionX", dx), ("positionY", dy)] { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar(pr.value.as_f64() + d); + for k in &mut pr.keys { + k.value = KV::Scalar(k.value.as_f64() + d); + } + } + } + } + if let Some(pr) = tr.get_mut("position") { + let shift = |v: &KV| match v { + KV::Vec2(a) => KV::Vec2([a[0] + dx, a[1] + dy]), + other => { + let a = other.as_vec3(); + KV::Vec3([a[0] + dx, a[1] + dy, a[2]]) + } + }; + pr.value = shift(&pr.value); + for k in &mut pr.keys { + k.value = shift(&k.value); + } + } +} + /// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions /// are separated. pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) { diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c342f72ff..01886936a 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -3,6 +3,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling}; +use effectcraft_time::Tick; use serde_json::{Value, json}; use crate::{EngineError, Event, Session}; @@ -103,6 +104,100 @@ fn transform_dialog_values_and_center_anchor() { assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath); } +/// Inclusive bounds of samples whose alpha is above one half, plus the frame size and the count. +fn opaque_bounds(s: &Session, t: Tick) -> (u32, u32, i64, i64, i64, i64, u32) { + let img = s.render(s.active_comp_id().unwrap(), t, Default::default()); + let mut n = 0u32; + let mut x0 = i64::MAX; + let mut y0 = i64::MAX; + let mut x1 = i64::MIN; + let mut y1 = i64::MIN; + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + if img.get(x, y)[3] > 0.5 { + n += 1; + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + } + (img.width, img.height, x0, y0, x1, y1, n) +} + +#[test] +fn separated_position_follows_canvas_resize_and_crop() { + let square = |separated: bool| { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Test", "width": 100, "height": 100, "frameRate": 10, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": a, "prop": "position", "value": [40, 50]})).unwrap(); + if separated { + s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap(); + } + (s, a) + }; + let placed = |s: &Session, a: u64, t: f64, x: f64, y: f64| { + let l = layer(s, a); + let tr = l.transform().unwrap(); + let at = Tick::from_seconds_f64(t); + if tr.get("positionX").is_some() { + assert!((tr.get("positionX").unwrap().value_at(at).as_f64() - x).abs() < 1e-6); + assert!((tr.get("positionY").unwrap().value_at(at).as_f64() - y).abs() < 1e-6); + } + let p = tr.get("position").unwrap().value_at(at).as_vec3(); + assert!((p[0] - x).abs() < 1e-6 && (p[1] - y).abs() < 1e-6, "combined {p:?}"); + }; + + let (mut combined, a) = square(false); + let (mut separated, b) = square(true); + assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO)); + combined.execute("comp.settings", json!({"width": 200})).unwrap(); + separated.execute("comp.settings", json!({"width": 200})).unwrap(); + placed(&separated, b, 0.0, 90.0, 50.0); + placed(&combined, a, 0.0, 90.0, 50.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (200, 100, 85, 45, 94, 54, 100)); + assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO)); + + let (mut separated, b) = square(true); + separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap(); + separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + placed(&separated, b, 0.0, 30.0, 30.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); + + let (mut separated, b) = square(true); + separated.execute("layer.select", json!({"layers": [b]})).unwrap(); + separated.execute("comp.cropToLayerBounds", json!({})).unwrap(); + let bounds = opaque_bounds(&separated, Tick::ZERO); + assert_eq!((bounds.0, bounds.1, bounds.6), (10, 10, 100)); + assert!(bounds.2 == 0 && bounds.3 == 0, "the square fills the cropped frame: {bounds:?}"); + + // Two Position keys keep their spacing after the same canvas offset. + let (mut separated, b) = square(true); + separated.execute("edit.undo", json!({})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap(); + separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + separated.execute("comp.settings", json!({"width": 200})).unwrap(); + placed(&separated, b, 0.0, 90.0, 50.0); + placed(&separated, b, 1.0, 110.0, 50.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO).2, 85); + assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)).2, 105); + + let (mut separated, b) = square(true); + separated.execute("edit.undo", json!({})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap(); + separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap(); + separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + placed(&separated, b, 0.0, 30.0, 30.0); + placed(&separated, b, 1.0, 50.0, 30.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); + assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)), (70, 70, 45, 25, 54, 34, 100)); +} + #[test] fn mask_menu_on_existing_masks() { let mut s = comp();