From 4aaba7c3bf2e9ed32980bdc7d68da8e9a5ece3be Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:54:24 +0200 Subject: [PATCH] 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 --- crates/engine/src/cmd/comp_more.rs | 89 +++++++++++++++++- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/scale_comp.rs | 125 ++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/tests/scale_comp.rs diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index 478dc8259..b2c6d9339 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -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 { 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 { + 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, String> { use image::ImageEncoder; @@ -233,6 +309,15 @@ pub fn specs() -> Vec { 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...", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25d05b903..fc9d24a95 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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; diff --git a/crates/engine/src/tests/scale_comp.rs b/crates/engine/src/tests/scale_comp.rs new file mode 100644 index 000000000..6983f8c11 --- /dev/null +++ b/crates/engine/src/tests/scale_comp.rs @@ -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"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 34829d406..9c3693ad5 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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) |