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 committed 2026-10-30 00:44:58 +01:00
1 parent a8d83b9717
commit 4aaba7c3bf
4 files changed
+214 -3

No files matched your search

+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");
}
}
+1 -1
View File
@@ -429,7 +429,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Import | 97% | 0.5 | JPX / JBIG2 images and predefined non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.12: mesh shadings — free-form / lattice Gouraud triangles, Coons and tensor patches — and function-based shadings, CCITT G3 / G4 images, knockout / isolated transparency groups with group alpha and soft masks on the group's result, `/W2` vertical metrics, EPS text with embedded Type 1 / CFF or bundled fonts, and Create Shapes from Vector Layer keeping images as parented footage layers in paint order; M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier; PDF / AI Composition - Retain Layer Sizes crops each layer to its artwork in place, #1536) |
| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) |
| Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) |
| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates; nested comp markers on the precomp layer bar (Preserve frame rate / resolution when nested and nested comps showing nothing past their end landed in M4.9–M4.10; Pre-compose keeping the comp's settings and New Comp from Selection's dialog in M3.12; CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates; nested comp markers on the precomp layer bar; a dialog for Scale Composition (After Effects' File ▸ Scripts ▸ Scale Composition.jsx), which runs headless as `comp.scale {factor | width | height}` (#2208: the comp, every unparented layer's Position and Scale with all keys, camera Zoom and guides; nested precomps and expressions stay as they are) (Preserve frame rate / resolution when nested and nested comps showing nothing past their end landed in M4.9–M4.10; Pre-compose keeping the comp's settings and New Comp from Selection's dialog in M3.12; CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) |
| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: the expression pick whip scrolls the Timeline when held at its top or bottom, #362; dropped on one of a property's values it picks that dimension (`position[0]`), #363; every property has a property pick whip in the Parent & Link column, which adds the reference as its expression or replaces the one it has, and pick whips drop on Effect Controls properties too, #363; the inline expression field grows while typing and its bottom edge drags it taller or shorter, #371; an angle's revolutions scrub and take typing in Effect Controls, the Timeline and Properties, #93; puppet pin recording with Record Options landed in M13.1; pin selection, rotate/scale handles, nulls for pins and Follow-Through in M13.14–M13.16), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
| Text | ≈ 96% | 0.3 | no extruded strokes (G2: the font menus list every installed family with its own styles — before, only a project asking for a font read the system fonts — and `text.fonts` / MCP `list_fonts` list them for agents; M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) |