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); +}