Scale Composition: comp.scale resizes a comp and scales its layers, keys, cameras and guides by one factor

Like After Effects' File > Scripts > Scale Composition.jsx: a factor, or a new
width or height that keeps the aspect ratio, in one undo step. Layers whose
parent has a Scale follow that parent; nested precomps and expressions are
left as they are. No dialog yet.

Refs #2208
This commit is contained in:
Marco Arnulfo
2026-10-30 00:44:58 +01:00
parent a8d83b9717
commit 4aaba7c3bf
4 changed files with 214 additions and 3 deletions
+87 -2
View File
@@ -1,5 +1,5 @@
//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File,
//! Copy Frame to Clipboard, Responsive Design — Time.
//! Composition menu, part two: crop to region of interest / layer bounds, Scale Composition, Save
//! Frame As ▸ File, Copy Frame to Clipboard, Responsive Design — Time.
use effectcraft_project::Marker;
use effectcraft_time::Tick;
@@ -71,6 +71,82 @@ fn crop_to_layers(s: &mut Session, p: &Value) -> Result<Value> {
crop(s, cid, [x0, y0, b[2].ceil() - x0, b[3].ceil() - y0], "Crop Comp to Selected Layer(s) Bounds")
}
/// Multiply a property's value, every key, its spatial tangents and its speeds by `f`.
fn scale_prop(pr: &mut effectcraft_project::Property, f: f64) {
use effectcraft_keyframe::Value as KV;
let mul = |v: &KV| match v {
KV::Scalar(x) => KV::Scalar(x * f),
KV::Vec2(a) => KV::Vec2(a.map(|x| x * f)),
KV::Vec3(a) => KV::Vec3(a.map(|x| x * f)),
other => other.clone(),
};
pr.value = mul(&pr.value);
for k in &mut pr.keys {
k.value = mul(&k.value);
k.spatial_in = k.spatial_in.map(|x| x * f);
k.spatial_out = k.spatial_out.map(|x| x * f);
for e in k.in_ease.iter_mut().chain(&mut k.out_ease) {
e.speed *= 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.
fn scale_comp(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "comp.scale";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let (ow, oh) = (f64::from(comp.width), f64::from(comp.height));
let f = match (f_p(p, "factor"), f_p(p, "width"), f_p(p, "height")) {
(Some(f), ..) => f,
(None, Some(w), _) => w / ow,
(None, None, Some(h)) => h / oh,
_ => return Err(bad(C, "pass `factor`, `width` or `height`")),
};
let (w, h) = ((ow * f).round(), (oh * f).round());
let max = f64::from(effectcraft_project::limits::MAX_SIZE);
if !f.is_finite() || f <= 0.0 || !(4.0..=max).contains(&w) || !(4.0..=max).contains(&h) {
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 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();
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);
}
}
}
if let Some(cam) = l.props.sub_mut("cameraOptions") {
for name in ["zoom", "focusDistance", "aperture"] {
if let Some(pr) = cam.get_mut(name) {
scale_prop(pr, f);
}
}
}
}
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);
}
c.width = w;
c.height = h;
Ok(())
})?;
Ok(json!({"width": w, "height": h, "factor": f}))
}
/// Encode straight 8-bit RGBA as PNG.
pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result<Vec<u8>, String> {
use image::ImageEncoder;
@@ -233,6 +309,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi),
cmd!("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", ["Composition"], None, "{layers?}", has_layers, crop_to_layers),
cmd!(
"comp.scale",
"Scale Composition",
[],
None,
"{comp?, factor? (2 = twice the size) | width? | height? (the other side keeps the aspect ratio)}",
has_comp,
scale_comp
),
cmd!(
"comp.saveFrameAs",
"File...",
+1
View File
@@ -38,6 +38,7 @@ mod quick_offset;
mod replace_source;
mod rig3d;
pub(crate) mod roto;
mod scale_comp;
mod sequence;
mod session;
mod settings;
+125
View File
@@ -0,0 +1,125 @@
//! Scale Composition (`comp.scale`, #2208): the comp and everything in it grow or shrink by one
//! factor, so the frame looks the same at the new size.
use effectcraft_project::{Layer, LayerId};
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
fn comp(w: u32, h: u32) -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": w, "height": h, "frameRate": 10, "duration": 2})).unwrap();
s
}
fn solid(s: &mut Session, color: &str, w: u32, h: u32) -> u64 {
s.execute("layer.newSolid", json!({"color": color, "width": w, "height": h})).unwrap()["layer"].as_u64().unwrap()
}
fn layer(s: &Session, id: u64) -> Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
fn v3(s: &Session, id: u64, path: &str, t: f64) -> [f64; 3] {
layer(s, id).props.prop(path).unwrap().value_at(Tick::from_seconds_f64(t)).as_vec3()
}
/// Opaque pixels of `color_channel` (0 = red, 1 = green) at `t`: (x0, y0, x1, y1, count).
fn bounds(s: &Session, t: f64, channel: usize) -> (i64, i64, i64, i64, u32) {
let img = s.render(s.active_comp_id().unwrap(), Tick::from_seconds_f64(t), Default::default());
let (mut x0, mut y0, mut x1, mut y1, mut n) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN, 0);
for y in 0..img.height as i64 {
for x in 0..img.width as i64 {
let p = img.get(x, y);
if p[3] > 0.5 && p[channel] > 0.5 {
n += 1;
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
}
(x0, y0, x1, y1, n)
}
/// Twice as large on every side, from a pixel-exact frame.
fn doubled(b: (i64, i64, i64, i64, u32)) -> (i64, i64, i64, i64, u32) {
(b.0 * 2, b.1 * 2, b.2 * 2 + 1, b.3 * 2 + 1, b.4 * 4)
}
#[test]
fn scale_comp_doubles_the_frame_with_keys_and_parented_layers() {
let mut s = comp(200, 100);
// A red solid moving right (two Position keys), and a green solid parented to a null.
let red = solid(&mut s, "#ff0000", 20, 10);
s.execute("prop.addKey", json!({"layer": red, "path": "transform/position", "time": 0, "value": [30, 20]})).unwrap();
s.execute("prop.addKey", json!({"layer": red, "path": "transform/position", "time": 1, "value": [70, 20]})).unwrap();
let green = solid(&mut s, "#00ff00", 10, 10);
let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": rig, "path": "transform/position", "value": [150, 60]})).unwrap();
s.execute("layer.setParent", json!({"layers": [green], "parent": rig})).unwrap();
s.execute("prop.set", json!({"layer": green, "path": "transform/position", "value": [10, 15]})).unwrap();
let before = s.project.clone();
let (red0, red1, green0) = (bounds(&s, 0.0, 0), bounds(&s, 1.0, 0), bounds(&s, 0.0, 1));
assert_eq!(red0, (20, 15, 39, 24, 200));
let r = s.execute("comp.scale", json!({"factor": 2})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(400), Some(200)));
let c = s.active_comp().unwrap();
assert_eq!((c.width, c.height), (400, 200));
// Every key moves, not only the value at the current time.
assert_eq!(bounds(&s, 0.0, 0), doubled(red0));
assert_eq!(bounds(&s, 1.0, 0), doubled(red1));
assert_eq!(v3(&s, red, "transform/scale", 1.0), [200.0; 3]);
// The child keeps its own transform: its scaled parent carries it.
assert_eq!(bounds(&s, 0.0, 1), doubled(green0));
assert_eq!(v3(&s, green, "transform/position", 0.0)[..2], [10.0, 15.0]);
assert_eq!(v3(&s, green, "transform/scale", 0.0), [100.0; 3]);
// One undo step.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project == before, "undo restores the comp and every layer");
}
#[test]
fn scale_comp_to_a_width_keeps_the_aspect_and_scales_cameras_3d_and_guides() {
let mut s = comp(640, 360);
let card = solid(&mut s, "#ff0000", 100, 100);
s.execute("layer.setSwitch", json!({"layers": [card], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [100, 50, 200]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("view.addGuide", json!({"orientation": "vertical", "position": 160})).unwrap();
s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 25, "percent": true})).unwrap();
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);
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)));
assert_eq!(v3(&s, card, "transform/position", 0.0), [150.0, 75.0, 300.0]);
assert_eq!(v3(&s, card, "transform/scale", 0.0), [150.0; 3]);
assert!((zoom(&s) - zoom0 * 1.5).abs() < 1e-9, "the camera sees the same frame");
assert_eq!(v3(&s, cam, "transform/position", 0.0), cam0.map(|v| v * 1.5));
assert_eq!(v3(&s, cam, "transform/poi", 0.0), poi0.map(|v| v * 1.5));
// Through the camera the card covers the same part of the frame.
let seen = bounds(&s, 0.0, 0);
let near = |a: i64, b: i64| (a as f64 - b as f64 * 1.5).abs() <= 2.0;
assert!(near(seen.0, seen0.0) && near(seen.1, seen0.1) && near(seen.2, seen0.2) && near(seen.3, seen0.3), "{seen0:?} → {seen:?}");
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");
// 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_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");
assert!(s.project == before, "{p} leaves the project alone");
}
}