diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index acd8e9c8..eaf70cf7 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1141,6 +1141,22 @@ pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) } } +/// A Transform property's default (Reset) by match id: the anchor at the centre of the source +/// (`w`×`h`), the position at the comp's (`cw`×`ch`), 100% scale and opacity, no rotation. +pub(crate) fn transform_default(m: &str, (w, h): (f64, f64), (cw, ch): (f64, f64)) -> Option { + Some(match m { + "anchor" => KV::Vec3([w / 2.0, h / 2.0, 0.0]), + "position" => KV::Vec3([cw / 2.0, ch / 2.0, 0.0]), + "positionX" => KV::Scalar(cw / 2.0), + "positionY" => KV::Scalar(ch / 2.0), + "positionZ" | "rotation" | "rotationX" | "rotationY" => KV::Scalar(0.0), + "scale" => KV::Vec3([100.0; 3]), + "orientation" => KV::Vec3([0.0; 3]), + "opacity" => KV::Scalar(100.0), + _ => return None, + }) +} + fn transform_op(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let ids = unlocked(s, cid, ids, "layer.transform")?; @@ -1163,14 +1179,12 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { }; match op.as_str() { "reset" => { - set(tr, "anchor", KV::Vec3([w / 2.0, h / 2.0, 0.0])); + for m in ["anchor", "scale", "rotation", "rotationX", "rotationY", "orientation", "opacity"] { + if let Some(v) = transform_default(m, (w, h), (cw, ch)) { + set(tr, m, v); + } + } update_position(tr, lt, |_| [cw / 2.0, ch / 2.0, 0.0]); - set(tr, "scale", KV::Vec3([100.0; 3])); - set(tr, "rotation", KV::Scalar(0.0)); - set(tr, "rotationX", KV::Scalar(0.0)); - set(tr, "rotationY", KV::Scalar(0.0)); - set(tr, "orientation", KV::Vec3([0.0; 3])); - set(tr, "opacity", KV::Scalar(100.0)); } // Centred in the frame; a 3D layer keeps its depth. "center" => update_position(tr, lt, |p| [cw / 2.0, ch / 2.0, p[2]]), diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 8ee546de..063b179f 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -271,13 +271,40 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// A property's default value, when it has a known one: an effect parameter's, or a Transform +/// property's (not a camera's or light's). +fn default_value(s: &Session, cid: ItemId, lid: LayerId, uid: Uid) -> Option { + let comp = s.project.comp(cid)?; + let layer = comp.layer(lid)?; + let pr = layer.props.find(uid)?; + let effect = layer.props.node_chain(uid)?.into_iter().find_map(|n| match n { + effectcraft_project::Node::Group(g) => match &g.kind { + GroupKind::Effect { effect } => Some(effect.clone()), + _ => None, + }, + _ => None, + }); + if let Some(effect) = effect { + let ps = effectcraft_effects::find(&effect)?.params.iter().find(|ps| ps.id == pr.match_id)?; + return Some(effectcraft_effects::default_value(ps, effectcraft_render::effect_bounds(&s.project, comp, layer).0)); + } + if matches!(layer.source, effectcraft_project::LayerSource::Camera | effectcraft_project::LayerSource::Light { .. }) + || !layer.transform()?.props().any(|t| t.uid == uid) + { + return None; + } + let (w, h) = effectcraft_render::source_size(&s.project, layer); + super::layer::transform_default(&pr.match_id, (w as f64, h as f64), (comp.width as f64, comp.height as f64)) +} + fn reset(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?; let def = p.get("default").cloned(); + let own = default_value(s, cid, lid, uid); with_prop(s, "Reset Property", None, cid, lid, uid, |pr, _| { pr.keys.clear(); pr.expr = None; - if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)) { + if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)).or(own) { pr.value = d; } Ok(Value::Null) @@ -783,7 +810,15 @@ pub fn specs() -> Vec { toggle_transform_key ), cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr), - cmd!("prop.reset", "Reset Property", [], None, "{layer?, path|prop, default?}", has_layers, reset), + cmd!( + "prop.reset", + "Reset Property", + [], + None, + "{layer?, path|prop, default?} → removes keyframes and expression; the value becomes `default`, else the property's own (an effect parameter's, a Transform property's)", + has_layers, + reset + ), cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop), cmd!( "prop.convertExpressionToKeyframes", diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 587f0c85..cdfbf8dd 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -482,6 +482,43 @@ fn pick_whip_generates_ae_reference_expressions() { assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err()); } +/// #407: Reset without a value gives a property its own default (a Transform property's, an +/// effect parameter's) besides removing its keyframes and expression. +#[test] +fn reset_gives_a_property_its_default() { + let (mut s, l) = setup(); + s.execute("effect.apply", json!({"layer": l, "effect": "Gaussian Blur"})).unwrap(); + let blur = prop(&s, l, "effects/#1/blurriness").value; + let set = [ + ("transform/anchor", json!([10, 20, 0])), + ("transform/position", json!([10, 20, 0])), + ("transform/scale", json!([50, 70, 100])), + ("transform/rotation", json!(30)), + ("transform/opacity", json!(40)), + ("effects/#1/blurriness", json!(25)), + ]; + for (path, v) in &set { + s.execute("prop.set", json!({"layer": l, "path": path, "value": v})).unwrap(); + } + animate(&mut s, l, "transform/rotation", &[(0.0, json!(10)), (1.0, json!(20))]); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap(); + for (path, _) in &set { + s.execute("prop.reset", json!({"layer": l, "path": path})).unwrap(); + } + let v = |s: &Session, path: &str| prop(s, l, path).value; + assert_eq!(v(&s, "transform/anchor"), KV::Vec3([50.0, 50.0, 0.0]), "the solid's centre"); + assert_eq!(v(&s, "transform/position"), KV::Vec3([200.0, 150.0, 0.0]), "the comp's centre"); + assert_eq!(v(&s, "transform/scale"), KV::Vec3([100.0; 3])); + assert_eq!(v(&s, "transform/rotation"), KV::Scalar(0.0)); + assert!(!prop(&s, l, "transform/rotation").is_animated()); + assert_eq!(v(&s, "transform/opacity"), KV::Scalar(100.0)); + assert!(prop(&s, l, "transform/opacity").expr.is_none()); + assert_eq!(v(&s, "effects/#1/blurriness"), blur); + // A given default still wins. + s.execute("prop.reset", json!({"layer": l, "path": "transform/opacity", "default": 30})).unwrap(); + assert_eq!(v(&s, "transform/opacity"), KV::Scalar(30.0)); +} + /// #363: dropped on one of the target's values the pick whip picks that dimension /// (`position[0]`), as in After Effects; a multi-dimension property follows it on every axis. #[test] diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 279a437f..f41242f0 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -194,6 +194,17 @@ fn sequence_fields(app: &EffectcraftApp, runs: &[Vec]) -> Vec { ] } +/// Fields to type a numeric property value in (`value`, or `value[0]`… per dimension). +fn value_fields(name: &str, v: &effectcraft_engine::keyframe::Value) -> Option> { + use effectcraft_engine::keyframe::Value as KV; + Some(match v { + KV::Scalar(v) => vec![Field::num("value", name, *v)], + KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])], + KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])], + _ => return None, + }) +} + /// If `id` is a dialog command invoked without its parameters, open its form and return true. pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let s = &app.session; @@ -251,13 +262,14 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let t = p.get("time").and_then(Value::as_f64).map(effectcraft_engine::time::Tick::from_seconds_f64); let Some((pr, t)) = pr.zip(t) else { return false }; let Some(k) = pr.keys.iter().min_by_key(|k| (k.time.0 - t.0).abs()) else { return false }; - use effectcraft_engine::keyframe::Value as KV; - let fields = match &k.value { - KV::Scalar(v) => vec![Field::num("value", &pr.name, *v)], - KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])], - KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])], - _ => return false, - }; + let Some(fields) = value_fields(&pr.name, &k.value) else { return false }; + (pr.name.clone(), fields) + } + // A property's context menu ▸ Edit Value…: its value at the current time. + "prop.set" if !has(p, &["value"]) => { + let l = p.get("layer").and_then(Value::as_u64).and_then(|l| comp?.layer(effectcraft_engine::project::LayerId(l))); + let Some((l, pr)) = l.and_then(|l| Some((l, l.props.find(p.get("prop").and_then(Value::as_u64)?)?))) else { return false }; + let Some(fields) = value_fields(&pr.name, &pr.value_at(l.layer_time(t))) else { return false }; (pr.name.clone(), fields) } "layer.setTransform" if !has(p, &["value"]) => { diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7369c634..7f19d91b 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -495,12 +495,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte app.ui.timeline.graph_transform_box = !tbox; } x += 6.0; - let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| { - let layer = comp.layer(*l)?; - let tr = layer.transform()?; - (tr.get("position").is_some_and(|p| p.uid == *u) || ["positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == *u))) - .then_some(l.0) - }); + let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0)); if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0)) { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ebcc3e08..23e1850e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2356,6 +2356,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if name_resp.clicked() { actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)}))); } + name_resp.context_menu(|ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions)); // Value editors. let value = ectx.value(layer, prop); let vx = (cw.switches + 4.0).max(name_x + 120.0); @@ -3243,6 +3244,43 @@ fn kind_name(k: MatteKind) -> &'static str { } } +/// Is property `uid` the layer's Position (or, separated, its X, Y or Z Position)? +pub(crate) fn is_position(layer: &Layer, uid: u64) -> bool { + layer.transform().is_some_and(|tr| ["position", "positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == uid))) +} + +/// A property's context menu (right-click its name in the Timeline), as in After Effects. +fn prop_menu( + ui: &mut egui::Ui, + auto: &mut crate::automation::Registry, + comp: ItemId, + layer: &Layer, + prop: &Property, + actions: &mut Vec<(String, serde_json::Value)>, +) { + let (l, uid) = (layer.id.0, prop.uid); + let mut item = |ui: &mut egui::Ui, key: &str, label: &str, selected: bool, cmd: &str, params: serde_json::Value| { + let resp = ui.add(egui::Button::new(label).selected(selected)); + auto.add(&format!("timeline.prop.{uid}.menu.{key}"), resp.rect, label); + if resp.clicked() { + actions.push((cmd.into(), params)); + ui.close(); + } + }; + item(ui, "reset", "Reset", false, "prop.reset", json!({"layer": l, "prop": uid})); + if matches!(prop.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) { + item(ui, "editValue", "Edit Value…", false, "prop.set", json!({"layer": l, "prop": uid})); + } + if is_position(layer, uid) { + let separated = layer.transform().is_some_and(|tr| tr.get("positionX").is_some()); + item(ui, "separateDimensions", "Separate Dimensions", separated, "prop.separateDimensions", json!({"layer": l, "value": !separated})); + } + ui.separator(); + let (key, label) = if prop.expr.is_some() { ("removeExpression", "Remove Expression") } else { ("addExpression", "Add Expression") }; + item(ui, key, label, false, "prop.setExpression", json!({"layer": l, "prop": uid})); + item(ui, "essentialGraphics", "Add Property to Essential Graphics", false, "essential.addProperty", json!({"comp": comp.0, "layer": l, "prop": uid})); +} + fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(String, serde_json::Value)>) { resp.context_menu(|ui| layer_menu(ui, &[layer.id.0], actions)); } diff --git a/crates/ui-egui/tests/ui_blank_context_menus.rs b/crates/ui-egui/tests/ui_blank_context_menus.rs index 415eddc7..561f50a0 100644 --- a/crates/ui-egui/tests/ui_blank_context_menus.rs +++ b/crates/ui-egui/tests/ui_blank_context_menus.rs @@ -1,4 +1,4 @@ -//! Right-click actions in the blank Project and Timeline areas. +//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names. use effectcraft_engine::Session; use effectcraft_ui_egui::{Dialog, EffectcraftApp}; use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2}; @@ -131,3 +131,67 @@ fn timeline_blank_menu_opens_composition_settings() { h.run_steps(3); assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); } + +/// #407: right-clicking a property's name in the Timeline opens After Effects' property menu: +/// Reset, Edit Value…, Separate Dimensions (Position only, checked once separated), Add or +/// Remove Expression and Add Property to Essential Graphics. +#[test] +fn timeline_property_names_have_the_property_menu() { + let mut h = harness(); + let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + h.state_mut().session.execute("prop.set", json!({"layer": layer, "path": "transform/rotation", "value": 30})).unwrap(); + let props = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid; + h.state_mut().ui.timeline.open_layers.insert(layer); + let transform = props(&h).sub("transform").unwrap().uid; + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let menu = |h: &mut Harness<'_, EffectcraftApp>, uid: u64| { + let p = rect(h, &format!("timeline.prop.{uid}.name")).center(); + right_click(h, p); + }; + let choose = |h: &mut Harness<'_, EffectcraftApp>, uid: u64, entry: &str| { + let p = rect(h, &format!("timeline.prop.{uid}.menu.{entry}")).center(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); + }; + // Reset: Rotation's default. + let rotation = uid(&h, "transform/rotation"); + menu(&mut h, rotation); + assert!(h.query_by_label("Edit Value…").is_some()); + assert!(h.query_by_label("Separate Dimensions").is_none(), "only Position separates"); + choose(&mut h, rotation, "reset"); + assert_eq!(props(&h).prop("transform/rotation").unwrap().value.as_f64(), 0.0); + // Separate Dimensions on Position; X Position's menu has it too, and joins them again. + let position = uid(&h, "transform/position"); + menu(&mut h, position); + choose(&mut h, position, "separateDimensions"); + assert!(props(&h).prop("transform/positionX").is_some(), "separated"); + let x = uid(&h, "transform/positionX"); + menu(&mut h, x); + choose(&mut h, x, "separateDimensions"); + assert!(props(&h).prop("transform/positionX").is_none(), "joined"); + // Add Expression, then Remove Expression. + let opacity = uid(&h, "transform/opacity"); + menu(&mut h, opacity); + choose(&mut h, opacity, "addExpression"); + assert_eq!(props(&h).prop("transform/opacity").unwrap().expr.as_ref().map(|e| e.text.clone()).as_deref(), Some("transform.opacity")); + menu(&mut h, opacity); + choose(&mut h, opacity, "removeExpression"); + assert!(props(&h).prop("transform/opacity").unwrap().expr.is_none()); + // Add Property to Essential Graphics. + menu(&mut h, opacity); + choose(&mut h, opacity, "essentialGraphics"); + let comp = h.state().session.active_comp_id().unwrap(); + assert_eq!(h.state().session.project.comp(comp).unwrap().essential.as_ref().map_or(0, |e| e.controls.len()), 1); + // Edit Value… opens the value dialog. + let scale = uid(&h, "transform/scale"); + menu(&mut h, scale); + choose(&mut h, scale, "editValue"); + assert_eq!(h.state().dialog, Some(Dialog::Form)); +} diff --git a/docs/agents.md b/docs/agents.md index e30d956e..71d52afe 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -487,6 +487,12 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its expression pick whip does; `dimension` picks one of the target's values (`position[0]`), as a drop on that value field does. +* A property's menu (right-click its name; entries `timeline.prop..menu.`): Reset + (`prop.reset`: no keyframes or expression, and the property's default value, an effect + parameter's or a Transform property's), Edit Value… (`prop.set {layer, prop}` without `value` + opens the value dialog), Separate Dimensions (Position: `prop.separateDimensions {layer, + value}`), Add / Remove Expression (`prop.setExpression {layer, prop}`) and Add Property to + Essential Graphics (`essential.addProperty`). ### History, puppet recording, plug-ins diff --git a/docs/parity.md b/docs/parity.md index 64fddba9..2adb4490 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -171,7 +171,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | 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) | | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the viewer's resize cursors point the way each handle scales and turn with the layer, #393; Ctrl/Cmd-click on the Timeline's or the Composition panel's current-time display toggles Timecode / Frames, #370; Tab / Shift+Tab in a value being typed commits it and types in the next / previous value of the panel (Timeline, Effect Controls), #361; the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the viewer's resize cursors point the way each handle scales and turn with the layer, #393; Ctrl/Cmd-click on the Timeline's or the Composition panel's current-time display toggles Timecode / Frames, #370; Tab / Shift+Tab in a value being typed commits it and types in the next / previous value of the panel (Timeline, Effect Controls), #361; right-clicking a property's name in the Timeline opens its menu (Reset, Edit Value…, Separate Dimensions, Add / Remove Expression, Add Property to Essential Graphics), #407; the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297) | | Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |