M5.10: keyframes behave like After Effects: drags never destroy keys, stretched layers, Shift/Ctrl/Ctrl+Alt clicks, locked layers, Hold off

- 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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 07:40:42 +07:00
1 parent 3b6b390f68
commit 9929b5f9ac
6 files changed
+262 -28

No files matched your search

+5 -6
View File
@@ -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<Value> {
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<Value> {
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
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).
+94 -9
View File
@@ -335,7 +335,19 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Tick>> = 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<Tick> = 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<Value> {
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<Value> {
@@ -543,7 +613,14 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
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<CommandSpec> {
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),
+14
View File
@@ -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<KeyRef>,
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<sysinfo::SysMemory>,
/// The keyframe drag in progress (`keys.move` with a merge key).
pub key_move: Option<KeyMove>,
/// 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,
+75
View File
@@ -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<f64> {
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());
}
+39 -13
View File
@@ -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<f64> =
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::<KeyDrag>(key_drag_id()));
+35
View File
@@ -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);
}