diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index b2c6d9339..a51d9636c 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -91,11 +91,28 @@ fn scale_prop(pr: &mut effectcraft_project::Property, f: f64) { } } +/// Multiply only the Z of a 3D value (and of its keys and spatial tangents) by `f`. +fn scale_prop_z(pr: &mut effectcraft_project::Property, f: f64) { + use effectcraft_keyframe::Value as KV; + let mul = |v: &mut KV| { + if let KV::Vec3(a) = v { + a[2] *= f; + } + }; + mul(&mut pr.value); + for k in &mut pr.keys { + mul(&mut k.value); + k.spatial_in[2] *= f; + k.spatial_out[2] *= f; + } +} + /// Scale Composition (After Effects ships it as File ▸ Scripts ▸ Scale Composition.jsx): the comp /// and its contents grow or shrink by one factor, keys included, in one undo step. Layers whose /// parent has a Scale follow that parent; everything else scales its Position (and Point of /// Interest) and Scale. Anchor Point, masks, shapes and effects live in layer space and follow the -/// layer's Scale. Cameras scale their Zoom so 3D layers keep their place in the frame. +/// layer's Scale. Cameras scale their Zoom so 3D layers keep their place in the frame; a 2D parent +/// carries no depth, so its 3D children scale their Z themselves. fn scale_comp(s: &mut Session, p: &Value) -> Result { const C: &str = "comp.scale"; let cid = comp_id(s, p)?; @@ -113,19 +130,36 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("the composition would be {w}×{h}: width and height run from 4 to 30,000 px"))); } let (w, h) = (w as u32, h as u32); - s.edit("Scale Composition", None, |proj, _| { + let active = s.active_comp_id() == Some(cid); + s.edit("Scale Composition", None, |proj, st| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - // A parent's Scale carries its children; a camera or light parent has none. - let scaling_parents: std::collections::HashSet<_> = - c.layers.iter().filter(|l| l.transform().is_some_and(|tr| tr.get("scale").is_some())).map(|l| l.id).collect(); + // A parent's Scale carries its children (X and Y only when the parent is 2D); a camera or + // light parent has none. + let scaling_parents: std::collections::HashMap<_, _> = + c.layers.iter().filter(|l| l.transform().is_some_and(|tr| tr.get("scale").is_some())).map(|l| (l.id, l.is_3d())).collect(); for l in &mut c.layers { - let follows_parent = l.parent.is_some_and(|id| scaling_parents.contains(&id)); - if !follows_parent && let Some(tr) = l.transform_mut() { - for name in ["position", "positionX", "positionY", "positionZ", "poi", "scale"] { - if let Some(pr) = tr.get_mut(name) { - scale_prop(pr, f); + let parent_3d = l.parent.and_then(|id| scaling_parents.get(&id).copied()); + let flat_parent = parent_3d == Some(false) && l.is_3d(); + let Some(tr) = l.transform_mut() else { continue }; + match parent_3d { + None => { + for name in ["position", "positionX", "positionY", "positionZ", "poi", "scale"] { + if let Some(pr) = tr.get_mut(name) { + scale_prop(pr, f); + } } } + Some(_) if flat_parent => { + if let Some(pr) = tr.get_mut("positionZ") { + scale_prop(pr, f); + } + for name in ["position", "scale"] { + if let Some(pr) = tr.get_mut(name) { + scale_prop_z(pr, f); + } + } + } + Some(_) => {} } if let Some(cam) = l.props.sub_mut("cameraOptions") { for name in ["zoom", "focusDistance", "aperture"] { @@ -135,13 +169,15 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result { } } } - let (old_w, old_h) = (c.width, c.height); - for g in &mut c.guides { - let px = g.px(old_w, old_h) * f; - g.set_px(px, w, h); + // Percentage guides keep their percent. + for g in c.guides.iter_mut().filter(|g| !g.percent) { + g.position *= f; } c.width = w; c.height = h; + if active && let Some(r) = &mut st.region_of_interest { + *r = r.map(|v| v * f); + } Ok(()) })?; Ok(json!({"width": w, "height": h, "factor": f})) diff --git a/crates/engine/src/tests/scale_comp.rs b/crates/engine/src/tests/scale_comp.rs index 6983f8c11..252366bee 100644 --- a/crates/engine/src/tests/scale_comp.rs +++ b/crates/engine/src/tests/scale_comp.rs @@ -5,7 +5,7 @@ use effectcraft_project::{Layer, LayerId}; use effectcraft_time::Tick; use serde_json::json; -use crate::Session; +use crate::{EngineError, Session}; fn comp(w: u32, h: u32) -> Session { let mut s = Session::default(); @@ -92,6 +92,7 @@ fn scale_comp_to_a_width_keeps_the_aspect_and_scales_cameras_3d_and_guides() { let zoom = |s: &Session| layer(s, cam).props.prop("cameraOptions/zoom").unwrap().value.as_f64(); let (zoom0, cam0, poi0) = (zoom(&s), v3(&s, cam, "transform/position", 0.0), v3(&s, cam, "transform/poi", 0.0)); let seen0 = bounds(&s, 0.0, 0); + s.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap(); let r = s.execute("comp.scale", json!({"width": 960})).unwrap(); assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["factor"].as_f64()), (Some(960), Some(540), Some(1.5))); @@ -107,19 +108,64 @@ fn scale_comp_to_a_width_keeps_the_aspect_and_scales_cameras_3d_and_guides() { let c = s.active_comp().unwrap(); assert_eq!(c.guides[0].px(c.width, c.height), 240.0); assert_eq!(c.guides[1].position, 25.0, "a percentage guide keeps its percent"); + assert_eq!(s.state.region_of_interest, Some([15.0, 30.0, 105.0, 105.0]), "the region of interest covers the same part"); // A height alone works the same way. let r = s.execute("comp.scale", json!({"height": 270})).unwrap(); assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(480), Some(270))); } +#[test] +fn scale_comp_scales_depth_under_a_2d_parent_and_children_of_a_camera() { + let mut s = comp(200, 100); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + // A 3D card under a 2D null: the null's Scale carries X and Y but not Z. + let card = solid(&mut s, "#ff0000", 20, 10); + let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": rig, "path": "transform/position", "value": [100, 50]})).unwrap(); + s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setParent", json!({"layers": [card], "parent": rig})).unwrap(); + s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap(); + // A 3D card parented to the camera, in front of it. + let held = solid(&mut s, "#00ff00", 10, 10); + s.execute("layer.setSwitch", json!({"layers": [held], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setParent", json!({"layers": [held], "parent": cam})).unwrap(); + s.execute("prop.set", json!({"layer": held, "path": "transform/position", "value": [30, 0, 500]})).unwrap(); + let (card0, held0) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1)); + let (held_pos0, held_scale0) = (v3(&s, held, "transform/position", 0.0), v3(&s, held, "transform/scale", 0.0)); + assert!(card0.4 > 0 && held0.4 > 0, "both cards are in view: {card0:?} {held0:?}"); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + let near = |a: (i64, i64, i64, i64, u32), b: (i64, i64, i64, i64, u32)| { + [(a.0, b.0 * 2), (a.1, b.1 * 2), (a.2, b.2 * 2 + 1), (a.3, b.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2) + }; + let (card1, held1) = (bounds(&s, 0.0, 0), bounds(&s, 0.0, 1)); + assert!(near(card1, card0), "the card under the 2D null doubles: {card0:?} → {card1:?}"); + assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 1000.0]); + assert!(near(held1, held0), "the card held by the camera doubles: {held0:?} → {held1:?}"); + assert_eq!(v3(&s, held, "transform/position", 0.0), held_pos0.map(|v| v * 2.0)); + assert_eq!(v3(&s, held, "transform/scale", 0.0), held_scale0.map(|v| v * 2.0)); +} + #[test] fn scale_comp_refuses_a_bad_factor_and_changes_nothing() { let mut s = comp(640, 360); solid(&mut s, "#ff0000", 100, 100); let before = s.project.clone(); - for p in [json!({}), json!({"factor": 0}), json!({"factor": -2}), json!({"width": 0}), json!({"factor": 100}), json!({"factor": 0.001})] { - assert!(s.execute("comp.scale", p.clone()).is_err(), "{p} is refused"); + let cases = [ + (json!({}), "pass `factor`"), + (json!({"factor": 0}), "4 to 30,000 px"), + (json!({"factor": -2}), "4 to 30,000 px"), + (json!({"width": 0}), "4 to 30,000 px"), + (json!({"factor": 100}), "4 to 30,000 px"), + (json!({"factor": 0.001}), "4 to 30,000 px"), + ]; + for (p, why) in cases { + match s.execute("comp.scale", p.clone()) { + Err(EngineError::BadParams { cmd, msg }) => assert!(cmd == "comp.scale" && msg.contains(why), "{p}: {msg}"), + other => panic!("{p} is refused as bad parameters, got {other:?}"), + } assert!(s.project == before, "{p} leaves the project alone"); } + s.execute("comp.scale", json!({"factor": 1})).unwrap(); }