From fcf3bb5dcad50cbb54f46c1979b9b6b8905ce5d4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:33:59 +0700 Subject: [PATCH 1/7] Viewer: resize cursors follow each handle and the layer's rotation (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bounding-box handle in the Composition viewer showed the north-west / south-east resize cursor, so two corners and all four edges showed the wrong direction. Each handle now takes the cursor along the direction it scales, worked out from the on-screen box: a corner points between its two edges (the box's diagonal whatever its proportions), an edge handle across its edge. The cursors turn with the layer's rotation, as in After Effects. Tests: handle_cursors_follow_each_handle_and_the_layer_rotation (ui_viewer: a wide box at 0°, 90° and 45°). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/viewer.rs | 37 +++++++++++++++++++++++++---- crates/ui-egui/tests/ui_viewer.rs | 32 +++++++++++++++++++++++++ docs/parity.md | 2 +- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index e63d9c7f..66045ce2 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -295,6 +295,32 @@ fn handle_point(b: [f64; 4], handle: usize) -> [f64; 2] { } } +/// The resize cursor over bounding-box handle `handle` of the on-screen box `sq` (corners +/// clockwise from the top left, handles as in [`handle_point`]): along the direction the handle +/// scales, so it turns with the layer, as in After Effects. A corner points between its two +/// edges (the box's diagonal whatever its proportions), an edge handle across its edge. +fn handle_cursor(sq: &[Pos2], handle: usize) -> egui::CursorIcon { + let at = |k: usize| sq.get(k % 4).copied().unwrap_or(Pos2::ZERO); + let dir = if handle < 4 { + let c = at(handle); + (c - at(handle + 3)).normalized() + (c - at(handle + 1)).normalized() + } else { + let e = at(handle - 3) - at(handle - 4); + vec2(-e.y, e.x) + }; + // Screen y points down: 45° is the south-east diagonal. + let a = dir.y.atan2(dir.x).to_degrees().rem_euclid(180.0); + if !(22.5..157.5).contains(&a) { + egui::CursorIcon::ResizeHorizontal + } else if a < 67.5 { + egui::CursorIcon::ResizeNwSe + } else if a < 112.5 { + egui::CursorIcon::ResizeVertical + } else { + egui::CursorIcon::ResizeNeSw + } +} + /// The scale ratio (x, y) that puts the point grabbed at `start` (relative to the anchor, layer /// pixels) under the pointer at `cur`. `uniform` (Shift on a corner) keeps the aspect ratio, /// following the pointer along the handle's diagonal. Dragging past the anchor flips the layer, @@ -748,7 +774,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Overlays + interaction. let selected = app.session.state.selected_layers.clone(); - let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new(); + // Bounding-box handles: (layer, handle, screen point, its resize cursor). + let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new(); let mut vertex_hits: Vec = Vec::new(); let mut key_hits: Vec = Vec::new(); let mut paths: Vec = Vec::new(); @@ -815,7 +842,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hr = Rect::from_center_size(hp, vec2(hs, hs)); painter.rect_filled(hr, 0.0, col); painter.rect_stroke(hr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(120)), StrokeKind::Outside); - handle_hits.push((l.id, i, hp)); + handle_hits.push((l.id, i, hp, handle_cursor(&sq, i))); app.auto.add(&format!("viewer.handle.{}.{i}", l.id.0), hr, "handle"); } // Anchor point. @@ -1036,8 +1063,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::CursorIcon::Move } else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) { if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical } - } else if handle_hits.iter().any(|(_, _, h)| h.distance(hp) < HANDLE) { - egui::CursorIcon::ResizeNwSe + } else if let Some((.., cursor)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(hp) < HANDLE) { + *cursor } else { egui::CursorIcon::Default } @@ -1213,7 +1240,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // far enough to count as a drag it may have left a small handle. Tool::Selection => { let cpt = map.to_comp(press); - if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(press) < HANDLE + 2.0).cloned() { + if let Some((lid, hi, ..)) = handle_hits.iter().find(|(_, _, h, _)| h.distance(press) < HANDLE + 2.0).cloned() { let layer = comp.layer(lid).cloned(); layer.and_then(|layer| { let tr = layer.transform()?; diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 34b1ed3a..cfb1db37 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -1538,6 +1538,38 @@ fn handle_drags_snap_to_the_comp_edges() { assert!(close(s, [100.0, 100.0]), "{s:?}"); } +/// Each bounding-box handle shows the resize cursor along the direction it scales, turning with +/// the layer (#393: every handle showed the north-west / south-east cursor). +#[test] +fn handle_cursors_follow_each_handle_and_the_layer_rotation() { + use egui::CursorIcon::{ResizeHorizontal as H, ResizeNeSw as NeSw, ResizeNwSe as NwSe, ResizeVertical as V}; + let mut h = harness(); + let box_id = h.state().session.active_comp().unwrap().layers[0].id.0; + let set = |h: &mut Harness<'_, EffectcraftApp>, path: &str, v: serde_json::Value| { + h.state_mut().session.execute("prop.set", json!({"layer": box_id, "path": path, "value": v})).unwrap(); + h.run_steps(2); + }; + let cursors = |h: &mut Harness<'_, EffectcraftApp>| { + (0..8) + .map(|i| { + let c = rect(h, &format!("viewer.handle.{box_id}.{i}")).center(); + h.input_mut().events.push(Event::PointerMoved(c)); + h.run_steps(2); + h.output().platform_output.cursor_icon + }) + .collect::>() + }; + h.state_mut().session.execute("layer.select", json!({"layers": [box_id]})).unwrap(); + // A wide box: its corners still take the diagonal cursors. + set(&mut h, "transform/scale", json!([300, 100, 100])); + // Corners clockwise from the top left, then the top, right, bottom and left edges. + assert_eq!(cursors(&mut h), [NwSe, NeSw, NwSe, NeSw, V, H, V, H]); + set(&mut h, "transform/rotation", json!(90)); + assert_eq!(cursors(&mut h), [NeSw, NwSe, NeSw, NwSe, H, V, H, V], "turned a quarter"); + set(&mut h, "transform/rotation", json!(45)); + assert_eq!(cursors(&mut h), [V, H, V, H, NeSw, NwSe, NeSw, NwSe], "turned an eighth"); +} + /// The arrow keys over the Composition panel nudge the selected layer 1 pixel at the viewer's /// magnification (half a comp pixel at 200 %), Shift+arrow 10, one undo step each (#290). #[test] diff --git a/docs/parity.md b/docs/parity.md index 9e3fab58..3f05de6f 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 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; 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) | From 0b2ef8e58498489cd80d185ee1dbb80a593ac18e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:34:37 +0700 Subject: [PATCH 2/7] Timeline: Ctrl/Cmd-click on the time display toggles Timecode / Frames (#370) The Time Display Style could only be changed in Project Settings. As in After Effects, Ctrl-click (Cmd-click on macOS) on the current-time display of the Timeline or the Composition panel now toggles it between Timecode and Frames (Feet + Frames goes to Timecode). It runs `file.projectSettings {timeDisplay}`, so it is one undo step. Under the Timeline's display the other style shows now (frames under timecode, timecode under frames), as in After Effects; it always showed frames. Tests: ctrl_click_on_the_time_display_toggles_timecode_and_frames (ui_inline_edit). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/mod.rs | 12 ++++++ crates/ui-egui/src/panels/timeline.rs | 11 +++++- crates/ui-egui/src/panels/viewer_tools.rs | 2 + crates/ui-egui/tests/ui_inline_edit.rs | 46 +++++++++++++++++++++++ docs/agents.md | 2 + docs/parity.md | 2 +- 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 047966af..ab5a1c8f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -84,6 +84,18 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { effectcraft_engine::commands::time::display_time(session, comp, t) } +/// Ctrl/Cmd-click on a current-time display toggles the project's Time Display Style between +/// Timecode and Frames, as in After Effects (through Project Settings: one undo step). +pub(crate) fn toggle_time_display(app: &mut EffectcraftApp, resp: &egui::Response) { + if !(resp.clicked() && resp.ctx.input(|i| i.modifiers.command)) { + return; + } + let timecode = app.session.project.settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode; + if let Err(e) = app.session.execute("file.projectSettings", serde_json::json!({"timeDisplay": if timecode { "frames" } else { "timecode" }})) { + app.ui.status = e.to_string(); + } +} + /// Spacebar held outside a text field: the Hand tool (drags pan the viewer and scroll the /// Timeline). pub(crate) fn space_hand(ctx: &egui::Context) -> bool { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 69139d3b..042718fb 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1156,9 +1156,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0)); let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag()); p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode); - let sub = format!("{:05} ({:.2} fps)", fr.frame_at(time) + app.session.project.settings.frame_start, fr.as_f64()); - p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, sub, Tokens::ui(10.5), t.text_faint); + // Under it the other style (frames under timecode, timecode under frames) and the rate. + let settings = &app.session.project.settings; + let other = if settings.time_display == effectcraft_engine::project::TimeDisplayStyle::Timecode { + format!("{:05}", fr.frame_at(time) + settings.frame_start) + } else { + effectcraft_engine::time::format_timecode_ae(fr.frame_at(time + comp.display_start), fr, false) + }; + p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, format!("{other} ({:.2} fps)", fr.as_f64()), Tokens::ui(10.5), t.text_faint); app.auto.add("timeline.timecode", tc_rect, &tc); + super::toggle_time_display(app, &tc_resp); if tc_resp.dragged() { let d = tc_resp.drag_delta().x as f64; let f = fr.frame_at(time) + (d * 0.5).round() as i64; diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 06b529b3..36b64b48 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -733,7 +733,9 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode); let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0)); p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode); + let resp = ui.interact(tr, egui::Id::new("vw-timecode"), Sense::click()); app.auto.add("viewer.timecode", tr, "Current time"); + crate::panels::toggle_time_display(app, &resp); // Right: render time. let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); if tr.max.x + 50.0 < bar.max.x { diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index 47b77471..a8702ad1 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -127,3 +127,49 @@ fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() { whip(&mut h); assert_eq!(expr(&h), "transform.rotation"); } + +/// A click at `p` with `modifiers` held. +fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers }); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(3); +} + +/// #370: Ctrl/Cmd-click on the current-time display (the Timeline's or the Composition +/// panel's) toggles the Time Display Style between Timecode and Frames, one undo step each, as +/// in After Effects; under the Timeline's display the other style shows. +#[test] +fn ctrl_click_on_the_time_display_toggles_timecode_and_frames() { + use effectcraft_engine::project::TimeDisplayStyle; + let (mut h, _) = harness(); + h.state_mut().session.execute("time.set", json!({"frame": 30})).unwrap(); + h.run_steps(2); + let shown = |h: &Harness<'_, EffectcraftApp>, id: &str| h.state().auto.find(id).unwrap().label.clone(); + let style = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.settings.time_display; + let undo = h.state().session.history.undo.len(); + let at = center(&h, "timeline.timecode"); + click_with(&mut h, at, Modifiers::COMMAND); + assert_eq!(style(&h), TimeDisplayStyle::Frames); + assert_eq!(shown(&h, "timeline.timecode"), "00030"); + assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step"); + assert!(!h.ctx.egui_wants_keyboard_input(), "no time entry opened"); + let at = center(&h, "viewer.timecode"); + click_with(&mut h, at, Modifiers::COMMAND); + assert_eq!(style(&h), TimeDisplayStyle::Timecode); + assert_eq!(shown(&h, "timeline.timecode"), "0:00:01:06"); + // A plain click changes nothing. + click_with(&mut h, at, Modifiers::NONE); + let at = center(&h, "timeline.timecode"); + click_with(&mut h, at, Modifiers::NONE); + assert_eq!(style(&h), TimeDisplayStyle::Timecode); + // Feet + Frames goes to Timecode. + h.state_mut().session.execute("file.projectSettings", json!({"timeDisplay": "feet35"})).unwrap(); + h.run_steps(2); + click_with(&mut h, at, Modifiers::COMMAND); + assert_eq!(style(&h), TimeDisplayStyle::Timecode); +} diff --git a/docs/agents.md b/docs/agents.md index 207406f5..e12c1509 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -482,6 +482,8 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers). * Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly. +* Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time + Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. ### History, puppet recording, plug-ins diff --git a/docs/parity.md b/docs/parity.md index 3f05de6f..4ebdb4b4 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; 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; 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) | From 639342a5e7d55f6cfd8ef65be398ccbdbbf64642 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:35:15 +0700 Subject: [PATCH 3/7] Timeline: the pick whip scrolls the layers at their edges (#362) A pick whip dragged to the top or bottom of the Timeline's layers left them where they were, so a property out of view couldn't be picked. While a pick whip (expression or parent) is held within a row of the top or bottom edge of the layers, or past it, the layers scroll that way, faster the deeper, as in After Effects. Tests: the_pick_whip_scrolls_the_timeline_at_its_edges (ui_inline_edit). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 27 +++++++++++++++- crates/ui-egui/tests/ui_inline_edit.rs | 45 ++++++++++++++++++++++++++ docs/parity.md | 2 +- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 042718fb..7b1ccb69 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -132,6 +132,20 @@ fn pick_whip_id() -> egui::Id { egui::Id::new("tl-pickwhip") } +/// Autoscroll speed (points per second, up negative) of a drag held at height `y` within +/// `zone` of the top or bottom edge of `rows`, or past it: faster the deeper. +fn edge_scroll_speed(rows: Rect, y: f32, zone: f32) -> f32 { + let zone = zone.min(rows.height() / 2.0).max(1.0); + let depth = if y < rows.min.y + zone { + y - (rows.min.y + zone) + } else if y > rows.max.y - zone { + y - (rows.max.y - zone) + } else { + return 0.0; + }; + (depth / zone).clamp(-3.0, 3.0) * 400.0 +} + /// The text of an expression being edited when its pick whip was pressed, and the selection in /// it (characters): the pick whip's reference goes in there. type WhipInto = (String, [usize; 2]); @@ -1552,7 +1566,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } - app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.min(max_scroll); + // A pick whip held near the top or bottom of the layers (or past it) scrolls them, as in + // After Effects. + if ctx.data(|d| d.get_temp::(pick_whip_id())).is_some() + && let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()).filter(|p| rect.x_range().contains(p.x)) + { + let speed = edge_scroll_speed(rows_rect, ptr.y, rh); + if speed != 0.0 { + app.ui.timeline.scroll_y += speed * ctx.input(|i| i.stable_dt).min(0.1); + ctx.request_repaint(); + } + } + app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.clamp(0.0, max_scroll); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None }; diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index a8702ad1..a5e35479 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -128,6 +128,51 @@ fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() { assert_eq!(expr(&h), "transform.rotation"); } +/// #362: an expression pick whip held near the bottom or top of the Timeline's layers scrolls +/// them (After Effects), so a property out of view can be picked. +#[test] +fn the_pick_whip_scrolls_the_timeline_at_its_edges() { + let (mut h, _) = harness(); + let (top, opacity, transform) = { + let s = &mut h.state_mut().session; + for i in 0..40 { + s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap(); + } + let l = s.execute("layer.newSolid", json!({"name": "Top", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(top); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let scroll = |h: &Harness<'_, EffectcraftApp>| h.state().ui.timeline.scroll_y; + let rows = h.state().auto.find("timeline.scroll").unwrap().rect; + let from = center(&h, &format!("timeline.prop.{opacity}.pickWhip")); + let (bottom, upper) = (pos2(from.x, rows[1] + rows[3] - 2.0), pos2(from.x, rows[1] + 2.0)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (bottom - from) * (k as f32 / 8.0))); + h.step(); + } + h.run_steps(20); + let down = scroll(&h); + assert!(down > 50.0, "held at the bottom edge the layers scroll up: {down}"); + h.input_mut().events.push(Event::PointerMoved(upper)); + h.run_steps(10); + assert!(scroll(&h) < down, "and at the top edge back down: {} after {down}", scroll(&h)); + h.input_mut().events.push(Event::PointerMoved(pos2(from.x, rows[1] + rows[3] / 2.0))); + h.run_steps(2); + let still = scroll(&h); + h.run_steps(10); + assert_eq!(scroll(&h), still, "away from the edges it stays"); + h.input_mut().events.push(Event::PointerButton { pos: upper, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + /// A click at `p` with `modifiers` held. fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) { h.input_mut().events.push(Event::PointerMoved(p)); diff --git a/docs/parity.md b/docs/parity.md index 4ebdb4b4..499066f0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -166,7 +166,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | 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) | -| Animation | 70% | 9.0 | puppet depth beyond pins, recording, rigging and follow-through (G2: 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) | +| 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; 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) | | 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 | From 6fadae2818f1025d94d717f6c7f4d5a8d595aa50 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:35:45 +0700 Subject: [PATCH 4/7] Timeline: the pick whip picks the dimension it is dropped on (#363) Dropping the expression pick whip on one of a property's values (the X of Position) referenced the whole property. As in After Effects, a drop on one value now picks that dimension: `transform.position[0]`; a drop on the name still picks the whole property. `prop.pickWhip` takes `target.dimension`. The rest of #363 is unchanged: inserting at the cursor landed with #284, and After Effects' property pick whip in the Parent & Link column is left for later. Tests: pick_whip_picks_one_dimension_of_the_target (engine), the_expression_pick_whip_picks_the_dimension_it_is_dropped_on (ui_inline_edit). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/link.rs | 8 +++++- crates/engine/src/tests_timeline.rs | 17 ++++++++++++ crates/ui-egui/src/panels/timeline.rs | 19 +++++++++++-- crates/ui-egui/tests/ui_inline_edit.rs | 38 ++++++++++++++++++++++++++ docs/agents.md | 3 ++ docs/parity.md | 2 +- 6 files changed, 82 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/commands/link.rs b/crates/engine/src/commands/link.rs index 73697698..2eeb7c5a 100644 --- a/crates/engine/src/commands/link.rs +++ b/crates/engine/src/commands/link.rs @@ -164,6 +164,12 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result { let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?); let (dm, dt) = (dims(from, me), dims(target, them)); + // Dropped on one of the target's values: that dimension (`position[0]`), as in After Effects. + let (r, dt) = match t.get("dimension").and_then(Value::as_u64) { + Some(d) if d < dt as u64 => (format!("{r}[{d}]"), 1), + Some(d) => return Err(bad("prop.pickWhip", format!("the target has {dt} dimension(s), no dimension {d}"))), + None => (r, dt), + }; // While the expression is being edited the reference goes in at the cursor, replacing the // selected text (After Effects): `expression` is the text being edited, `range` the // selection [start, end] in characters. @@ -200,7 +206,7 @@ pub fn specs() -> Vec { "Pick Whip (Link Property)", [], None, - "{layer?, path|prop, target: {layer, path|prop}, expression?, range?: [start, end]} → sets an AE reference expression (with `expression`: inserts it there, replacing the characters in `range`, default the end)", + "{layer?, path|prop, target: {layer, path|prop, dimension?}, expression?, range?: [start, end]} → sets an AE reference expression (`dimension`: one of the target's values, `position[0]`; with `expression`: inserts it there, replacing the characters in `range`, default the end)", has_layers, pick_whip ), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index a392a478..587f0c85 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -482,6 +482,23 @@ 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()); } +/// #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] +fn pick_whip_picks_one_dimension_of_the_target() { + let (mut s, l) = setup(); + let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + let whip = |s: &mut Session, path: &str, d: u64| { + s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": {"layer": o, "path": "transform/position", "dimension": d}})) + }; + let r = "thisComp.layer(\"Other\").transform.position"; + assert_eq!(whip(&mut s, "transform/rotation", 0).unwrap(), json!(format!("{r}[0]"))); + assert_eq!(whip(&mut s, "transform/scale", 1).unwrap(), json!(format!("temp = {r}[1];\n[temp, temp]"))); + assert_eq!(prop(&s, l, "transform/scale").expr.unwrap().text, format!("temp = {r}[1];\n[temp, temp]")); + // A 2D layer's Position has two. + assert!(whip(&mut s, "transform/rotation", 2).is_err()); +} + /// #284: with the expression being edited, the pick whip puts the reference at the cursor, /// replacing the selected characters (After Effects), in one undo step. #[test] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 7b1ccb69..b25c8d9d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1599,6 +1599,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let outline_empty = ui.interact(outline_rect, egui::Id::new("tl-layer-bg"), Sense::click_and_drag()); app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers"); let mut hit_rows: Vec<(Rect, Row)> = vec![]; + // The value fields of multi-dimension properties (per dimension), for the pick whip. + let mut dim_hits: Vec<(u64, Vec)> = vec![]; for (ri, row) in rows.iter().enumerate() { // Outline widgets never spill into the time graph (narrow timelines). ui.set_clip_rect(left_clip); @@ -2335,7 +2337,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Value editors. let value = ectx.value(layer, prop); let vx = (cw.switches + 4.0).max(name_x + 120.0); - value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions); + let dims = value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions); + if !dims.is_empty() { + dim_hits.push((*uid, dims)); + } ui.set_clip_rect(right_clip); // Expression text row hint. if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) { @@ -2567,6 +2572,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } (1, Some((_, Row { layer, kind: RowKind::Prop { uid }, .. }))) if uid != src_prop => { let mut params = json!({"layer": src_layer, "prop": src_prop, "target": {"layer": layer.0, "prop": uid}}); + // On one of its values: that dimension (`position[0]`). + if let Some(d) = dim_hits.iter().find(|(u, _)| *u == uid).and_then(|(_, rs)| rs.iter().position(|r| r.contains(ptr))) { + params["target"]["dimension"] = json!(d); + } if let Some((text, [a, b])) = into { // Into the expression being edited, which goes on being edited. params["expression"] = json!(text); @@ -3301,7 +3310,8 @@ fn constrained(c: &[f64], d: usize, v: f64) -> Vec { .collect() } -/// Inline value editor for a property row; pushes `prop.set` actions. +/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a +/// multi-dimension value's fields, one per dimension (none for other values). fn value_editor( app: &mut EffectcraftApp, ui: &mut egui::Ui, @@ -3311,9 +3321,10 @@ fn value_editor( value: &Value, at: Pos2, actions: &mut Vec<(String, serde_json::Value)>, -) { +) -> Vec { let t = app.tokens; let uid = prop.uid; + let mut dims = vec![]; let merge = format!("scrub-{uid}"); let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| { actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge}))) @@ -3368,6 +3379,7 @@ fn value_editor( let suffix = if pct && d + 1 == n { "%" } else { "" }; let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t); app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name); + dims.push(r); if let Some(nv) = nv { let mut nc = c.clone(); // Alt edits one value of a linked pair. @@ -3450,6 +3462,7 @@ fn value_editor( p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::(), Tokens::ui(12.0), t.text_dim); } } + dims } #[cfg(test)] diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index a5e35479..d15b05ec 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -128,6 +128,44 @@ fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() { assert_eq!(expr(&h), "transform.rotation"); } +/// #363: dropped on one of a property's values the expression pick whip picks that dimension +/// (`position[1]`), as in After Effects; on its name, the whole property. +#[test] +fn the_expression_pick_whip_picks_the_dimension_it_is_dropped_on() { + let (mut h, _) = harness(); + let (layer, opacity, position, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.get("position").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let expr = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(opacity).unwrap().expr.as_ref().unwrap().text.clone() + }; + let whip_to = |h: &mut Harness<'_, EffectcraftApp>, target: &str| { + let (from, to) = (center(h, &format!("timeline.prop.{opacity}.pickWhip")), center(h, target)); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + whip_to(&mut h, &format!("timeline.prop.{position}.value.1")); + assert_eq!(expr(&h), "transform.position[1]"); + whip_to(&mut h, &format!("timeline.prop.{position}.name")); + assert_eq!(expr(&h), "transform.position[0]", "the whole Position into one value: its first, as before"); +} + /// #362: an expression pick whip held near the bottom or top of the Timeline's layers scrolls /// them (After Effects), so a property out of view can be picked. #[test] diff --git a/docs/agents.md b/docs/agents.md index e12c1509..e30d956e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -484,6 +484,9 @@ Extension points and the bundled extensions: [plugins.md](plugins.md). click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly. * Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. +* `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. ### History, puppet recording, plug-ins diff --git a/docs/parity.md b/docs/parity.md index 499066f0..f8a75dca 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -166,7 +166,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | 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) | -| 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; 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) | +| 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; 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) | | 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 | From 0171eb1e2e19ece60828d0abf90113aee2771bd8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:36:13 +0700 Subject: [PATCH 5/7] Timeline: the expression field grows while typing and drags taller (#371) The inline expression field took its height from the saved expression, so lines typed into it ran over the rows below until the expression was committed. Its height now comes from the text being typed (up to 8 lines, as before), and its bottom edge drags it to any height in whole lines, as in After Effects; the dragged height is kept per property in the Timeline state (`expr_lines`). Tests: the_expression_field_grows_while_editing_and_drags_taller (ui_inline_edit). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 54 ++++++++++++++++++-------- crates/ui-egui/src/state.rs | 5 +++ crates/ui-egui/tests/ui_inline_edit.rs | 51 ++++++++++++++++++++++++ docs/parity.md | 2 +- 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b25c8d9d..ebcc3e08 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -93,14 +93,26 @@ fn stripes(p: &egui::Painter, r: Rect, label: Color32) { /// Row height (expression editors grow with their text). fn row_height(row: &Row, rh: f32) -> f32 { match row.kind { - RowKind::Expr { lines, .. } => rh * lines.clamp(1, 8) as f32 + 6.0, + RowKind::Expr { lines, .. } => rh * lines as f32 + 6.0, RowKind::Waveform { .. } => rh * 3.0, _ => rh, } } -/// Insert an expression editor row after every property that has an expression. -fn with_expr_rows(rows: Vec, comp: &Comp, closed: &std::collections::BTreeSet) -> Vec { +/// An inline expression editor grows with its text up to this many lines (After Effects)... +const EXPR_AUTO_LINES: usize = 8; +/// ...and its bottom edge drags it up to this many. +const EXPR_MAX_LINES: usize = 100; + +/// The text of the expression being edited (while its editor has the keyboard). +fn expr_buf_id(prop: u64) -> egui::Id { + egui::Id::new(("expr-buf", prop)) +} + +/// Insert an expression editor row after every property that has an expression: as many lines +/// as its text (the text being edited, so it grows while typing), up to 8, or the height its +/// bottom edge was dragged to. +fn with_expr_rows(rows: Vec, comp: &Comp, tl: &crate::state::TimelineState, ctx: &egui::Context) -> Vec { let mut out = Vec::with_capacity(rows.len()); // Rows come grouped by layer: look each layer up once (a linear search per property row // made twirling open many layers of a big comp quadratic). @@ -108,15 +120,17 @@ fn with_expr_rows(rows: Vec, comp: &Comp, closed: &std::collections::BTreeS for r in rows { let mut extra = None; if let RowKind::Prop { uid } = r.kind - && !closed.contains(&uid) + && !tl.expr_closed.contains(&uid) { if cur.is_none_or(|l| l.id != r.layer) { cur = comp.layer(r.layer); } - extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row { - layer: r.layer, - depth: r.depth, - kind: RowKind::Expr { uid, lines: e.text.lines().count().max(1) }, + extra = cur.and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| { + let lines = tl.expr_lines.get(&uid).copied().unwrap_or_else(|| { + let edited = ctx.data(|d| d.get_temp::(expr_buf_id(uid))); + edited.as_deref().unwrap_or(&e.text).split('\n').count().min(EXPR_AUTO_LINES) + }); + Row { layer: r.layer, depth: r.depth, kind: RowKind::Expr { uid, lines: lines.clamp(1, EXPR_MAX_LINES) } } }); } out.push(r); @@ -1542,7 +1556,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = cw.end; // ---- rows. - let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline.expr_closed); + let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &ctx); let rh = t.row_h; let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum(); let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); @@ -2108,6 +2122,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { RowKind::Expr { uid, lines } => { let Some(prop) = layer.props.find(*uid) else { continue }; let Some(ex) = prop.expr.clone() else { continue }; + let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::new(("expr-edit", uid))); lp.rect_filled(left, 0.0, t.row_alt); let indent = cw.name + 6.0 + 14.0 * row.depth as f32; lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim); @@ -2138,9 +2153,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Pressed while the expression is being edited (its text is buffered while // the editor has the focus): the reference will go in at the cursor, // replacing the selection (#284). - let into = ctx.data(|d| d.get_temp::(egui::Id::new(("expr-buf", uid)))).map(|buf| { + let into = ctx.data(|d| d.get_temp::(buf_id)).map(|buf| { let n = buf.chars().count(); - let sel = egui::text_edit::TextEditState::load(&ctx, egui::Id::new(("expr-edit", uid))) + let sel = egui::text_edit::TextEditState::load(&ctx, edit_id) .and_then(|st| st.cursor.char_range()) .map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]); (buf, sel) @@ -2187,8 +2202,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } }); if let Some(text) = picked { - let edit_id = egui::Id::new(("expr-edit", uid)); - let buf_id = egui::Id::new(("expr-buf", uid)); let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); let at = egui::text_edit::TextEditState::load(&ctx, edit_id) .and_then(|st| st.cursor.char_range()) @@ -2205,8 +2218,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(right_clip); gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a)); let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); - let buf_id = egui::Id::new(("expr-buf", uid)); - let edit_id = egui::Id::new(("expr-edit", uid)); let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); if let Some(after) = ctx.data_mut(|d| d.remove_temp::(expr_refocus_id(*uid))) { // A pick whip just put a reference in: editing goes on in the new text, @@ -2219,8 +2230,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Settings ▸ Scripting & Expressions ▸ Expressions Editor. let sp = app.session.prefs.scripting.clone(); - let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, (*lines).clamp(1, 8), &t); + let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, *lines, &t); app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name); + // Its bottom edge drags to set its height in whole lines, as in After Effects. + let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y)); + let eresp = ui.interact(edge, egui::Id::new(("expr-edge", uid)), Sense::drag()); + app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height"); + if eresp.hovered() || eresp.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) { + let n = ((py - r.min.y - 6.0) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32); + app.ui.timeline.expr_lines.insert(*uid, n as usize); + } let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl)); if commit { resp.surrender_focus(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d7a7edcd..a7069c2b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -413,6 +413,10 @@ pub struct TimelineState { /// Properties whose inline expression editor is collapsed. #[serde(default)] pub expr_closed: BTreeSet, + /// Inline expression editors given a height (in lines) by dragging their bottom edge; + /// the others grow with their text, up to 8 lines. + #[serde(default)] + pub expr_lines: BTreeMap, /// Scale and Mask Feather properties whose chain link (Constrain Proportions, on by default) /// was turned off. #[serde(default)] @@ -462,6 +466,7 @@ impl Default for TimelineState { graph_reference: false, graph_transform_box: true, expr_closed: BTreeSet::new(), + expr_lines: BTreeMap::new(), unlinked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index d15b05ec..2562f64e 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -211,6 +211,57 @@ fn the_pick_whip_scrolls_the_timeline_at_its_edges() { h.run_steps(3); } +/// #371: the inline expression field grows with the text being typed (it used to keep the +/// saved text's height until committed), and its bottom edge drags to any height in lines. +#[test] +fn the_expression_field_grows_while_editing_and_drags_taller() { + let (mut h, _) = harness(); + let (layer, opacity, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50"})).unwrap(); + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone(); + (l, tr.get("opacity").unwrap().uid, tr.uid) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.run_steps(3); + let id = format!("timeline.prop.{opacity}.expression"); + let height = |h: &Harness<'_, EffectcraftApp>| h.state().auto.find(&id).unwrap().rect[3]; + let line = h.state().tokens.row_h; + let one = height(&h); + let p = center(&h, &id); + click(&mut h, p, 1); + key(&mut h, Key::End); + for more in ["+ 1", "+ 2"] { + key(&mut h, Key::Enter); + type_text(&mut h, more); + } + h.run_steps(2); + assert!((height(&h) - one - 2.0 * line).abs() < 0.5, "three lines while editing: {} from {one}", height(&h)); + // Its bottom edge: dragged down 4 lines, then up past the top (one line). + let edge_id = format!("timeline.prop.{opacity}.expressionHeight"); + let drag_edge = |h: &mut Harness<'_, EffectcraftApp>, dy: f32| { + let edge = center(h, &edge_id); + let to = edge + egui::vec2(0.0, dy); + h.input_mut().events.push(Event::PointerMoved(edge)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: edge, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(edge + (to - edge) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + drag_edge(&mut h, 4.0 * line); + assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&7)); + assert!((height(&h) - one - 6.0 * line).abs() < 0.5, "{}", height(&h)); + drag_edge(&mut h, -20.0 * line); + assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&1)); +} + /// A click at `p` with `modifiers` held. fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) { h.input_mut().events.push(Event::PointerMoved(p)); diff --git a/docs/parity.md b/docs/parity.md index f8a75dca..9f3ee66d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -166,7 +166,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | 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) | -| 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; 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) | +| 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; 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) | | 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 | From 9c9e954ee26404508b1478559a3c515b665fc6fb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:36:43 +0700 Subject: [PATCH 6/7] Timeline: Tab moves to the next value field (#361) Tab in a value being typed (hot text) dropped the keyboard: egui moved the focus to the next widget, and the inline editors asked for the focus again every frame, which also reset what keys a field keeps for itself. As in After Effects, Tab now commits the value and types in the next value field of the same panel (the next dimension, then the next property's), Shift+Tab the previous one. The hot-text fields of each panel register in drawing order; the dock tags each panel's Ui so the order stays within it. `keep_focus` asks for the focus only when the field doesn't have it. Tests: tab_commits_a_value_and_moves_to_the_next (ui_inline_edit: Timeline and Effect Controls). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock_ui.rs | 4 +- crates/ui-egui/src/widgets.rs | 71 ++++++++++++++++++++++++-- crates/ui-egui/tests/ui_inline_edit.rs | 68 ++++++++++++++++++++++++ docs/parity.md | 2 +- 4 files changed, 138 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 9ba6ed55..ab8baace 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -159,7 +159,9 @@ impl EffectcraftApp { panels::precomp::navigator(self, &mut child, nav); content.min.y = nav.max.y; } - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id()))); + let mut child = ui.new_child( + egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())).ui_stack_info(egui::UiStackInfo::default().with_tag(crate::widgets::PANEL_TAG)), + ); child.set_clip_rect(content.intersect(ui.clip_rect())); panels::show(self, &mut child, p, content); if viewer && !self.ui.start_screen { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 003cb9ad..8f12e52b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -6,7 +6,49 @@ use crate::automation::Registry; use crate::icons::{self, Icon}; use crate::theme::Tokens; -/// "Hot text": a blue number you drag horizontally to scrub, click to type. +/// The [`egui::UiStackInfo`] tag of a dock panel's `Ui`: Tab moves between the hot-text fields +/// of one panel. +pub const PANEL_TAG: &str = "effectcraft.panel"; + +/// A panel's hot-text fields in drawing order (this pass's and the last one's), and a Tab +/// pressed while typing in one: (that field, Shift held, the pass). +#[derive(Clone, Default)] +struct TabOrder { + pass: u64, + current: Vec, + last: Vec, + pending: Option<(egui::Id, bool, u64)>, +} + +fn tab_order_id(ui: &Ui) -> egui::Id { + let panel = ui.stack().iter().find(|s| s.tags().contains(PANEL_TAG)).map_or(egui::Id::NULL, |s| s.id); + egui::Id::new(("hot-tab-order", panel)) +} + +/// Puts hot-text field `id` in its panel's Tab order. True when a Tab in the field before it +/// (Shift+Tab: after it) moves the typing here. +fn tab_stop(ui: &Ui, id: egui::Id) -> bool { + let key = tab_order_id(ui); + let pass = ui.ctx().cumulative_pass_nr(); + ui.data_mut(|d| { + let o = d.get_temp_mut_or_default::(key); + if o.pass != pass { + o.last = std::mem::take(&mut o.current); + o.pass = pass; + } + o.current.push(id); + let Some((from, back, at)) = o.pending else { return false }; + let i = o.last.iter().position(|f| *f == from); + let next = i.and_then(|i| if back { i.checked_sub(1) } else { i.checked_add(1) }).and_then(|i| o.last.get(i)).copied(); + if next == Some(id) || next.is_none() || pass > at.saturating_add(2) { + o.pending = None; + } + next == Some(id) + }) +} + +/// "Hot text": a blue number you drag horizontally to scrub, click to type. Tab / Shift+Tab +/// while typing commits and types in the panel's next / previous one, as in After Effects. /// Returns (response, Some(new value) when changed, drag finished). pub fn hot_number_at( ui: &mut Ui, @@ -20,16 +62,28 @@ pub fn hot_number_at( t: &Tokens, ) -> (Rect, Option, bool) { let editing_id = id.with("editing"); - let editing: Option = ui.data(|d| d.get_temp(editing_id)); + let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); + if tab_stop(ui, id) && editing.is_none() { + editing = Some(format!("{value:.decimals$}")); + } let text = format!("{value:.decimals$}{suffix}"); let galley = ui.painter().layout_no_wrap(text, Tokens::ui(12.0), t.hot_text); let rect = Rect::from_min_size(rect_min, galley.size() + vec2(4.0, 4.0)); if let Some(mut buf) = editing { let er = Rect::from_min_size(rect_min - vec2(2.0, 1.0), vec2((galley.size().x + 24.0).max(54.0), 18.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); - let r = child.add(egui::TextEdit::singleline(&mut buf).desired_width(er.width()).font(Tokens::ui(12.0)).margin(egui::Margin::symmetric(2, 1))); + // (Tab stays with the field: it moves to the next hot-text field, not the next widget.) + let r = child.add( + egui::TextEdit::singleline(&mut buf) + .id(id.with("text")) + .desired_width(er.width()) + .font(Tokens::ui(12.0)) + .margin(egui::Margin::symmetric(2, 1)) + .lock_focus(true), + ); + let tab = r.has_focus().then(|| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab).then_some(i.modifiers.shift))).flatten(); let mut out = None; - if r.lost_focus() { + if r.lost_focus() || tab.is_some() { let txt = buf.trim().trim_end_matches(suffix).trim().replace(',', "."); if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && let Ok(v) = txt.parse::() @@ -38,6 +92,11 @@ pub fn hot_number_at( out = Some(v.clamp(range.0, range.1)); } ui.data_mut(|d| d.remove::(editing_id)); + if let Some(back) = tab { + r.surrender_focus(); + let (key, pass) = (tab_order_id(ui), ui.ctx().cumulative_pass_nr()); + ui.data_mut(|d| d.get_temp_mut_or_default::(key).pending = Some((id, back, pass))); + } } else { keep_focus(ui.ctx(), &r, &buf); ui.data_mut(|d| d.insert_temp(editing_id, buf)); @@ -97,11 +156,13 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s /// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it /// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value. +/// (Only then is the focus requested: a request resets what keys the field keeps for itself, +/// such as Tab with [`egui::TextEdit::lock_focus`].) pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) { if !ctx.memory(|m| m.has_focus(field.id)) { select_all(ctx, field.id, text); + field.request_focus(); } - field.request_focus(); } /// Select all of the text field `id`'s `text`, so typing replaces it. diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index 2562f64e..420e9635 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -262,6 +262,74 @@ fn the_expression_field_grows_while_editing_and_drags_taller() { assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&1)); } +fn key_with(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +/// #361: Tab in a value being typed commits it and types in the next value (the next dimension, +/// then the next property's), Shift+Tab the previous, in the Timeline and in Effect Controls, +/// as in After Effects. It used to drop the keyboard instead. +#[test] +fn tab_commits_a_value_and_moves_to_the_next() { + let (mut h, _) = harness(); + let (layer, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + for fx in ["Point Control", "Slider Control"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().uid; + (l, tr) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::EffectControls); + h.run_steps(3); + let props = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone(); + let value = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().value.components(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid; + let (position, scale) = (uid(&h, "transform/position"), uid(&h, "transform/scale")); + let none = Modifiers::NONE; + // Timeline: Position X, Tab, Position Y, Tab, Scale (linked). + let p = center(&h, &format!("timeline.prop.{position}.value.0")); + click(&mut h, p, 1); + type_text(&mut h, "100"); + key_with(&mut h, Key::Tab, none); + type_text(&mut h, "50"); + key_with(&mut h, Key::Tab, none); + type_text(&mut h, "200"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "transform/position")[..2], [100.0, 50.0]); + assert_eq!(value(&h, "transform/scale")[..2], [200.0, 200.0]); + assert!(!h.ctx.egui_wants_keyboard_input(), "Enter ends the typing"); + // Shift+Tab: from Scale X back to Position Y. + let p = center(&h, &format!("timeline.prop.{scale}.value.0")); + click(&mut h, p, 1); + type_text(&mut h, "120"); + key_with(&mut h, Key::Tab, Modifiers::SHIFT); + type_text(&mut h, "60"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "transform/position")[..2], [100.0, 60.0]); + assert_eq!(value(&h, "transform/scale")[..2], [120.0, 120.0]); + // Effect Controls: Point Control's Y, Tab, Slider Control's Slider (the next effect). + let point = uid(&h, "effects/#1/point"); + let p = center(&h, &format!("effectControls.prop.{point}.value.1")); + click(&mut h, p, 1); + type_text(&mut h, "7"); + key_with(&mut h, Key::Tab, none); + type_text(&mut h, "42"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "effects/#1/point")[1], 7.0); + assert_eq!(value(&h, "effects/#2/slider"), [42.0]); +} + /// A click at `p` with `modifiers` held. fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) { h.input_mut().events.push(Event::PointerMoved(p)); diff --git a/docs/parity.md b/docs/parity.md index 9f3ee66d..64fddba9 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; 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; 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) | From 79bf5a6b594b4696b7348423734653d114ff98ef Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:37:19 +0700 Subject: [PATCH 7/7] Timeline: property names have the property menu (#407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a property's name in the Timeline did nothing, so Position's dimensions could be separated only from the Graph Editor. As in After Effects, it now opens the property's menu: Reset, Edit Value…, Separate Dimensions (Position and, once separated, X / Y / Z Position, checked), Add / Remove Expression and Add Property to Essential Graphics. Only Position separates, as in After Effects (Scale stays one property). `prop.reset` without `default` used to leave the value as it was: it now gives the property its own default (an effect parameter's, or a Transform property's, shared with Layer ▸ Transform ▸ Reset), which also makes the Properties panel's Reset reset the values. `prop.set {layer, prop}` without `value` opens the value dialog, like `keys.set`. Tests: timeline_property_names_have_the_property_menu (ui_blank_context_menus), reset_gives_a_property_its_default (engine). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 28 ++++++-- crates/engine/src/commands/prop.rs | 39 ++++++++++- crates/engine/src/tests_timeline.rs | 37 +++++++++++ crates/ui-egui/src/panels/forms.rs | 26 ++++++-- crates/ui-egui/src/panels/graph.rs | 7 +- crates/ui-egui/src/panels/timeline.rs | 38 +++++++++++ .../ui-egui/tests/ui_blank_context_menus.rs | 66 ++++++++++++++++++- docs/agents.md | 6 ++ docs/parity.md | 2 +- 9 files changed, 225 insertions(+), 24 deletions(-) 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) |