diff --git a/crates/engine/src/cmd/comp_more.rs b/crates/engine/src/cmd/comp_more.rs index 824966cb3..449444ed1 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,164 @@ 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; + } + } +} + +/// 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, (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 { + // 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; + } + } + } + } +} + +/// 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; 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)?; + 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, + (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); + 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)?; + // 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(&(has_scale, three_d, up)) = cur.and_then(|id| chain.get(&id)) else { break }; + xy |= has_scale; + z |= has_scale && three_d; + cur = up; + } + (xy, z) + }; + for l in &mut c.layers { + let (xy_carried, z_carried) = carried(l.parent); + let three_d = l.is_3d(); + let Some(tr) = l.transform_mut() else { continue }; + if !xy_carried { + for name in ["position", "positionX", "positionY", "positionZ", "poi", "scale"] { + if let Some(pr) = tr.get_mut(name) { + scale_prop(pr, f); + } + } + } else if three_d && !z_carried { + if let Some(pr) = tr.get_mut("positionZ") { + scale_prop(pr, f); + } + for name in ["position", "poi", "scale"] { + if let Some(pr) = tr.get_mut(name) { + scale_prop_z(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); + } + } + } + } + // 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})) +} + /// 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; @@ -236,6 +394,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 050f78957..aee5e8550 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -40,6 +40,7 @@ mod render_overwrite; 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..752ce9fe5 --- /dev/null +++ b/crates/engine/src/tests/scale_comp.rs @@ -0,0 +1,287 @@ +//! 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::{EngineError, 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]); + let after = s.project.clone(); + + // 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] +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); + 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))); + 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"); + 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_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_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); + 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_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_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); + solid(&mut s, "#ff0000", 100, 100); + let before = s.project.clone(); + 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"), + (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()) { + 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(); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6c0bd82a8..cfe7fd217 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) |