From 4aaba7c3bf2e9ed32980bdc7d68da8e9a5ece3be Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:54:24 +0200 Subject: [PATCH 1/7] 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) | From 76369bc10a9ae9d2068d10a60a3c9f76791aaeca Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 22:05:51 +0200 Subject: [PATCH 2/7] Scale Composition: Z of 3D layers under 2D parents, percentage guides left alone, region of interest scaled A 2D parent's Scale carries no depth, so its 3D children scale their own Z. Percentage guides keep their percent exactly instead of drifting by rounding. Tests now check the specific errors and cover a layer parented to a camera. --- crates/engine/src/cmd/comp_more.rs | 64 +++++++++++++++++++++------ crates/engine/src/tests/scale_comp.rs | 52 ++++++++++++++++++++-- 2 files changed, 99 insertions(+), 17 deletions(-) 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(); } From 76e02f88b7c8fd602ac59c4923e77c43f2caeef4 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 22:12:43 +0200 Subject: [PATCH 3/7] Scale Composition: keep the ease of depth moves under 2D parents, and scale depth once through a 3D ancestor Position speeds stretch with the path's direction at each key and Scale's Z speed with the factor, so the ease keeps its shape. A 3D layer under a 2D parent scales its own Z only when no 3D ancestor's Scale already carries it. --- crates/engine/src/cmd/comp_more.rs | 51 ++++++++++++++++++++++++--- crates/engine/src/tests/scale_comp.rs | 48 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index a51d9636c..2783c47be 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -91,19 +91,48 @@ 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`. +/// Multiply only the Z of a 3D value (and of its keys and spatial tangents) by `f`. Speeds follow: +/// a spatial side's speed by how much the path's direction there stretches, so the ease keeps +/// its shape; a per-dimension one (Scale) by `f` for Z only. fn scale_prop_z(pr: &mut effectcraft_project::Property, f: f64) { use effectcraft_keyframe::Value as KV; + let stretch = |v: [f64; 3]| { + let n = v[0].hypot(v[1]).hypot(v[2]); + if n > 1e-12 && n.is_finite() { v[0].hypot(v[1]).hypot(v[2] * f) / n } else { 1.0 } + }; + let pos = |k: &effectcraft_project::Keyframe| k.value.as_vec3(); + let sub = |a: [f64; 3], b: [f64; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + let is_zero = |v: [f64; 3]| v.iter().all(|x| x.abs() <= 1e-12); + // Read every side's direction before anything changes (auto tangents come from the neighbours). + let sides: Vec<(f64, f64)> = (0..pr.keys.len()) + .map(|i| { + let (tin, tout) = effectcraft_keyframe::spatial_tangents(&pr.keys, i); + let here = pr.keys.get(i).map(pos).unwrap_or_default(); + let chord = |j: Option| j.and_then(|j| pr.keys.get(j)).map(|k| sub(pos(k), here)).unwrap_or_default(); + let tin = if is_zero(tin) { chord(i.checked_sub(1)) } else { tin }; + let tout = if is_zero(tout) { chord(Some(i + 1)) } else { tout }; + (stretch(tin), stretch(tout)) + }) + .collect(); + let spatial = pr.spatial; let mul = |v: &mut KV| { if let KV::Vec3(a) = v { a[2] *= f; } }; mul(&mut pr.value); - for k in &mut pr.keys { + for (k, (s_in, s_out)) in pr.keys.iter_mut().zip(sides) { mul(&mut k.value); k.spatial_in[2] *= f; k.spatial_out[2] *= f; + if spatial { + k.in_ease.iter_mut().for_each(|e| e.speed *= s_in); + k.out_ease.iter_mut().for_each(|e| e.speed *= s_out); + } else { + for e in [k.in_ease.get_mut(2), k.out_ease.get_mut(2)].into_iter().flatten() { + e.speed *= f; + } + } } } @@ -136,10 +165,22 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result { // 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(); + c.layers.iter().filter(|l| l.transform().is_some_and(|tr| tr.get("scale").is_some())).map(|l| (l.id, (l.is_3d(), l.parent))).collect(); + // A 3D ancestor's Scale carries depth through any 2D layers between (bounded: parent + // chains in a file can loop). + let depth_carried = |mut cur: Option| { + for _ in 0..64 { + let Some((three_d, up)) = cur.and_then(|id| scaling_parents.get(&id)) else { return false }; + if *three_d { + return true; + } + cur = *up; + } + false + }; for l in &mut c.layers { - let parent_3d = l.parent.and_then(|id| scaling_parents.get(&id).copied()); - let flat_parent = parent_3d == Some(false) && l.is_3d(); + let parent_3d = l.parent.and_then(|id| scaling_parents.get(&id).map(|p| p.0)); + let flat_parent = parent_3d == Some(false) && l.is_3d() && !depth_carried(l.parent); let Some(tr) = l.transform_mut() else { continue }; match parent_3d { None => { diff --git a/crates/engine/src/tests/scale_comp.rs b/crates/engine/src/tests/scale_comp.rs index 252366bee..08ddb544b 100644 --- a/crates/engine/src/tests/scale_comp.rs +++ b/crates/engine/src/tests/scale_comp.rs @@ -147,6 +147,54 @@ fn scale_comp_scales_depth_under_a_2d_parent_and_children_of_a_camera() { assert_eq!(v3(&s, held, "transform/scale", 0.0), held_scale0.map(|v| v * 2.0)); } +#[test] +fn scale_comp_keeps_the_ease_of_depth_moves_under_a_2d_parent() { + let mut s = comp(200, 100); + let card = solid(&mut s, "#ff0000", 20, 10); + let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().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.addKey", json!({"layer": card, "path": "transform/position", "time": 0, "value": [0, 0, 0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": card, "path": "transform/position", "time": 1, "value": [0, 0, 100]})).unwrap(); + s.execute("keys.setEase", json!({"layer": card, "path": "transform/position", "time": 0, "side": "out", "speed": 100, "influence": 50})).unwrap(); + s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 0, "value": [100, 100, 100]})).unwrap(); + s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 1, "value": [100, 100, 300]})).unwrap(); + s.execute("keys.setEase", json!({"layer": card, "path": "transform/scale", "time": 0, "side": "out", "speed": 300, "influence": 50})).unwrap(); + let (pos0, scale0) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25)); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + // Twice the distance in the same time: the same ease, every frame twice as deep. + let (pos, scale) = (v3(&s, card, "transform/position", 0.25), v3(&s, card, "transform/scale", 0.25)); + assert!((pos[2] - pos0[2] * 2.0).abs() < 1e-6, "{pos0:?} → {pos:?}"); + assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}"); + assert_eq!(scale[..2], scale0[..2]); +} + +#[test] +fn scale_comp_scales_depth_once_through_a_3d_grandparent() { + let mut s = comp(200, 100); + s.execute("layer.newCamera", json!({})).unwrap(); + // card (3D) → A (2D null) → B (3D null): B's Scale already carries the depth. + let card = solid(&mut s, "#ff0000", 20, 10); + let a = s.execute("layer.newNull", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newNull", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [card, b], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [100, 50, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [a], "parent": b})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [0, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [card], "parent": a})).unwrap(); + s.execute("prop.set", json!({"layer": card, "path": "transform/position", "value": [-40, 0, 500]})).unwrap(); + let card0 = bounds(&s, 0.0, 0); + assert!(card0.4 > 0, "the card is in view"); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + assert_eq!(v3(&s, card, "transform/position", 0.0), [-40.0, 0.0, 500.0]); + let card1 = bounds(&s, 0.0, 0); + let near = + [(card1.0, card0.0 * 2), (card1.1, card0.1 * 2), (card1.2, card0.2 * 2 + 1), (card1.3, card0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2); + assert!(near, "{card0:?} → {card1:?}"); +} + #[test] fn scale_comp_refuses_a_bad_factor_and_changes_nothing() { let mut s = comp(640, 360); From 1f693acea6b5737f6d1fa5021ef3ca68e5fa7569 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 22:19:43 +0200 Subject: [PATCH 4/7] Scale Composition: walk the parent chain through cameras and lights, and give Z its own Scale speed The renderer applies every ancestor's transform, so a scaled null above a camera already carries the camera's children. A Scale key with one shared ease gets a separate Z ease before its speed is scaled. --- crates/engine/src/cmd/comp_more.rs | 65 ++++++++++++++------------- crates/engine/src/tests/scale_comp.rs | 48 ++++++++++++++++++++ 2 files changed, 81 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index 2783c47be..00f1bb200 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -129,8 +129,14 @@ fn scale_prop_z(pr: &mut effectcraft_project::Property, f: f64) { k.in_ease.iter_mut().for_each(|e| e.speed *= s_in); k.out_ease.iter_mut().for_each(|e| e.speed *= s_out); } else { - for e in [k.in_ease.get_mut(2), k.out_ease.get_mut(2)].into_iter().flatten() { - e.speed *= f; + // A shorter list lends its first ease to Z (`effective_ease`): give Z its own. + for list in [&mut k.in_ease, &mut k.out_ease] { + if let Some(&first) = list.first() { + list.resize(list.len().max(3), first); + } + if let Some(e) = list.get_mut(2) { + e.speed *= f; + } } } } @@ -162,45 +168,40 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result { 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 (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(), l.parent))).collect(); - // A 3D ancestor's Scale carries depth through any 2D layers between (bounded: parent - // chains in a file can loop). - let depth_carried = |mut cur: Option| { + // An ancestor's Scale carries its descendants (X and Y only when that ancestor is 2D), + // through cameras and lights too: the renderer applies every ancestor's transform. + let chain: std::collections::HashMap<_, _> = + c.layers.iter().map(|l| (l.id, (l.transform().is_some_and(|tr| tr.get("scale").is_some()), l.is_3d(), l.parent))).collect(); + // (X and Y carried, Z carried); bounded, as parent chains in a file can loop. + let carried = |mut cur: Option| { + let (mut xy, mut z) = (false, false); for _ in 0..64 { - let Some((three_d, up)) = cur.and_then(|id| scaling_parents.get(&id)) else { return false }; - if *three_d { - return true; - } - cur = *up; + let Some(&(has_scale, three_d, up)) = cur.and_then(|id| chain.get(&id)) else { break }; + xy |= has_scale; + z |= has_scale && three_d; + cur = up; } - false + (xy, z) }; for l in &mut c.layers { - let parent_3d = l.parent.and_then(|id| scaling_parents.get(&id).map(|p| p.0)); - let flat_parent = parent_3d == Some(false) && l.is_3d() && !depth_carried(l.parent); + let (xy_carried, z_carried) = carried(l.parent); + let three_d = 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") { + if !xy_carried { + for name in ["position", "positionX", "positionY", "positionZ", "poi", "scale"] { + if let Some(pr) = tr.get_mut(name) { scale_prop(pr, f); } - for name in ["position", "scale"] { - if let Some(pr) = tr.get_mut(name) { - scale_prop_z(pr, f); - } + } + } else if three_d && !z_carried { + 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"] { diff --git a/crates/engine/src/tests/scale_comp.rs b/crates/engine/src/tests/scale_comp.rs index 08ddb544b..27397e744 100644 --- a/crates/engine/src/tests/scale_comp.rs +++ b/crates/engine/src/tests/scale_comp.rs @@ -170,6 +170,30 @@ fn scale_comp_keeps_the_ease_of_depth_moves_under_a_2d_parent() { assert_eq!(scale[..2], scale0[..2]); } +#[test] +fn scale_comp_gives_z_its_own_speed_when_a_scale_key_has_one_ease() { + let mut s = comp(200, 100); + let card = solid(&mut s, "#ff0000", 20, 10); + let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().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.addKey", json!({"layer": card, "path": "transform/scale", "time": 0, "value": [100, 100, 100]})).unwrap(); + s.execute("prop.addKey", json!({"layer": card, "path": "transform/scale", "time": 1, "value": [100, 100, 300]})).unwrap(); + s.execute("keys.setEase", json!({"layer": card, "path": "transform/scale", "time": 0, "side": "out", "speed": 300, "influence": 50})).unwrap(); + // One ease for every dimension, as a project file may store it. + let cid = s.active_comp_id().unwrap(); + let key = + &mut std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(card)).unwrap().props.prop_mut("transform/scale").unwrap().keys + [0]; + key.out_ease.truncate(1); + let scale0 = v3(&s, card, "transform/scale", 0.25); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + let scale = v3(&s, card, "transform/scale", 0.25); + assert!((scale[2] - scale0[2] * 2.0).abs() < 1e-6, "{scale0:?} → {scale:?}"); + assert_eq!(scale[..2], scale0[..2], "X and Y keep the shared ease"); +} + #[test] fn scale_comp_scales_depth_once_through_a_3d_grandparent() { let mut s = comp(200, 100); @@ -195,6 +219,30 @@ fn scale_comp_scales_depth_once_through_a_3d_grandparent() { assert!(near, "{card0:?} → {card1:?}"); } +#[test] +fn scale_comp_scales_once_through_a_camera_under_a_scaled_null() { + let mut s = comp(200, 100); + // solid → camera → B (3D null): B's Scale reaches the solid through the camera. + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newNull", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [b], "switch": "threeD", "value": true})).unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": b})).unwrap(); + 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 held0 = bounds(&s, 0.0, 1); + assert!(held0.4 > 0, "the solid is in view: {held0:?}"); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + assert_eq!(v3(&s, held, "transform/position", 0.0), [30.0, 0.0, 500.0]); + assert_eq!(v3(&s, held, "transform/scale", 0.0), [100.0; 3]); + let held1 = bounds(&s, 0.0, 1); + let near = + [(held1.0, held0.0 * 2), (held1.1, held0.1 * 2), (held1.2, held0.2 * 2 + 1), (held1.3, held0.3 * 2 + 1)].iter().all(|&(x, y)| (x - y).abs() <= 2); + assert!(near, "{held0:?} → {held1:?}"); +} + #[test] fn scale_comp_refuses_a_bad_factor_and_changes_nothing() { let mut s = comp(640, 360); From fc7882b0eed5c15d78b643c1a972b0081340b329 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 22:23:21 +0200 Subject: [PATCH 5/7] Scale Composition: scale the Point of Interest depth of a camera or light under a 2D parent Otherwise the eye moves back while the point it looks at stays put, and the view direction changes. --- crates/engine/src/cmd/comp_more.rs | 2 +- crates/engine/src/tests/scale_comp.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index 00f1bb200..940c09f8d 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -197,7 +197,7 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result { if let Some(pr) = tr.get_mut("positionZ") { scale_prop(pr, f); } - for name in ["position", "scale"] { + for name in ["position", "poi", "scale"] { if let Some(pr) = tr.get_mut(name) { scale_prop_z(pr, f); } diff --git a/crates/engine/src/tests/scale_comp.rs b/crates/engine/src/tests/scale_comp.rs index 27397e744..28cc1ba88 100644 --- a/crates/engine/src/tests/scale_comp.rs +++ b/crates/engine/src/tests/scale_comp.rs @@ -243,6 +243,21 @@ fn scale_comp_scales_once_through_a_camera_under_a_scaled_null() { assert!(near, "{held0:?} → {held1:?}"); } +#[test] +fn scale_comp_scales_the_point_of_interest_depth_of_a_camera_under_a_2d_null() { + let mut s = comp(200, 100); + let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap(); + let rig = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": rig})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [10, 20, -1000]})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/poi", "value": [10, 20, 200]})).unwrap(); + + s.execute("comp.scale", json!({"factor": 2})).unwrap(); + // The 2D null carries X and Y; the camera's own Z doubles at both ends, so it looks the same way. + assert_eq!(v3(&s, cam, "transform/position", 0.0), [10.0, 20.0, -2000.0]); + assert_eq!(v3(&s, cam, "transform/poi", 0.0), [10.0, 20.0, 400.0]); +} + #[test] fn scale_comp_refuses_a_bad_factor_and_changes_nothing() { let mut s = comp(640, 360); From 807803c14bbe5c9b82ff3d34c589019c55106255 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 24 Oct 2026 15:32:07 +0200 Subject: [PATCH 6/7] Scale Composition: escape pipes in the parity table, check redo in the test --- crates/engine/src/tests/scale_comp.rs | 5 ++++- docs/target-app-parity.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/scale_comp.rs b/crates/engine/src/tests/scale_comp.rs index 28cc1ba88..aad1511d9 100644 --- a/crates/engine/src/tests/scale_comp.rs +++ b/crates/engine/src/tests/scale_comp.rs @@ -74,10 +74,13 @@ fn scale_comp_doubles_the_frame_with_keys_and_parented_layers() { 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]); + let after = s.project.clone(); - // One undo step. + // One undo step, and one redo step. s.execute("edit.undo", json!({})).unwrap(); assert!(s.project == before, "undo restores the comp and every layer"); + s.execute("edit.redo", json!({})).unwrap(); + assert!(s.project == after, "redo scales the comp and every layer again"); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c3693ad5..bec66f75d 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; 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) | +| 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) | From 495243c5b6d78936d984f517b060262efa5bd519 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Thu, 29 Oct 2026 22:39:28 +0100 Subject: [PATCH 7/7] Scale Composition: refuse more than one of factor, width and height instead of ignoring the others --- crates/engine/src/cmd/comp_more.rs | 4 ++++ crates/engine/src/tests/scale_comp.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index 940c09f8d..6eaeda862 100644 --- a/crates/engine/src/cmd/comp_more.rs +++ b/crates/engine/src/cmd/comp_more.rs @@ -153,6 +153,10 @@ fn scale_comp(s: &mut Session, p: &Value) -> Result { 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)); + // The three are alternatives: with two, one would be ignored without a word. + if ["factor", "width", "height"].iter().filter(|k| f_p(p, k).is_some()).count() > 1 { + return Err(bad(C, "pass only one of `factor`, `width` or `height`")); + } let f = match (f_p(p, "factor"), f_p(p, "width"), f_p(p, "height")) { (Some(f), ..) => f, (None, Some(w), _) => w / ow, diff --git a/crates/engine/src/tests/scale_comp.rs b/crates/engine/src/tests/scale_comp.rs index aad1511d9..752ce9fe5 100644 --- a/crates/engine/src/tests/scale_comp.rs +++ b/crates/engine/src/tests/scale_comp.rs @@ -273,6 +273,8 @@ fn scale_comp_refuses_a_bad_factor_and_changes_nothing() { (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"), + (json!({"factor": 2, "width": 960}), "only one of"), + (json!({"width": 960, "height": 360}), "only one of"), ]; for (p, why) in cases { match s.execute("comp.scale", p.clone()) {