diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 0fbef4bb..0747290e 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}; @@ -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 @@ -145,6 +153,37 @@ 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())) +} + +/// 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())) } @@ -153,11 +192,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). @@ -399,6 +435,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/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/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/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/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/lib.rs b/crates/engine/src/lib.rs index ca93f29b..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,10 @@ 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). pub applied_nested_switches: Option, /// Runs renders and analyses off the UI thread where there are no threads (the web app's @@ -465,6 +480,8 @@ 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, 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_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 5168e46c..7f1fce5f 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -863,3 +863,170 @@ 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()); +} + +#[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]); +} + +#[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]); +} + +#[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"); +} + +#[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/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"); + } +} 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/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/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/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); } } } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 28b10af3..d1a93618 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; @@ -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") @@ -1857,27 +1870,55 @@ 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)); - 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)); + // 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!({})), @@ -1918,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()}]}))); @@ -1934,21 +1978,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 +2069,49 @@ 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 { + // 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 { + 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); + // 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())); + 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/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)); diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs new file mode 100644 index 00000000..dd269d9f --- /dev/null +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -0,0 +1,210 @@ +//! 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.step(); + 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"); +} + +#[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]); +} + +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); +} + +#[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:?}"); +} + +#[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)); +} 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