Merge pull request #606 from vbasky/fix/539-separated-canvas

This commit is contained in:
Claude
2026-10-10 16:28:05 +00:00
4 changed files with 129 additions and 22 deletions
+2 -13
View File
@@ -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);
}
}
}
+2 -9
View File
@@ -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 {
+31
View File
@@ -1270,6 +1270,37 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
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]) {
+94
View File
@@ -104,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();