From 2d4a0902eaacbed300bf8a960d59d40760a8392a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:05:17 +0700 Subject: [PATCH 01/10] M12.8: the app no longer freezes every 10 s on Windows: the system memory is read in the background Every 10 seconds the UI thread re-read the system's memory for the cache budgets, and on Windows that starts PowerShell (`Get-CimInstance Win32_OperatingSystem`) and waits for it: about a second, sometimes two, during which the whole app froze. The reading now runs on a background thread (`sysinfo::MemoryWatch`); `Session::memory_tick` takes the last reading and starts the next one, never waiting. Console programs started for it no longer open a console window (`CREATE_NO_WINDOW`). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 3 ++ crates/engine/src/session_settings.rs | 8 +++-- crates/engine/src/sysinfo.rs | 49 +++++++++++++++++++++++++-- crates/engine/src/tests_settings.rs | 22 ++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ca93f29b..3313543a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -373,6 +373,8 @@ pub struct Session { /// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown /// (headless sessions don't query it, see [`Session::memory_tick`]). pub sys_memory: Option, + /// Reads the system's memory in the background for [`Session::memory_tick`]. + pub memory_watch: sysinfo::MemoryWatch, /// Switches Affect Nested Comps as last applied (a change empties the layer cache). pub applied_nested_switches: Option, /// Runs renders and analyses off the UI thread where there are no threads (the web app's @@ -465,6 +467,7 @@ impl Default for Session { plugin_loader: None, script_ui: scriptui::ScriptUi::default(), sys_memory: None, + memory_watch: Default::default(), applied_nested_switches: None, offload: None, offloaded: vec![], diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index e19d0701..4afd9691 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -75,11 +75,13 @@ impl Session { self.footage.set_cache_budget(b.media); } - /// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply - /// the cache budgets when the reading changes them. Returns the effective budgets. + /// Take the latest reading of the system's memory and start the next one in the background + /// (desktop frontends call this every few seconds; it never waits for the system), then + /// re-apply the cache budgets when the reading changes them. Returns the effective budgets. pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets { let before = self.prefs.cache_budgets(self.sys_memory); - self.sys_memory = crate::sysinfo::memory().or(self.sys_memory); + self.sys_memory = self.memory_watch.latest().or(self.sys_memory); + self.memory_watch.poll(); let after = self.prefs.cache_budgets(self.sys_memory); if after != before { self.apply_cache_budgets(); diff --git a/crates/engine/src/sysinfo.rs b/crates/engine/src/sysinfo.rs index 0c79c2a1..8e26e70a 100644 --- a/crates/engine/src/sysinfo.rs +++ b/crates/engine/src/sysinfo.rs @@ -4,7 +4,11 @@ //! Pure Rust and dependency-free: the values come from the operating system's own reporting //! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every //! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their -//! configured budgets. +//! configured budgets. The periodic reading behind the cache budgets runs on a background +//! thread ([`MemoryWatch`]): on Windows it starts PowerShell, which takes about a second. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use serde::Serialize; @@ -23,10 +27,51 @@ impl SysMemory { } } +/// The system's memory, read in the background: [`MemoryWatch::poll`] starts a reading (unless +/// one is running) and [`MemoryWatch::latest`] returns the last one without waiting. +#[derive(Clone, Default)] +pub struct MemoryWatch { + latest: Arc>>, + busy: Arc, +} + +impl MemoryWatch { + /// Start a new reading in the background (no threads on the web: read inline, it is + /// unknown there anyway). + pub fn poll(&self) { + if self.busy.swap(true, Ordering::AcqRel) { + return; + } + let (latest, busy) = (self.latest.clone(), self.busy.clone()); + let read = move || { + if let Some(m) = memory() { + *latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m); + } + busy.store(false, Ordering::Release); + }; + #[cfg(not(target_arch = "wasm32"))] + if std::thread::Builder::new().name("ec-memory".into()).spawn(read).is_err() { + self.busy.store(false, Ordering::Release); + } + #[cfg(target_arch = "wasm32")] + read(); + } + + /// The last reading (`None` until one finished). + pub fn latest(&self) -> Option { + *self.latest.lock().unwrap_or_else(PoisonError::into_inner) + } +} + #[cfg(not(target_arch = "wasm32"))] #[allow(dead_code)] fn run(cmd: &str, args: &[&str]) -> Option { - let out = std::process::Command::new(cmd).args(args).output().ok()?; + let mut c = std::process::Command::new(cmd); + c.args(args); + // A console program started from the windowed app would flash a console window. + #[cfg(target_os = "windows")] + std::os::windows::process::CommandExt::creation_flags(&mut c, 0x0800_0000); // CREATE_NO_WINDOW + let out = c.output().ok()?; out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string()) } diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 50fe186d..e1820578 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -464,3 +464,25 @@ fn open_recent_increment_and_revert() { assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj"); let _ = std::fs::remove_dir_all(d); } + +#[test] +fn memory_tick_never_waits_for_the_system() { + let mut s = Session::default(); + let t0 = std::time::Instant::now(); + s.memory_tick(); + s.memory_tick(); + // Reading the system's memory starts PowerShell on Windows (about a second): the UI thread + // only takes the last reading. + assert!(t0.elapsed() < std::time::Duration::from_millis(200), "{:?}", t0.elapsed()); + if crate::sysinfo::memory().is_some() { + let mut got = None; + for _ in 0..200 { + got = s.memory_watch.latest(); + if got.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(got.is_some_and(|m| m.total > 0), "the reading arrives in the background"); + } +} From f3db1ebe380e2a99b29cf435ecf06c16afceaa24 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:14:02 +0700 Subject: [PATCH 02/10] M5.9: keyframe drags in the Timeline follow the pointer to the end; Shift snaps Dragging a keyframe moved it a frame or two and then let go: the key's widget id includes its time, so after the first move it was a new widget and egui ended the drag. The Timeline now follows the whole gesture itself (`KeyDrag`: from where the button went down, by whole frames, every selected key, one undo step), like After Effects. Holding Shift snaps the grabbed key to the current time, the work area and composition markers. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 66 +++++++++++++++++----- crates/ui-egui/tests/ui_keyframes.rs | 81 +++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/tests/ui_keyframes.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 28b10af3..aba24f17 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -779,6 +779,19 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon fn layer_drag_id() -> egui::Id { egui::Id::new("tl-layer-drag") } + +/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time +/// (seconds) and how many frames the selected keys have moved so far. +#[derive(Clone, Copy, Debug)] +struct KeyDrag { + origin_x: f32, + anchor: f64, + applied: i64, +} + +fn key_drag_id() -> egui::Id { + egui::Id::new("tl-key-drag") +} /// Set on the frame the reorder drag is released. fn layer_drop_id() -> egui::Id { egui::Id::new("tl-layer-drop") @@ -1934,21 +1947,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { { super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds()); } - let alt_scaling = super::graph_tools::alt_scale_active(&ctx); - if kresp.dragged() && !alt_scaling { - let acc_id = egui::Id::new("key-drag-acc"); - let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); - acc += kresp.drag_delta().x; - let frames = ((acc as f64 / pps) / fd).trunc() as i64; - if frames != 0 { - acc -= (frames as f64 * fd * pps) as f32; - actions.push(("keys.move".into(), json!({"delta": frames as f64 * fd, "merge": "key-drag"}))); - } - ctx.data_mut(|d| d.insert_temp(acc_id, acc)); - } - if kresp.drag_stopped() { - ctx.data_mut(|d| d.remove::(egui::Id::new("key-drag-acc"))); - ui_actions.push(UiAct::EndMerge); + // A plain drag moves the selected keys. The timeline follows it (below): this + // widget's id changes with the key's time, so its own drag would end after + // the first frame of movement. + // (From where the button went down: a drag starts a few pixels later.) + if kresp.drag_started() + && !super::graph_tools::alt_scale_active(&ctx) + && let Some(p) = ctx.input(|i| i.pointer.press_origin()) + { + ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0 })); } } } @@ -2031,6 +2038,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.remove::>(layer_drag_id())); } } + // Keyframe drag: the selected keys move by whole frames with the pointer until it is + // released (one undo step); Shift snaps the grabbed key to the current time, the work area + // and composition markers. + if let Some(mut kd) = ctx.data(|d| d.get_temp::(key_drag_id())) { + let (down, ptr, shift) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers.shift)); + match ptr.filter(|_| down) { + Some(ptr) => { + let mut target = kd.anchor + (ptr.x - kd.origin_x) as f64 / pps; + if shift { + let snaps = [time, comp.work_area.0, comp.work_area.1].into_iter().chain(comp.markers.iter().map(|m| m.time)).map(Tick::seconds); + let reach = 8.0 / pps; + if let Some(s) = snaps.filter(|s| (s - target).abs() <= reach).min_by(|a, b| (a - target).abs().total_cmp(&(b - target).abs())) { + target = s; + } + } + let total = ((target - kd.anchor) / fd).round() as i64; + if total != kd.applied { + actions.push(("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"}))); + kd.applied = total; + ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd)); + } + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + } + None => { + ctx.data_mut(|d| d.remove::(key_drag_id())); + ui_actions.push(UiAct::EndMerge); + } + } + } if empty.drag_stopped() && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::new("tl-box"))) { diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs new file mode 100644 index 00000000..6fc844d2 --- /dev/null +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -0,0 +1,81 @@ +//! Headless checks for Timeline keyframes as a person uses them: dragging keys over many frames +//! in one gesture (the drag used to end after the first frame), Shift-snapping, and Ctrl+C / +//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Pos2, pos2}; +use egui_kittest::Harness; +use serde_json::json; + +/// A 4 s, 30 fps comp with a Box layer whose Opacity has keys at 0 s and 1 s, revealed in the +/// Timeline; returns the harness, the layer and the property uid. +fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Keys", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap(); + let id = s.active_comp().unwrap().layers[0].id; + for (t, v) in [(0.0, 0.0), (1.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let uid = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().uid; + s.execute("layer.select", json!({"layers": [id.0]})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap(); + h.run_steps(4); + (h, id, uid) +} + +fn key_times(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Vec { + let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone(); + l.props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect() +} + +/// Screen centres of the property's keys, left to right. +fn keys(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec { + let mut ks: Vec = + h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect(); + ks.sort_by(|a, b| a.x.total_cmp(&b.x)); + ks +} + +/// Press at `from`, move to `to` in small steps (a real drag), release. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + for i in 1..=24 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 24.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.input_mut().events.push(Event::ModifiersChanged(Default::default())); + h.run_steps(2); +} + +#[test] +fn a_key_drag_follows_the_pointer_for_its_whole_length() { + let (mut h, id, uid) = harness(); + let ks = keys(&h, uid); + assert_eq!(ks.len(), 2); + let px_per_s = ks[1].x - ks[0].x; + // Drag the 1 s key to where 2.5 s is: many frames, one gesture. + let to = pos2(ks[1].x + px_per_s * 1.5, ks[1].y); + drag(&mut h, ks[1], to, Default::default()); + let ts = key_times(&h, id); + assert!((ts[1] - 2.5).abs() <= 2.0 / 30.0, "the key follows the whole drag: {ts:?}"); + let steps = h.state().session.history.undo.iter().filter(|(l, _)| l == "Move Keyframes").count(); + assert_eq!(steps, 1, "one undo step per drag"); + // Shift snaps it to the current time indicator (at 0.5 s), from a few pixels away. + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(0.5)); + h.run_steps(2); + let ks = keys(&h, uid); + let near = pos2(ks[0].x + px_per_s * 0.5 + 4.0, ks[1].y); + drag(&mut h, ks[1], near, egui::Modifiers { shift: true, ..Default::default() }); + assert_eq!(key_times(&h, id)[1], 0.5, "snapped to the current time"); +} From e3778b1afd7db1fb650f7b082c6726e93036cde4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:15:22 +0700 Subject: [PATCH 03/10] =?UTF-8?q?M3.15:=20Ctrl+C=20/=20Ctrl+X=20/=20Ctrl+V?= =?UTF-8?q?=20reach=20Edit=20=E2=96=B8=20Copy,=20Cut=20and=20Paste=20in=20?= =?UTF-8?q?the=20real=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windowing layer delivers Ctrl+C, Ctrl+X and Ctrl+V as clipboard events instead of key presses, and the shortcut dispatcher only read key presses: in the app, copying and pasting keyframes, layers and effects with the keyboard did nothing (tests passed because they inject key presses). Clipboard events now map back to their keys with the modifiers held (so Ctrl+Alt+C and the other variants work too), except while text is edited in the viewer, which handles them itself. Ctrl+V sends no event while the system clipboard is empty, so copying now also puts a line naming what was copied on it ("EffectCraft: 3 keyframes"). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 31 ++++++++++++++++++++++++++ crates/ui-egui/tests/ui_keyframes.rs | 33 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3b2ff265..68d62b58 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -497,10 +497,32 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V } /// Run an engine command and perform the frontend events it emitted right away. +/// Commands that fill the internal clipboard. +const COPY_COMMANDS: &[&str] = + &["edit.copy", "edit.cut", "edit.copyWithPropertyLinks", "edit.copyWithRelativePropertyLinks", "edit.copyExpressionOnly", "keys.copy", "effect.copy"]; + +/// A line for the system clipboard naming what EffectCraft copied. The system clipboard must +/// hold something: with nothing on it, the windowing layer sends no paste event for Ctrl+V. +fn clipboard_note(s: &effectcraft_engine::Session) -> String { + let st = &s.state; + let n = |n: usize, one: &str, many: &str| format!("EffectCraft: {n} {}", if n == 1 { one } else { many }); + if st.clip_is_keys && !st.key_clipboard.is_empty() { + n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes") + } else if !st.effect_clipboard.is_empty() { + n(st.effect_clipboard.len(), "effect", "effects") + } else if st.link_clipboard.is_some() { + "EffectCraft: property links".into() + } else { + n(st.clipboard.len(), "layer", "layers") + } +} + fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result { let r = app.session.execute(id, params).map_err(|e| e.to_string()); if let Err(e) = &r { app.ui.status = e.clone(); + } else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() { + ctx.copy_text(clipboard_note(&app.session)); } let events = app.session.drain_events(); for ev in events { @@ -1217,7 +1239,13 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { if ctx.egui_wants_keyboard_input() { return; } + // Text editing in the viewer takes the clipboard events itself. + let clipboard = app.session.state.text_edit.is_none(); let events: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| { + // The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of + // key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut, + // Paste and their variants (Ctrl+Alt+C…) run. + let held = if i.modifiers.command { i.modifiers } else { egui::Modifiers::COMMAND }; i.events .iter() .filter_map(|e| match e { @@ -1226,6 +1254,9 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { { Some((*key, *modifiers)) } + egui::Event::Copy if clipboard => Some((egui::Key::C, held)), + egui::Event::Cut if clipboard => Some((egui::Key::X, held)), + egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held)), _ => None, }) .collect() diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 6fc844d2..4f73d2be 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -79,3 +79,36 @@ fn a_key_drag_follows_the_pointer_for_its_whole_length() { drag(&mut h, ks[1], near, egui::Modifiers { shift: true, ..Default::default() }); assert_eq!(key_times(&h, id)[1], 0.5, "snapped to the current time"); } + +#[test] +fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() { + let (mut h, id, uid) = harness(); + let ks = keys(&h, uid); + // Click the 1 s key to select it, Ctrl+C (a clipboard event, not a key press). + h.input_mut().events.push(Event::PointerMoved(ks[1])); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert_eq!(h.state().session.state.selected_keys.len(), 1); + h.input_mut().events.push(Event::Copy); + h.step(); + let copied: Vec = h + .output() + .platform_output + .commands + .iter() + .filter_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + .collect(); + assert_eq!(copied, ["EffectCraft: 1 keyframe"], "the system clipboard is filled, so Ctrl+V sends a paste event"); + assert!(h.state().session.state.clip_is_keys); + // Move to 3 s, Ctrl+V: the key lands there. + h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); + h.input_mut().events.push(Event::Paste("EffectCraft: 1 keyframe".into())); + h.run_steps(2); + assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]); +} From 3b6b390f681b7d162318456a0cc6929855011f06 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:25:01 +0700 Subject: [PATCH 04/10] M12.8: read the system memory every 30 s instead of 10 s Each reading starts PowerShell on Windows (in the background since the previous commit); the cache budgets only need to follow memory pressure, not track it closely. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/prefs_live.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index 9e730196..9dd4d639 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -41,12 +41,13 @@ fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) { } } -/// Re-read the system's free memory every few seconds (cache budgets follow it). +/// Re-read the system's free memory every 30 s (cache budgets follow it; on Windows each +/// reading starts PowerShell in the background, so it isn't done more often). fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); let id = egui::Id::new("prefs-memory-watch"); let last = ctx.data(|d| d.get_temp::(id)); - if last.is_some_and(|t| now - t < 10.0) { + if last.is_some_and(|t| now - t < 30.0) { return; } ctx.data_mut(|d| d.insert_temp(id, now)); From 9929b5f9ace3ec317873c16747405fbd85090735 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:40:42 +0700 Subject: [PATCH 05/10] M5.10: keyframes behave like After Effects: drags never destroy keys, stretched layers, Shift/Ctrl/Ctrl+Alt clicks, locked layers, Hold off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A drag (keys.move steps sharing a merge key) applies its total offset to the keys as they were before it (`Session::key_move`, `prop::shift_keys`): dragging over another key of the property no longer deletes it; only the drop position replaces a key, as in After Effects. - Keys of time-stretched layers move with the pointer: offsets are applied in comp time and snapped to comp frames (drags and Alt+Arrow nudges; they moved by the layer-time distance). - Shift+click toggles a key in or out of the selection (`keys.select {toggle}`); Ctrl+click switches Linear ↔ Auto Bezier and Ctrl+Alt+click toggles Hold. - Keys of locked layers can't be selected (clicks, marquee, Select All Keyframes), so nothing edits them. - Toggle Hold off returns an eased key to Bezier (it always restored Linear). - Shift-dragging a key snaps to everything the current time snaps to — other keys, layer in / out points, layer and comp markers, the work area — and a tooltip shows the key's time and how many frames it moved. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/anim.rs | 11 ++- crates/engine/src/commands/prop.rs | 103 +++++++++++++++++++++++--- crates/engine/src/lib.rs | 14 ++++ crates/engine/src/tests_menu_cmds.rs | 75 +++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 52 +++++++++---- crates/ui-egui/tests/ui_keyframes.rs | 35 +++++++++ 6 files changed, 262 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 0fbef4bb..3d6e3ac2 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid}; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::prop::{edit_keys, prop_ref}; +use super::prop::prop_ref; use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p}; use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query}; @@ -145,6 +145,8 @@ fn select_all(s: &mut Session, p: &Value) -> Result { l.props.walk("", &mut |_, pr| sel.extend(pr.keys.iter().map(|k| KeyRef { layer: l.id, prop: pr.uid, time: k.time }))); } } + // Keys of locked layers can't be selected. + sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked)); s.state.selected_keys = sel; Ok(json!(s.state.selected_keys.len())) } @@ -153,11 +155,8 @@ fn select_all(s: &mut Session, p: &Value) -> Result { fn nudge(s: &mut Session, p: &Value) -> Result { let frames = p.get("frames").and_then(Value::as_i64).ok_or_else(|| bad("keys.nudge", "missing `frames`"))?; let comp = s.active_comp().ok_or(EngineError::NoComp)?; - let d = Tick(comp.frame_duration().0 * frames); - edit_keys(s, "Nudge Keyframes", merge_p(p), move |_, _, t| { - *t += d; - true - }) + let d = Tick(comp.frame_duration().0.saturating_mul(frames)); + super::prop::shift_keys(s, "Nudge Keyframes", merge_p(p), d) } /// Edit one key directly: move it in time and/or set its value (Graph Editor drags). diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index bf74400c..bff27e77 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -335,7 +335,19 @@ fn select_keys(s: &mut Session, p: &Value) -> Result { sel.push(KeyRef { layer: l, prop: pr.uid, time: kt }); } } - if b_p(p, "add").unwrap_or(false) { + // Keys of locked layers can't be selected (so nothing edits them). + sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked)); + if b_p(p, "toggle").unwrap_or(false) { + // Shift+click: in and out of the selection. + for k in sel { + match s.state.selected_keys.iter().position(|x| *x == k) { + Some(i) => { + s.state.selected_keys.remove(i); + } + None => s.state.selected_keys.push(k), + } + } + } else if b_p(p, "add").unwrap_or(false) { for k in sel { if !s.state.selected_keys.contains(&k) { s.state.selected_keys.push(k); @@ -400,14 +412,72 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im }) } +/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys +/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of +/// locked layers stay. A key landing on another key of its property replaces it. +pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + // A drag (steps with one merge key) starts again from the keys before it each step. + let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge; + let (from, total, base) = match (&s.key_move, s.history.undo.last()) { + (Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())), + _ => (s.state.selected_keys.clone(), d, None), + }; + s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); + s.edit(label, merge, |proj, st| { + if let Some(b) = base { + *proj = (*b).clone(); + } + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in &from { + groups.entry((k.layer, k.prop)).or_default().push(k.time); + } + let mut sel = vec![]; + for ((lid, uid), times) in groups { + let Some(l) = comp.layer_mut(lid) else { continue }; + let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total)); + let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect(); + if l.switches.locked { + sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t })); + continue; + } + let Some(pr) = l.props.find_mut(uid) else { continue }; + // Take the moving keys out, then put them back at their new times. + let mut moved = vec![]; + for (old, new) in &moves { + if let Some(i) = key_at(&pr.keys, *old) { + let mut k = pr.keys.remove(i); + k.time = *new; + moved.push(k); + } + } + for k in moved { + sel.push(KeyRef { layer: lid, prop: uid, time: k.time }); + match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { + Ok(j) => pr.keys[j] = k, + Err(j) => pr.keys.insert(j, k), + } + } + // Roving keys follow their neighbours: keep the selection on them. + let before: Vec = pr.keys.iter().map(|k| k.time).collect(); + if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) { + for r in sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) { + if let Some(i) = before.iter().position(|t| *t == r.time) { + r.time = pr.keys[i].time; + } + } + } + } + st.selected_keys = sel; + Ok(json!(st.selected_keys.len())) + }) +} + fn move_keys(s: &mut Session, p: &Value) -> Result { let d = Tick::from_seconds_f64(f_p(p, "delta").ok_or_else(|| bad("keys.move", "missing `delta` (seconds)"))?); - let fr = s.active_comp().map(|c| c.frame_rate); - edit_keys(s, "Move Keyframes", merge_p(p), move |_, _, t| { - let nt = *t + d; - *t = fr.map(|r| r.snap_nearest(nt)).unwrap_or(nt); - true - }) + shift_keys(s, "Move Keyframes", merge_p(p), d) } fn delete_keys(s: &mut Session, _: &Value) -> Result { @@ -543,7 +613,14 @@ fn interpolation(s: &mut Session, p: &Value) -> Result { fn toggle_hold(s: &mut Session, _: &Value) -> Result { edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| { let k = &mut keys[i]; - k.out_interp = if k.out_interp == Interp::Hold { Interp::Linear } else { Interp::Hold }; + // Off again: back to Bezier when the key eases (its other side, auto / continuous, or + // an outgoing ease), else Linear. + let bezier = k.auto_bezier || k.continuous || k.in_interp == Interp::Bezier || !k.out_ease.is_empty(); + k.out_interp = match k.out_interp { + Interp::Hold if bezier => Interp::Bezier, + Interp::Hold => Interp::Linear, + _ => Interp::Hold, + }; false }) } @@ -667,7 +744,15 @@ pub fn specs() -> Vec { has_layers, convert_expr_to_keys ), - cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?}", has_comp, select_keys), + cmd!( + "keys.select", + "Select Keyframes", + [], + None, + "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection)}", + has_comp, + select_keys + ), cmd!("keys.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys), cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_keys), cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3313543a..f8abaa08 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -134,6 +134,17 @@ pub struct KeyRef { pub time: Tick, } +/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it +/// started from and the total offset so far, so every step is applied to the keys as they were +/// before the drag. A key passed over on the way is never overwritten; only the drop position +/// counts, as in After Effects. +#[derive(Clone, Debug)] +pub struct KeyMove { + pub merge: String, + pub from: Vec, + pub total: Tick, +} + /// Keyframes of one property on the keyframe clipboard. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct KeyClip { @@ -373,6 +384,8 @@ pub struct Session { /// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown /// (headless sessions don't query it, see [`Session::memory_tick`]). pub sys_memory: Option, + /// The keyframe drag in progress (`keys.move` with a merge key). + pub key_move: Option, /// Reads the system's memory in the background for [`Session::memory_tick`]. pub memory_watch: sysinfo::MemoryWatch, /// Switches Affect Nested Comps as last applied (a change empties the layer cache). @@ -467,6 +480,7 @@ impl Default for Session { plugin_loader: None, script_ui: scriptui::ScriptUi::default(), sys_memory: None, + key_move: None, memory_watch: Default::default(), applied_nested_switches: None, offload: None, diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 5168e46c..d59bf61c 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -863,3 +863,78 @@ fn new_comp_from_selection_survives_hostile_sequence_numbers() { s.state.project_selection = vec![a, b]; assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 99})).is_err()); } + +fn key_times(s: &Session, l: u64) -> Vec { + layer(s, l).props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect() +} + +fn opacity_keys(s: &mut Session, l: u64, times: &[f64]) { + for (i, t) in times.iter().enumerate() { + s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": i as f64 * 10.0})).unwrap(); + } +} + +#[test] +fn dragging_a_key_past_another_keeps_it() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + opacity_keys(&mut s, a, &[1.0, 2.0]); + s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + // One drag, frame by frame, from 1 s over the 2 s key to 3 s. + for _ in 0..60 { + s.execute("keys.move", json!({"delta": 1.0 / 30.0, "merge": "key-drag"})).unwrap(); + } + assert_eq!(key_times(&s, a), vec![2.0, 3.0], "the 2 s key passed over is still there"); + // Dropped exactly on a key: it replaces it, as in After Effects. + s.history.merge_key = None; + s.execute("keys.move", json!({"delta": -1.0, "merge": "key-drag-2"})).unwrap(); + assert_eq!(key_times(&s, a), vec![2.0]); + // The whole drag was one undo step. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(key_times(&s, a), vec![1.0, 2.0]); +} + +#[test] +fn keys_of_a_stretched_layer_move_with_the_pointer() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + opacity_keys(&mut s, a, &[0.0, 1.0]); + s.execute_checked("layer.timeStretch", json!({"layers": [a], "percent": 200})).unwrap(); + assert_eq!(layer(&s, a).stretch, 200.0); + let comp_time = |s: &Session, i: usize| { + let l = layer(s, a); + l.comp_time(l.props.prop("transform/opacity").unwrap().keys[i].time).seconds() + }; + let before = comp_time(&s, 1); + s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); + s.execute("keys.move", json!({"delta": 0.5})).unwrap(); + assert!((comp_time(&s, 1) - (before + 0.5)).abs() < 1.0 / 30.0, "{} → {}", before, comp_time(&s, 1)); + s.execute("keys.nudge", json!({"frames": 3})).unwrap(); + assert!((comp_time(&s, 1) - (before + 0.6)).abs() < 1.0 / 60.0, "three comp frames"); +} + +#[test] +fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + opacity_keys(&mut s, a, &[0.0, 1.0]); + let k = |t: f64| json!({"layer": a, "path": "transform/opacity", "time": t}); + s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap(); + s.execute("keys.select", json!({"keys": [k(1.0)], "toggle": true})).unwrap(); + assert_eq!(s.state.selected_keys.len(), 2); + s.execute("keys.select", json!({"keys": [k(0.0)], "toggle": true})).unwrap(); + assert_eq!(s.state.selected_keys.len(), 1, "toggled out"); + // Hold off returns an eased key to Bezier. + s.execute("keys.easyEase", json!({})).unwrap(); + s.execute("keys.toggleHold", json!({})).unwrap(); + s.execute("keys.toggleHold", json!({})).unwrap(); + let kf = layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].clone(); + assert_eq!(kf.out_interp, effectcraft_keyframe::Interp::Bezier); + // Locked: its keys can't be selected. + s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "value": true})).unwrap(); + s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap(); + assert!(s.state.selected_keys.is_empty()); + s.execute("keys.selectAll", json!({"layers": [a]})).unwrap(); + assert!(s.state.selected_keys.is_empty()); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index aba24f17..ee6a3e89 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -7,7 +7,7 @@ //! bar, layer duration bars, keyframes, the CTI, and the value graph editor. use effectcraft_engine::color::BlendMode; -use effectcraft_engine::keyframe::Value; +use effectcraft_engine::keyframe::{Interp, Value}; use effectcraft_engine::project::{Comp, GroupKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property}; use effectcraft_engine::render::EvalCtx; use effectcraft_engine::time::Tick; @@ -1874,14 +1874,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let kr = Rect::from_center_size(c, vec2(12.0, 14.0)); let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag()); app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name)); - if kresp.clicked() || kresp.drag_started() { - let shift = ui.input(|i| i.modifiers.shift); - if !ks || shift { - actions.push(( - "keys.select".into(), - json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "add": shift}), - )); + let mods = ui.input(|i| i.modifiers); + let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}); + if kresp.clicked() && mods.command { + // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off. + actions.push(("keys.select".into(), this_key.clone())); + if mods.alt { + actions.push(("keys.toggleHold".into(), json!({}))); + } else { + let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear; + actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }}))); } + } else if kresp.clicked() && mods.shift { + // Shift+click: in or out of the selection. + let mut p = this_key.clone(); + p["toggle"] = json!(true); + actions.push(("keys.select".into(), p)); + } else if (kresp.clicked() || kresp.drag_started()) && !ks { + let mut p = this_key.clone(); + p["add"] = json!(mods.shift); + actions.push(("keys.select".into(), p)); } if kresp.double_clicked() { ui_actions.push(UiAct::SetTime(ct)); @@ -2047,11 +2059,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { Some(ptr) => { let mut target = kd.anchor + (ptr.x - kd.origin_x) as f64 / pps; if shift { - let snaps = [time, comp.work_area.0, comp.work_area.1].into_iter().chain(comp.markers.iter().map(|m| m.time)).map(Tick::seconds); - let reach = 8.0 / pps; - if let Some(s) = snaps.filter(|s| (s - target).abs() <= reach).min_by(|a, b| (a - target).abs().total_cmp(&(b - target).abs())) { - target = s; - } + // The current time and whatever it snaps to (keys, layer ends, markers), but + // not the moving keys themselves. + let moving: Vec = + app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect(); + let mut cands = snap_candidates(&comp, &rows); + cands.retain(|c| !moving.iter().any(|m| (m - c).abs() < 1e-9)); + cands.push(time.seconds()); + target = snap_time(&cands, target, 8.0 / pps); } let total = ((target - kd.anchor) / fd).round() as i64; if total != kd.applied { @@ -2060,6 +2075,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd)); } ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + // Where the grabbed key is and how far the keys moved (After Effects shows this + // in the Info panel). + let at = Tick::from_seconds_f64(kd.anchor + kd.applied as f64 * fd); + let note = format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied); + let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip"))); + let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text); + let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0)); + fg.rect_filled(r, 3.0, t.panel_bg); + fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + fg.galley(r.min + vec2(5.0, 3.0), g, t.text); + app.ui.status = format!("Keyframe at {}", super::timecode(&app.session, &comp, at)); } None => { ctx.data_mut(|d| d.remove::(key_drag_id())); diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 4f73d2be..bcd0bb9b 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -54,6 +54,7 @@ fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: eg h.step(); } h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.step(); h.input_mut().events.push(Event::ModifiersChanged(Default::default())); h.run_steps(2); } @@ -112,3 +113,37 @@ fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() { h.run_steps(2); assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]); } + +fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: egui::Modifiers) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Default::default())); + h.run_steps(2); +} + +#[test] +fn shift_click_toggles_and_ctrl_click_switches_interpolation() { + let (mut h, id, uid) = harness(); + let ks = keys(&h, uid); + let shift = egui::Modifiers { shift: true, ..Default::default() }; + click_with(&mut h, ks[0], Default::default()); + click_with(&mut h, ks[1], shift); + assert_eq!(h.state().session.state.selected_keys.len(), 2, "Shift+click adds"); + click_with(&mut h, ks[1], shift); + assert_eq!(h.state().session.state.selected_keys.len(), 1, "and takes out again"); + let key = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[0].clone(); + // Ctrl+click: Linear → Auto Bezier → Linear. + click_with(&mut h, ks[0], egui::Modifiers::COMMAND); + assert!(key(&h).auto_bezier, "Auto Bezier"); + click_with(&mut h, ks[0], egui::Modifiers::COMMAND); + assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Linear); + // Ctrl+Alt+click: Hold. + click_with(&mut h, ks[0], egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND }); + assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Hold); +} From daed03305a055dcfeee7511da20420dc00ecefcd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:52:20 +0700 Subject: [PATCH 06/10] M5.11: Graph Editor: drags move every selected key, Shift keeps an axis, Ctrl / Ctrl+Alt clicks; drags and the transform box never merge or lose keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `keys.transform {fromStart}`: each step of a merged drag gives the whole transform since the drag started and applies to the keys as they were then. Keys squeezed onto one frame on the way (Alt-drag scaling, the transform box) are no longer merged for good, and a slow transform-box move, whose per-frame offsets rounded to nothing, now moves the keys. - Dragging a key in the Graph Editor moves every selected key in time and value (it moved only the grabbed one), by how far the pointer went from where the button went down (the key no longer jumps under the pointer); Shift keeps the drag to the time or the value axis. - Shift+click toggles a key in or out of the selection; Ctrl+click switches Linear ↔ Auto Bezier and Ctrl+Alt+click toggles Hold, as in the Timeline. - The transform box and the Timeline's Alt-drag scale send whole-gesture values. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/key_transform.rs | 25 +++++++-- crates/engine/src/tests_menu_cmds.rs | 19 +++++++ crates/ui-egui/src/panels/graph.rs | 58 +++++++++++++++------ crates/ui-egui/src/panels/graph_tools.rs | 30 ++++++----- crates/ui-egui/tests/ui_keyframes.rs | 31 +++++++++++ 5 files changed, 130 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/commands/key_transform.rs b/crates/engine/src/commands/key_transform.rs index 82037d52..b7843926 100644 --- a/crates/engine/src/commands/key_transform.rs +++ b/crates/engine/src/commands/key_transform.rs @@ -19,11 +19,25 @@ fn v3(v: Option<&Value>) -> Option<[f64; 3]> { /// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first /// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by /// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames. +/// With `fromStart` (drags), each step of one merged gesture gives the whole transform since the +/// drag started and applies to the keys as they were then: keys squeezed onto one frame on the +/// way, or rounded steps, are never lost; only the final position counts. fn transform(s: &mut Session, p: &Value) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let merge = merge_p(p); + let from_start = b_p(p, "fromStart").unwrap_or(false) && merge.is_some(); + let gesture = from_start && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge; + let (sel, base) = match (&s.key_move, s.history.undo.last()) { + (Some(g), Some((_, b))) if gesture => (g.from.clone(), Some(b.clone())), + _ => (s.state.selected_keys.clone(), None), + }; + if from_start { + s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: sel.clone(), total: Tick::ZERO }); + } + // The comp as it was when the drag started (its keys are what the transform applies to). + let start = base.clone().unwrap_or_else(|| s.project.clone()); + let comp = start.comp(cid).ok_or(EngineError::NoComp)?; let fr = comp.frame_rate; - let sel = s.state.selected_keys.clone(); let ts = f_p(p, "timeScale").unwrap_or(1.0); let toff = f_p(p, "timeOffset").unwrap_or(0.0); let vs = f_p(p, "valueScale").unwrap_or(1.0); @@ -46,7 +60,10 @@ fn transform(s: &mut Session, p: &Value) -> Result { fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0))) }; let touch_values = vs != 1.0 || voff != 0.0; - s.edit("Transform Keyframes", merge_p(p), |proj, st| { + s.edit("Transform Keyframes", merge, |proj, st| { + if let Some(b) = base { + *proj = (*b).clone(); + } let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); for k in &sel { @@ -136,7 +153,7 @@ pub fn specs() -> Vec { "Transform Keyframes", [], None, - "{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}", + "{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?, fromStart?: bool (with merge: values are the whole transform since the drag started)}", has_keys, transform ), diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index d59bf61c..1b7bc617 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -938,3 +938,22 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() { s.execute("keys.selectAll", json!({"layers": [a]})).unwrap(); assert!(s.state.selected_keys.is_empty()); } + +#[test] +fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + opacity_keys(&mut s, a, &[0.0, 1.0, 2.0]); + s.execute("keys.selectAll", json!({"layers": [a]})).unwrap(); + // Squeezed onto one frame on the way, then back: the three keys survive. + for k in [0.5, 0.01, 0.0001, 1.0] { + s.execute("keys.transform", json!({"timeScale": k, "timeAnchor": 0.0, "merge": "box", "fromStart": true})).unwrap(); + } + assert_eq!(key_times(&s, a), vec![0.0, 1.0, 2.0]); + // A slow move in tiny steps still arrives (each step is the whole offset). + s.history.merge_key = None; + for i in 1..=30 { + s.execute("keys.transform", json!({"timeOffset": i as f64 * 0.1 / 30.0, "merge": "move", "fromStart": true})).unwrap(); + } + assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]); +} diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 655fe619..d34bace3 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -272,7 +272,6 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions); // Keys and handles. let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![]; - let fr = comp.frame_rate; for c in &cs { let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim); for k in &c.keys { @@ -323,28 +322,46 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); let kresp = ui.interact(kr, egui::Id::new(("gkey", uid, d, k.idx)), Sense::click_and_drag()); app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); - let shift = ui.input(|i| i.modifiers.shift); - if (kresp.clicked() || kresp.drag_started()) && (!k.sel || shift) { - actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": shift}))); + let mods = ui.input(|i| i.modifiers); + let key = &c.prop.keys[k.idx]; + if kresp.clicked() && mods.command { + // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off (as in the Timeline). + actions.push(("keys.select".into(), json!({"keys": [kjson.clone()]}))); + if mods.alt { + actions.push(("keys.toggleHold".into(), json!({}))); + } else { + let linear = key.in_interp == Interp::Linear && key.out_interp == Interp::Linear; + actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }}))); + } + } else if kresp.clicked() && mods.shift { + actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true}))); + } else if (kresp.clicked() || kresp.drag_started()) && !k.sel { + actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift}))); + } + // A drag moves every selected key, by how far the pointer went from where the button + // went down (the key doesn't jump under the pointer); Shift keeps it to the time or + // the value axis, whichever moved more. + if kresp.drag_started() + && let Some(o) = ui.input(|i| i.pointer.press_origin()) + { + ctx.data_mut(|dd| dd.insert_temp(key_grab_id(), KeyGrab { press: o, t: k.t })); } if kresp.dragged() && let Some(pt) = kresp.interact_pointer_pos() + && let Some(g) = ctx.data(|dd| dd.get_temp::(key_grab_id())) { ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); - let merge = format!("gkey-{uid}-{}", k.idx); + let (dx, dy) = (pt.x - g.press.x, pt.y - g.press.y); + let (time_ok, value_ok) = if mods.shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) }; // Graph Editor ▸ Snap: to the current time and other keys. let others: Vec = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect(); - let ct = super::graph_tools::snap_time(app, tm, tm.t(pt.x).max(0.0), &others); - let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(ct))); - let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge}); - if !speed && c.editable { - let mut comps = c.prop.keys[k.idx].value.components(); - if d < comps.len() { - comps[d] = vmap(pt.y); - } - params["value"] = if comps.len() == 1 { json!(comps[0]) } else { json!(comps) }; + let t = if time_ok { super::graph_tools::snap_time(app, tm, (g.t + (tm.t(pt.x) - tm.t(g.press.x))).max(0.0), &others) } else { g.t }; + let mut params = json!({"timeOffset": t - g.t, "merge": "graph-key", "fromStart": true}); + if value_ok && !speed && c.editable { + params["valueOffset"] = json!(vmap(pt.y) - vmap(g.press.y)); + params["dim"] = json!(d); } - actions.push(("keys.set".into(), params)); + actions.push(("keys.transform".into(), params)); } if kresp.drag_stopped() { ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); @@ -500,3 +517,14 @@ fn fmt(v: f64, step: f64) -> String { let s = format!("{v:.dec$}"); if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s } } + +/// A Graph Editor key drag: where the button went down and the grabbed key's comp time then. +#[derive(Clone, Copy, Debug)] +struct KeyGrab { + press: Pos2, + t: f64, +} + +fn key_grab_id() -> egui::Id { + egui::Id::new("graph-key-grab") +} diff --git a/crates/ui-egui/src/panels/graph_tools.rs b/crates/ui-egui/src/panels/graph_tools.rs index 5585661c..a13a5396 100644 --- a/crates/ui-egui/src/panels/graph_tools.rs +++ b/crates/ui-egui/src/panels/graph_tools.rs @@ -29,7 +29,8 @@ struct BoxDrag { handle: usize, /// Fixed opposite point (comp s, value). anchor: [f64; 2], - /// Pointer data position of the previous frame. + /// Where the drag started (comp s, value): the pointer for a move, the handle for a scale. + /// Every step sends the whole transform since then (`keys.transform {fromStart}`). last: [f64; 2], } @@ -134,7 +135,7 @@ pub(crate) fn transform_box( }); } let Some(resp) = resp_any else { return }; - let Some(mut st) = ctx.data(|d| d.get_temp::(box_id())) else { return }; + let Some(st) = ctx.data(|d| d.get_temp::(box_id())) else { return }; if (st.handle == 8) == handles { return; } @@ -142,21 +143,20 @@ pub(crate) fn transform_box( && let Some(pt) = resp.interact_pointer_pos() { let cur = data(pt); - let mut params = json!({"merge": "graph-box"}); + let mut params = json!({"merge": "graph-box", "fromStart": true}); if st.handle == 8 { params["timeOffset"] = json!(cur[0] - st.last[0]); if value_ok { params["valueOffset"] = json!(cur[1] - st.last[1]); } - st.last = cur; } else { let (time_axis, value_axis) = match st.handle { 4 | 6 => (false, true), 5 | 7 => (true, false), _ => (true, true), }; - // Scale relative to where the handle is now (incremental, so merged steps compose). - let now = corner(st.handle); + // Scale relative to where the handle was when the drag started. + let now = st.last; if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 { let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]); if k > 0.01 { @@ -250,6 +250,8 @@ struct AltScale { anchor: f64, /// The dragged end is the group's last key (else its first). end_is_max: bool, + /// The group's span when the drag started (set on its first step). + span: Option, } fn alt_id() -> egui::Id { @@ -272,9 +274,9 @@ fn sel_range(app: &EffectcraftApp, comp: &Comp) -> Option<(f64, f64)> { pub(crate) fn alt_scale_begin(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, key_ct: f64) -> bool { let Some((lo, hi)) = sel_range(app, comp) else { return false }; let st = if (key_ct - hi).abs() < 1e-6 { - AltScale { anchor: lo, end_is_max: true } + AltScale { anchor: lo, end_is_max: true, span: None } } else if (key_ct - lo).abs() < 1e-6 { - AltScale { anchor: hi, end_is_max: false } + AltScale { anchor: hi, end_is_max: false, span: None } } else { return false; }; @@ -293,16 +295,16 @@ pub(crate) fn alt_scale_update(app: &EffectcraftApp, ctx: &egui::Context, comp: let (Some((lo, hi)), Some(px)) = (sel_range(app, comp), ctx.input(|i| i.pointer.latest_pos())) else { return }; let fr = comp.frame_rate; let target = fr.snap_nearest(effectcraft_engine::time::Tick::from_seconds_f64(tm.t(px.x).max(0.0))).seconds(); - let end = if st.end_is_max { hi } else { lo }; - let span = end - st.anchor; + // The group's span when the drag started: every step sends the whole scale since then. + let span = st.span.unwrap_or((if st.end_is_max { hi } else { lo }) - st.anchor); + if st.span.is_none() { + ctx.data_mut(|d| d.insert_temp(alt_id(), AltScale { span: Some(span), ..st })); + } let want = target - st.anchor; if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() { return; } - let k = want / span; - if (k - 1.0).abs() > 1e-6 { - actions.push(("keys.transform".into(), json!({"timeScale": k, "timeAnchor": st.anchor, "merge": "key-alt-scale"}))); - } + actions.push(("keys.transform".into(), json!({"timeScale": want / span, "timeAnchor": st.anchor, "merge": "key-alt-scale", "fromStart": true}))); } /// An Alt-drag group scale is in progress (the plain key move stands aside). diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index bcd0bb9b..fc14369e 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -147,3 +147,34 @@ fn shift_click_toggles_and_ctrl_click_switches_interpolation() { click_with(&mut h, ks[0], egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND }); assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Hold); } + +#[test] +fn graph_editor_drags_move_every_selected_key_and_shift_keeps_an_axis() { + let (mut h, id, uid) = harness(); + let s = &mut h.state_mut().session; + s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap(); + s.execute("keys.selectAll", json!({})).unwrap(); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + let at = |h: &Harness<'_, EffectcraftApp>, i: usize| { + let e = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap_or_else(|| panic!("no graph key {i}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) + }; + let (k0, k1) = (at(&h, 0), at(&h, 1)); + let px_per_s = k1.x - k0.x; + let values = |h: &Harness<'_, EffectcraftApp>| -> Vec { + h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.value.as_f64()).collect() + }; + // Shift: only along time, both keys, by 0.5 s. + drag(&mut h, k1, pos2(k1.x + px_per_s * 0.5, k1.y - 6.0), egui::Modifiers { shift: true, ..Default::default() }); + assert_eq!(key_times(&h, id), vec![0.5, 1.5], "both selected keys moved"); + assert_eq!(values(&h), vec![0.0, 100.0], "Shift kept the values"); + let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count(); + assert_eq!(undo, 1, "one undo step per drag"); + // Without Shift the values move too (both keys, by the same amount). + h.run_steps(2); + let k1 = at(&h, 1); + drag(&mut h, k1, pos2(k1.x, k1.y + 20.0), Default::default()); + let v = values(&h); + assert!(v[0] < 0.0 && (v[1] - 100.0 - v[0]).abs() < 1e-6, "{v:?}"); +} From 9ea1f1325d3ed4cadcc1e8d2e96531d048f06f81 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:58:03 +0700 Subject: [PATCH 07/10] M5.12: double-click a keyframe to edit its value; Select Equal / Previous / Following Keyframes; roving keys as dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Double-clicking a keyframe of a numeric property (or its context menu ▸ Edit Value…) opens the property's value dialog for that key, as in After Effects (it only moved the current time); other keys still move the current time to them. - The keyframe context menu has Select Equal Keyframes, Select Previous Keyframes and Select Following Keyframes (`keys.selectEqual` / `selectPrevious` / `selectFollowing`). - Roving keys draw as small dots in the Timeline, like After Effects. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/anim.rs | 32 +++++++++++++++++++++++++++ crates/engine/src/tests_menu_cmds.rs | 24 ++++++++++++++++++++ crates/ui-egui/src/panels/forms.rs | 17 ++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 23 +++++++++++++++++-- crates/ui-egui/tests/ui_keyframes.rs | 30 +++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3d6e3ac2..d4dec045 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -151,6 +151,35 @@ fn select_all(s: &mut Session, p: &Value) -> Result { Ok(json!(s.state.selected_keys.len())) } +/// The keyframe context menu's Select Equal / Previous / Following Keyframes: in every property +/// with a selected key, its keys with the same value as a selected one / before the first +/// selected one / after the last selected one (the selected keys stay selected). +fn select_related(s: &mut Session, which: &str) -> Result { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let mut sel = s.state.selected_keys.clone(); + let mut props: Vec<(LayerId, Uid)> = sel.iter().map(|k| (k.layer, k.prop)).collect(); + props.sort(); + props.dedup(); + for (lid, uid) in props { + let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) else { continue }; + let picked: Vec<&Keyframe> = pr.keys.iter().filter(|k| sel.iter().any(|r| r.layer == lid && r.prop == uid && r.time == k.time)).collect(); + let (Some(first), Some(last)) = (picked.iter().map(|k| k.time).min(), picked.iter().map(|k| k.time).max()) else { continue }; + for k in &pr.keys { + let hit = match which { + "equal" => picked.iter().any(|p| p.value == k.value), + "previous" => k.time < first, + _ => k.time > last, + }; + let r = KeyRef { layer: lid, prop: uid, time: k.time }; + if hit && !sel.contains(&r) { + sel.push(r); + } + } + } + s.state.selected_keys = sel; + Ok(json!(s.state.selected_keys.len())) +} + /// Move the selected keys by whole frames (Alt+→ / Alt+←, Shift for 10 frames). fn nudge(s: &mut Session, p: &Value) -> Result { let frames = p.get("frames").and_then(Value::as_i64).ok_or_else(|| bad("keys.nudge", "missing `frames`"))?; @@ -398,6 +427,9 @@ pub fn specs() -> Vec { &json!({"frames": -10}) )), cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key), + cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")), + cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")), + cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")), cmd!( "keys.setEase", "Set Keyframe Ease", diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 1b7bc617..71694f69 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -957,3 +957,27 @@ fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { } assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]); } + +#[test] +fn select_equal_previous_and_following_keyframes() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + for (t, v) in [(0.0, 20.0), (1.0, 50.0), (2.0, 20.0), (3.0, 70.0)] { + s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let pick = |s: &mut Session, t: f64| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": t}]})).unwrap(); + let times = |s: &Session| { + let mut v: Vec = s.state.selected_keys.iter().map(|k| k.time.seconds()).collect(); + v.sort_by(f64::total_cmp); + v + }; + pick(&mut s, 0.0); + s.execute("keys.selectEqual", json!({})).unwrap(); + assert_eq!(times(&s), vec![0.0, 2.0]); + pick(&mut s, 2.0); + s.execute("keys.selectPrevious", json!({})).unwrap(); + assert_eq!(times(&s), vec![0.0, 1.0, 2.0]); + pick(&mut s, 1.0); + s.execute("keys.selectFollowing", json!({})).unwrap(); + assert_eq!(times(&s), vec![1.0, 2.0, 3.0]); +} diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 9351a581..4df53e3a 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -195,6 +195,23 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ) } + // Double-click a keyframe (or its context menu ▸ Edit Value…): the property's value at + // that key, like After Effects' value dialog. + "keys.set" if !has(p, &["value", "newTime"]) => { + let l = p.get("layer").and_then(Value::as_u64).and_then(|l| comp?.layer(effectcraft_engine::project::LayerId(l))); + let pr = l.and_then(|l| l.props.find(p.get("prop").and_then(Value::as_u64)?)); + 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, + }; + (pr.name.clone(), fields) + } "layer.setTransform" if !has(p, &["value"]) => { let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position"); let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ee6a3e89..d1a93618 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1870,7 +1870,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { None if ks => t.keyframe_selected, None => t.keyframe, }; - icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200)); + if k.roving { + // Roving keys: a small dot (their time follows their neighbours). + gp.circle_filled(c, 2.5, kcol); + } else { + icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200)); + } let kr = Rect::from_center_size(c, vec2(12.0, 14.0)); let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag()); app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name)); @@ -1896,13 +1901,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("keys.select".into(), p)); } if kresp.double_clicked() { - ui_actions.push(UiAct::SetTime(ct)); + // Edit the key's value (numeric properties), as After Effects does. + if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) { + actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}))); + } else { + ui_actions.push(UiAct::SetTime(ct)); + } } kresp.context_menu(|ui| { for (lbl, cmd, params) in [ + ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})), + ("-", "", json!({})), ("Copy", "keys.copy", json!({})), ("Paste", "keys.paste", json!({})), ("-", "", json!({})), + ("Select Equal Keyframes", "keys.selectEqual", json!({})), + ("Select Previous Keyframes", "keys.selectPrevious", json!({})), + ("Select Following Keyframes", "keys.selectFollowing", json!({})), + ("-", "", json!({})), ("Keyframe Interpolation…", "keys.interpolation", json!({})), ("Keyframe Velocity…", "keys.velocity", json!({})), ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), @@ -1943,6 +1959,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if lbl == "Rove Across Time" && !prop.spatial { continue; } + if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) { + continue; + } if ui.button(lbl).clicked() { if !ks { actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}))); diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index fc14369e..dd269d9f 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -178,3 +178,33 @@ fn graph_editor_drags_move_every_selected_key_and_shift_keeps_an_axis() { let v = values(&h); assert!(v[0] < 0.0 && (v[1] - 100.0 - v[0]).abs() < 1e-6, "{v:?}"); } + +#[test] +fn double_click_a_key_to_edit_its_value() { + let (mut h, id, uid) = harness(); + let k1 = keys(&h, uid)[1]; + h.input_mut().events.push(Event::PointerMoved(k1)); + h.step(); + // Both clicks inside egui's double-click time. + for _ in 0..2 { + h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + h.step(); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "the value dialog opened"); + h.run_steps(2); + assert!(h.state().auto.find("form.field.value").is_some(), "one Opacity field"); + // OK keeps the key where it is (the dialog edits the value only). + let ok = h.state().auto.find("form.ok").expect("form.ok").clone(); + let p = pos2(ok.rect[0] + ok.rect[2] / 2.0, ok.rect[1] + ok.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert!(h.state().dialog.is_none()); + let k = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[1].clone(); + assert_eq!((k.time.seconds(), k.value.as_f64()), (1.0, 100.0)); +} From 43bf2906d36d1f6dd003fe494a7bc3b18ea69c4a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:02:16 +0700 Subject: [PATCH 08/10] M5.13: keyframe paste: several copied layers paste onto as many layers in order; Paste Reversed pastes like Paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Keys copied from several layers and pasted onto as many selected layers go first to first, second to second, as in After Effects; every target used to receive every copied property (the later ones overwriting the earlier). - Edit ▸ Paste Reversed Keyframes mirrors the clipboard over its span and pastes it through `keys.paste`, so it now follows the same rules as Paste: the layer's time stretch, the selected target property, Hold-only properties, value conversion and roving keys (its own simplified paste ignored them and kept existing keys' easing on a time collision). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/anim.rs | 10 +++- crates/engine/src/commands/edit.rs | 72 +++++++++++----------------- crates/engine/src/tests_menu_cmds.rs | 33 +++++++++++++ 3 files changed, 71 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index d4dec045..0747290e 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -73,11 +73,19 @@ fn paste(s: &mut Session, p: &Value) -> Result { let path = str_p(p, "path")?; comp.layer(*targets.first()?)?.props.prop(path).map(|pr| pr.uid) }); + // Keys copied from several layers, pasted onto as many layers: the first copied layer's keys + // go to the first target and so on (else every target gets every copied property). + let mut sources: Vec = clip.iter().map(|c| c.layer).collect(); + sources.dedup(); + let paired = sources.len() > 1 && sources.len() == targets.len(); // (layer, prop uid, clip index) let mut plan: Vec<(LayerId, Uid, usize)> = vec![]; - for lid in &targets { + for (ti, lid) in targets.iter().enumerate() { let Some(l) = comp.layer(*lid) else { continue }; for (ci, c) in clip.iter().enumerate() { + if paired && sources.get(ti) != Some(&c.layer) { + continue; + } let Some(sample) = c.keys.first().map(|k| &k.value) else { continue }; let target = if clip.len() == 1 { explicit diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index bcc6a910..b021e4e6 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -299,46 +299,6 @@ fn copy_expression_only(s: &mut Session, _: &Value) -> Result { } /// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths). -fn paste_keys(s: &mut Session, reversed: bool) -> Result { - let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - let ids = s.state.selected_layers.clone(); - if ids.is_empty() { - return Err(EngineError::Other("select a layer to paste keyframes into".into())); - } - let clip = s.state.key_clipboard.clone(); - let t = s.time(); - let n = s.edit(if reversed { "Paste Reversed Keyframes" } else { "Paste Keyframes" }, None, |proj, st| { - let mut n = 0; - st.selected_keys.clear(); - for lid in &ids { - let l = super::layer_mut(proj, cid, *lid)?; - let lt = l.layer_time(t); - for c in &clip { - let Some(pr) = l.props.prop_mut(&c.path) else { continue }; - let span = c.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO); - for k in &c.keys { - let mut k = k.clone(); - if std::mem::discriminant(&k.value) != std::mem::discriminant(&pr.value) { - continue; - } - if reversed { - k.time = span - k.time; - std::mem::swap(&mut k.in_interp, &mut k.out_interp); - std::mem::swap(&mut k.in_ease, &mut k.out_ease); - std::mem::swap(&mut k.spatial_in, &mut k.spatial_out); - } - k.time += lt; - st.selected_keys.push(crate::KeyRef { layer: *lid, prop: pr.uid, time: k.time }); - effectcraft_keyframe::set_key(&mut pr.keys, k); - n += 1; - } - } - } - Ok(n) - })?; - Ok(json!({"keys": n})) -} - fn paste_links(s: &mut Session, clip: LinkClip) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let ids = s.state.selected_layers.clone(); @@ -470,8 +430,34 @@ fn label(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -fn paste_reversed(s: &mut Session, _: &Value) -> Result { - paste_keys(s, true) +/// Edit ▸ Paste Reversed Keyframes: the copied keys mirrored in time over their whole span (the +/// last key first, incoming and outgoing eases swapped), pasted like Edit ▸ Paste +/// (`keys.paste`: at the current time, into the selected or the same properties). +fn paste_reversed(s: &mut Session, p: &Value) -> Result { + let original = s.state.key_clipboard.clone(); + let span = original.iter().flat_map(|c| c.keys.iter().map(|k| k.time)).max().unwrap_or(Tick::ZERO); + s.state.key_clipboard = original + .iter() + .map(|c| crate::KeyClip { + keys: c + .keys + .iter() + .rev() + .map(|k| { + let mut k = k.clone(); + k.time = span - k.time; + std::mem::swap(&mut k.in_interp, &mut k.out_interp); + std::mem::swap(&mut k.in_ease, &mut k.out_ease); + std::mem::swap(&mut k.spatial_in, &mut k.spatial_out); + k + }) + .collect(), + ..c.clone() + }) + .collect(); + let r = s.execute("keys.paste", p.clone()); + s.state.key_clipboard = original; + r } /// Lift (leave a gap) or extract (close the gap) the work area from the selected layers (all @@ -667,7 +653,7 @@ pub fn specs() -> Vec { ), cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only), cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste), - cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{}", has_key_clip, paste_reversed), + cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed), cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate), cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split), diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 71694f69..abf79d70 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -981,3 +981,36 @@ fn select_equal_previous_and_following_keyframes() { s.execute("keys.selectFollowing", json!({})).unwrap(); assert_eq!(times(&s), vec![1.0, 2.0, 3.0]); } + +#[test] +fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + opacity_keys(&mut s, a, &[0.0, 1.0]); + s.execute("prop.addKey", json!({"layer": b, "path": "transform/opacity", "time": 0.0, "value": 77})).unwrap(); + let c = solid(&mut s, "#0000ff"); + let d = solid(&mut s, "#ffffff"); + // Copy A's and B's Opacity keys, paste onto C and D: A → C, B → D (in order). + s.execute("keys.selectAll", json!({"layers": [a, b]})).unwrap(); + s.execute("keys.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("layer.select", json!({"layers": [c, d]})).unwrap(); + s.execute("keys.paste", json!({})).unwrap(); + assert_eq!(key_times(&s, c), vec![2.0, 3.0], "A's two keys"); + assert_eq!(key_times(&s, d), vec![2.0], "B's one key"); + // Paste Reversed onto a layer stretched to 200 %: the reversed keys land 2 s apart there too. + let e = solid(&mut s, "#808080"); + s.execute_checked("layer.timeStretch", json!({"layers": [e], "percent": 200})).unwrap(); + s.execute("keys.selectAll", json!({"layers": [a]})).unwrap(); + s.execute("keys.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 0.0})).unwrap(); + s.execute("layer.select", json!({"layers": [e]})).unwrap(); + s.execute("edit.pasteReversedKeyframes", json!({})).unwrap(); + let pr = layer(&s, e).props.prop("transform/opacity").unwrap().clone(); + let l = layer(&s, e); + let ct: Vec = pr.keys.iter().map(|k| l.comp_time(k.time).seconds()).collect(); + let vals: Vec = pr.keys.iter().map(|k| k.value.as_f64()).collect(); + assert!(ct.len() == 2 && (ct[1] - ct[0] - 1.0).abs() < 1e-6, "keys 1 s apart in comp time: {ct:?}"); + assert_eq!(vals, vec![10.0, 0.0], "reversed"); +} From dfb02274683c5841e10ebd79aa574487114a4833 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:05:03 +0700 Subject: [PATCH 09/10] M5.14: J / K stop at the work area; the Info panel shows a selected keyframe - Go to Next / Previous Keyframe (K / J) also stops at the work area's start and end, as in After Effects. - With one keyframe selected, the Info panel shows its property, time and value instead of "1 keyframes selected". Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/time.rs | 5 ++++- crates/engine/src/tests_menu_cmds.rs | 16 ++++++++++++++++ crates/ui-egui/src/panels/info.rs | 26 +++++++++++++++++++------- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 0b6d19bc..1c4c1c64 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -79,7 +79,7 @@ fn go(s: &mut Session, p: &Value) -> Result { "layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t), "layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t), "nextKey" | "prevKey" => { - // Keyframes (of visible/selected layers) and markers, in comp time. + // Keyframes (of visible/selected layers), markers and the work area, in comp time. let mut times: Vec = comp.markers.iter().map(|m| m.time).collect(); let layers: Vec<_> = if s.state.selected_layers.is_empty() { comp.layers.iter().collect() @@ -90,6 +90,9 @@ fn go(s: &mut Session, p: &Value) -> Result { let only = p.get("prop").and_then(Value::as_u64); if only.is_some() { times.clear(); + } else { + // The work area's start and end too, as in After Effects. + times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]); } for l in layers { l.props.walk("", &mut |_, pr| { diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index abf79d70..7f1fce5f 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -1014,3 +1014,19 @@ fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() assert!(ct.len() == 2 && (ct[1] - ct[0] - 1.0).abs() < 1e-6, "keys 1 s apart in comp time: {ct:?}"); assert_eq!(vals, vec![10.0, 0.0], "reversed"); } + +#[test] +fn j_and_k_stop_at_keys_markers_and_the_work_area() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + opacity_keys(&mut s, a, &[2.0]); + s.execute("comp.workArea", json!({"start": 1.0, "end": 3.0})).unwrap(); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + let mut stops = vec![]; + for _ in 0..4 { + s.execute("time.nextKey", json!({})).unwrap(); + stops.push((s.time().seconds() * 30.0).round() / 30.0); + } + let last = (3.0 * 30.0 - 1.0) / 30.0; + assert_eq!(stops, vec![1.0, 2.0, (last * 30.0f64).round() / 30.0, (last * 30.0f64).round() / 30.0]); +} diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 92df6f8c..4fad6666 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -101,13 +101,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps{rq}", app.fps), Tokens::ui(11.5), t.text_faint); } if !app.session.state.selected_keys.is_empty() { - p.text( - pos2(x0, ly + 18.0), - Align2::LEFT_CENTER, - format!("{} keyframes selected", app.session.state.selected_keys.len()), - Tokens::ui(11.5), - t.text_dim, - ); + // One key: its property, time and value (as After Effects' Info panel shows them). + let sel = &app.session.state.selected_keys; + let one = match sel.as_slice() { + [k] => c.layer(k.layer).and_then(|l| { + let pr = l.props.find(k.prop)?; + let key = pr.keys.iter().find(|x| x.time == k.time)?; + let tc = crate::panels::timecode(&app.session, &c, l.comp_time(k.time)); + let v = match &key.value { + effectcraft_engine::keyframe::Value::Scalar(v) => format!("{v:.2}"), + effectcraft_engine::keyframe::Value::Vec2(v) => format!("{:.1}, {:.1}", v[0], v[1]), + effectcraft_engine::keyframe::Value::Vec3(v) => format!("{:.1}, {:.1}, {:.1}", v[0], v[1], v[2]), + _ => String::new(), + }; + Some(format!("{} {tc} {v}", pr.name).trim_end().to_string()) + }), + _ => None, + }; + let line = one.unwrap_or_else(|| format!("{} keyframes selected", sel.len())); + p.text(pos2(x0, ly + 18.0), Align2::LEFT_CENTER, line, Tokens::ui(11.5), t.text_dim); } } } From ef856d8f67e1ea7861124aa26e969f2690d50863 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 08:06:32 +0700 Subject: [PATCH 10/10] M5.14: docs: parity, agents and preferences cover the keyframe, clipboard and responsiveness fixes Co-Authored-By: Claude Opus 5.5 --- docs/agents.md | 6 ++++++ docs/parity.md | 26 ++++++++++++++++++++++++++ docs/preferences.md | 2 +- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/agents.md b/docs/agents.md index 4a56bb45..396d8aaf 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -133,6 +133,12 @@ build real projects through these interfaces; they are worked examples of everyt (a string param that is exactly `"$N"` or `"$N.key"` is replaced; `$2.path` is the trim's path). * **Expressions**: `set_property`/`get_property` replies carry `evaluated` (what renders) next to `value` (the keyframed value) and `expressionError` when the expression fails. +* **Keyframes like a person**: `keys.select {keys, toggle: true}` (Shift+click), `keys.selectEqual` + / `selectPrevious` / `selectFollowing`, `keys.move {delta, merge}` (steps sharing a merge key are + one drag: each applies to the keys as they were before it, so a key passed over survives) and + `keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag + started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste` + at the current time (keys copied from several layers go to as many selected layers in order). * **Easing with Keyframe Velocity**: select keys by path, `keys.select {"keys":[{"layer":"Ring","path":"contents/trim/end","time":0}]}`, then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`. * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or diff --git a/docs/parity.md b/docs/parity.md index e0199a7b..3c1d5294 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -392,6 +392,32 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. +## Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness + +- **The app froze every 10 s on Windows** (M12.8): the cache budgets' memory reading started + PowerShell on the UI thread and waited about a second for it. It runs on a background thread + now, every 30 s. +- **Ctrl+C / Ctrl+X / Ctrl+V** (M3.15) reach Edit ▸ Copy, Cut and Paste in the app (the window + layer sends them as clipboard events, which the shortcut dispatcher ignored), and copying puts + a line on the system clipboard so Ctrl+V always arrives. +- **Timeline keyframes** (M5.9–M5.12): a drag follows the pointer to the end (it let go after a + frame or two), moves every selected key as one undo step, never deletes keys it passes over, + moves keys of time-stretched layers with the pointer, and Shift snaps to the current time, + keys, layer ends and markers with a time / offset tooltip; Shift+click toggles selection, + Ctrl+click switches Linear ↔ Auto Bezier, Ctrl+Alt+click toggles Hold; keys of locked layers + can't be selected; Toggle Hold off restores Bezier; double-click edits a key's value; Select + Equal / Previous / Following Keyframes; roving keys draw as dots. +- **Graph Editor** (M5.11): a drag moves every selected key in time and value from where it was + grabbed, Shift keeps one axis; the transform box and Alt-drag scaling never merge keys on the + way and move them however slowly they are dragged; the same Shift / Ctrl / Ctrl+Alt clicks. +- **Paste** (M5.13): keys copied from several layers paste onto as many layers in order; Paste + Reversed Keyframes follows Paste's rules (stretch, target property, Hold-only properties). +- **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected + key's property, time and value. + +Not yet: J / K and Select All Keyframes use every property of the layers, not only the revealed +ones; Graph Editor keys are drawn as squares whatever their interpolation; Auto-Select Graph Type. + ## Update: M4.9–M4.11 nested comps and motion blur - **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and diff --git a/docs/preferences.md b/docs/preferences.md index 6e68ba94..60b38084 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -124,7 +124,7 @@ Commands (CLI, MCP, control channel): sample rate and read back instead of decoding again - `memory.ramReservedGb`: the cache budgets together leave this much physical memory free - `memory.reduceCacheWhenLow`: cache budgets halve while the system is low on memory (checked - every 10 s by the desktop app) + every 30 s by the desktop app, on a background thread) - `video.enableOutput`, `video.device`, `video.outputDuringPlayback`, `video.mirrorOnMonitor`, `video.disableWhenBackground`: Video Preview, a second window (or full screen on the display it is on) showing the composition frame, Mercury Transmit-style