Merge pull request #40 from storytold/timeline-keyframes

Fix the periodic freeze on Windows; keyframe drag, copy/paste and Graph Editor behave like After Effects
This commit is contained in:
BFlat 2026-10-05 08:13:28 +07:00 committed by GitHub
commit c90fd35ff8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 930 additions and 133 deletions

View File

@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
use effectcraft_time::Tick; use effectcraft_time::Tick;
use serde_json::{Value, json}; 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 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}; use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query};
@ -73,11 +73,19 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path")?; let path = str_p(p, "path")?;
comp.layer(*targets.first()?)?.props.prop(path).map(|pr| pr.uid) 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<LayerId> = clip.iter().map(|c| c.layer).collect();
sources.dedup();
let paired = sources.len() > 1 && sources.len() == targets.len();
// (layer, prop uid, clip index) // (layer, prop uid, clip index)
let mut plan: Vec<(LayerId, Uid, usize)> = vec![]; 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 }; let Some(l) = comp.layer(*lid) else { continue };
for (ci, c) in clip.iter().enumerate() { 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 Some(sample) = c.keys.first().map(|k| &k.value) else { continue };
let target = if clip.len() == 1 { let target = if clip.len() == 1 {
explicit explicit
@ -145,6 +153,37 @@ 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 }))); 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<Value> {
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; s.state.selected_keys = sel;
Ok(json!(s.state.selected_keys.len())) Ok(json!(s.state.selected_keys.len()))
} }
@ -153,11 +192,8 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
fn nudge(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 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 comp = s.active_comp().ok_or(EngineError::NoComp)?;
let d = Tick(comp.frame_duration().0 * frames); let d = Tick(comp.frame_duration().0.saturating_mul(frames));
edit_keys(s, "Nudge Keyframes", merge_p(p), move |_, _, t| { super::prop::shift_keys(s, "Nudge Keyframes", merge_p(p), d)
*t += d;
true
})
} }
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags). /// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
@ -399,6 +435,9 @@ pub fn specs() -> Vec<CommandSpec> {
&json!({"frames": -10}) &json!({"frames": -10})
)), )),
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key), 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!( cmd!(
"keys.setEase", "keys.setEase",
"Set Keyframe Ease", "Set Keyframe Ease",

View File

@ -299,46 +299,6 @@ fn copy_expression_only(s: &mut Session, _: &Value) -> Result<Value> {
} }
/// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths). /// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths).
fn paste_keys(s: &mut Session, reversed: bool) -> Result<Value> {
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<Value> { fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let ids = s.state.selected_layers.clone(); let ids = s.state.selected_layers.clone();
@ -470,8 +430,34 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null) Ok(Value::Null)
} }
fn paste_reversed(s: &mut Session, _: &Value) -> Result<Value> { /// Edit ▸ Paste Reversed Keyframes: the copied keys mirrored in time over their whole span (the
paste_keys(s, true) /// 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<Value> {
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 /// 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<CommandSpec> {
), ),
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only), 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.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.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate), 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), cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),

View File

@ -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 /// 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 /// 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. /// `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<Value> { fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; 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 fr = comp.frame_rate;
let sel = s.state.selected_keys.clone();
let ts = f_p(p, "timeScale").unwrap_or(1.0); let ts = f_p(p, "timeScale").unwrap_or(1.0);
let toff = f_p(p, "timeOffset").unwrap_or(0.0); let toff = f_p(p, "timeOffset").unwrap_or(0.0);
let vs = f_p(p, "valueScale").unwrap_or(1.0); let vs = f_p(p, "valueScale").unwrap_or(1.0);
@ -46,7 +60,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0))) fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
}; };
let touch_values = vs != 1.0 || voff != 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 comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default(); let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &sel { for k in &sel {
@ -136,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transform Keyframes", "Transform Keyframes",
[], [],
None, 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, has_keys,
transform transform
), ),

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 }); 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 { for k in sel {
if !s.state.selected_keys.contains(&k) { if !s.state.selected_keys.contains(&k) {
s.state.selected_keys.push(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> { 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 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); shift_keys(s, "Move Keyframes", merge_p(p), d)
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
})
} }
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> { 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> { fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| { edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| {
let k = &mut 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 false
}) })
} }
@ -667,7 +744,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers, has_layers,
convert_expr_to_keys 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.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys),
cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_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), cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease),

View File

@ -79,7 +79,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
"layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t), "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), "layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t),
"nextKey" | "prevKey" => { "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<Tick> = comp.markers.iter().map(|m| m.time).collect(); let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
let layers: Vec<_> = if s.state.selected_layers.is_empty() { let layers: Vec<_> = if s.state.selected_layers.is_empty() {
comp.layers.iter().collect() comp.layers.iter().collect()
@ -90,6 +90,9 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
let only = p.get("prop").and_then(Value::as_u64); let only = p.get("prop").and_then(Value::as_u64);
if only.is_some() { if only.is_some() {
times.clear(); 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 { for l in layers {
l.props.walk("", &mut |_, pr| { l.props.walk("", &mut |_, pr| {

View File

@ -134,6 +134,17 @@ pub struct KeyRef {
pub time: Tick, 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. /// Keyframes of one property on the keyframe clipboard.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct KeyClip { pub struct KeyClip {
@ -373,6 +384,10 @@ pub struct Session {
/// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown /// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown
/// (headless sessions don't query it, see [`Session::memory_tick`]). /// (headless sessions don't query it, see [`Session::memory_tick`]).
pub sys_memory: Option<sysinfo::SysMemory>, 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). /// Switches Affect Nested Comps as last applied (a change empties the layer cache).
pub applied_nested_switches: Option<bool>, pub applied_nested_switches: Option<bool>,
/// Runs renders and analyses off the UI thread where there are no threads (the web app's /// 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, plugin_loader: None,
script_ui: scriptui::ScriptUi::default(), script_ui: scriptui::ScriptUi::default(),
sys_memory: None, sys_memory: None,
key_move: None,
memory_watch: Default::default(),
applied_nested_switches: None, applied_nested_switches: None,
offload: None, offload: None,
offloaded: vec![], offloaded: vec![],

View File

@ -75,11 +75,13 @@ impl Session {
self.footage.set_cache_budget(b.media); self.footage.set_cache_budget(b.media);
} }
/// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply /// Take the latest reading of the system's memory and start the next one in the background
/// the cache budgets when the reading changes them. Returns the effective budgets. /// (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 { pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
let before = self.prefs.cache_budgets(self.sys_memory); 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); let after = self.prefs.cache_budgets(self.sys_memory);
if after != before { if after != before {
self.apply_cache_budgets(); self.apply_cache_budgets();

View File

@ -4,7 +4,11 @@
//! Pure Rust and dependency-free: the values come from the operating system's own reporting //! 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 //! (`/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 //! 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; 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<Mutex<Option<SysMemory>>>,
busy: Arc<AtomicBool>,
}
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<SysMemory> {
*self.latest.lock().unwrap_or_else(PoisonError::into_inner)
}
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)] #[allow(dead_code)]
fn run(cmd: &str, args: &[&str]) -> Option<String> { fn run(cmd: &str, args: &[&str]) -> Option<String> {
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()) out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string())
} }

View File

@ -863,3 +863,170 @@ fn new_comp_from_selection_survives_hostile_sequence_numbers() {
s.state.project_selection = vec![a, b]; s.state.project_selection = vec![a, b];
assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 99})).is_err()); 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());
}
#[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<f64> = 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<f64> = pr.keys.iter().map(|k| l.comp_time(k.time).seconds()).collect();
let vals: Vec<f64> = 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]);
}

View File

@ -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"); 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); 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");
}
}

View File

@ -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. /// 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<Value, String> { fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
let r = app.session.execute(id, params).map_err(|e| e.to_string()); let r = app.session.execute(id, params).map_err(|e| e.to_string());
if let Err(e) = &r { if let Err(e) = &r {
app.ui.status = e.clone(); 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(); let events = app.session.drain_events();
for ev in 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() { if ctx.egui_wants_keyboard_input() {
return; 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| { 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 i.events
.iter() .iter()
.filter_map(|e| match e { .filter_map(|e| match e {
@ -1226,6 +1254,9 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
{ {
Some((*key, *modifiers)) 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, _ => None,
}) })
.collect() .collect()

View File

@ -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"]) => { "layer.setTransform" if !has(p, &["value"]) => {
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position"); 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)); 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));

View File

@ -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); super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions);
// Keys and handles. // Keys and handles.
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![]; let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
let fr = comp.frame_rate;
for c in &cs { for c in &cs {
let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim); let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim);
for k in &c.keys { 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 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()); 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); app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
let shift = ui.input(|i| i.modifiers.shift); let mods = ui.input(|i| i.modifiers);
if (kresp.clicked() || kresp.drag_started()) && (!k.sel || shift) { let key = &c.prop.keys[k.idx];
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": shift}))); 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() if kresp.dragged()
&& let Some(pt) = kresp.interact_pointer_pos() && let Some(pt) = kresp.interact_pointer_pos()
&& let Some(g) = ctx.data(|dd| dd.get_temp::<KeyGrab>(key_grab_id()))
{ {
ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); 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. // Graph Editor ▸ Snap: to the current time and other keys.
let others: Vec<f64> = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect(); let others: Vec<f64> = 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 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 nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(ct))); let mut params = json!({"timeOffset": t - g.t, "merge": "graph-key", "fromStart": true});
let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge}); if value_ok && !speed && c.editable {
if !speed && c.editable { params["valueOffset"] = json!(vmap(pt.y) - vmap(g.press.y));
let mut comps = c.prop.keys[k.idx].value.components(); params["dim"] = json!(d);
if d < comps.len() {
comps[d] = vmap(pt.y);
}
params["value"] = if comps.len() == 1 { json!(comps[0]) } else { json!(comps) };
} }
actions.push(("keys.set".into(), params)); actions.push(("keys.transform".into(), params));
} }
if kresp.drag_stopped() { if kresp.drag_stopped() {
ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); 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$}"); let s = format!("{v:.dec$}");
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s } 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")
}

View File

@ -29,7 +29,8 @@ struct BoxDrag {
handle: usize, handle: usize,
/// Fixed opposite point (comp s, value). /// Fixed opposite point (comp s, value).
anchor: [f64; 2], 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], last: [f64; 2],
} }
@ -134,7 +135,7 @@ pub(crate) fn transform_box(
}); });
} }
let Some(resp) = resp_any else { return }; let Some(resp) = resp_any else { return };
let Some(mut st) = ctx.data(|d| d.get_temp::<BoxDrag>(box_id())) else { return }; let Some(st) = ctx.data(|d| d.get_temp::<BoxDrag>(box_id())) else { return };
if (st.handle == 8) == handles { if (st.handle == 8) == handles {
return; return;
} }
@ -142,21 +143,20 @@ pub(crate) fn transform_box(
&& let Some(pt) = resp.interact_pointer_pos() && let Some(pt) = resp.interact_pointer_pos()
{ {
let cur = data(pt); let cur = data(pt);
let mut params = json!({"merge": "graph-box"}); let mut params = json!({"merge": "graph-box", "fromStart": true});
if st.handle == 8 { if st.handle == 8 {
params["timeOffset"] = json!(cur[0] - st.last[0]); params["timeOffset"] = json!(cur[0] - st.last[0]);
if value_ok { if value_ok {
params["valueOffset"] = json!(cur[1] - st.last[1]); params["valueOffset"] = json!(cur[1] - st.last[1]);
} }
st.last = cur;
} else { } else {
let (time_axis, value_axis) = match st.handle { let (time_axis, value_axis) = match st.handle {
4 | 6 => (false, true), 4 | 6 => (false, true),
5 | 7 => (true, false), 5 | 7 => (true, false),
_ => (true, true), _ => (true, true),
}; };
// Scale relative to where the handle is now (incremental, so merged steps compose). // Scale relative to where the handle was when the drag started.
let now = corner(st.handle); let now = st.last;
if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 { if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 {
let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]); let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]);
if k > 0.01 { if k > 0.01 {
@ -250,6 +250,8 @@ struct AltScale {
anchor: f64, anchor: f64,
/// The dragged end is the group's last key (else its first). /// The dragged end is the group's last key (else its first).
end_is_max: bool, end_is_max: bool,
/// The group's span when the drag started (set on its first step).
span: Option<f64>,
} }
fn alt_id() -> egui::Id { 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 { 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 Some((lo, hi)) = sel_range(app, comp) else { return false };
let st = if (key_ct - hi).abs() < 1e-6 { 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 { } 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 { } else {
return false; 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 (Some((lo, hi)), Some(px)) = (sel_range(app, comp), ctx.input(|i| i.pointer.latest_pos())) else { return };
let fr = comp.frame_rate; 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 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 }; // The group's span when the drag started: every step sends the whole scale since then.
let span = end - st.anchor; 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; let want = target - st.anchor;
if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() { if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() {
return; return;
} }
let k = want / span; actions.push(("keys.transform".into(), json!({"timeScale": want / span, "timeAnchor": st.anchor, "merge": "key-alt-scale", "fromStart": true})));
if (k - 1.0).abs() > 1e-6 {
actions.push(("keys.transform".into(), json!({"timeScale": k, "timeAnchor": st.anchor, "merge": "key-alt-scale"})));
}
} }
/// An Alt-drag group scale is in progress (the plain key move stands aside). /// An Alt-drag group scale is in progress (the plain key move stands aside).

View File

@ -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); 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() { if !app.session.state.selected_keys.is_empty() {
p.text( // One key: its property, time and value (as After Effects' Info panel shows them).
pos2(x0, ly + 18.0), let sel = &app.session.state.selected_keys;
Align2::LEFT_CENTER, let one = match sel.as_slice() {
format!("{} keyframes selected", app.session.state.selected_keys.len()), [k] => c.layer(k.layer).and_then(|l| {
Tokens::ui(11.5), let pr = l.props.find(k.prop)?;
t.text_dim, 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);
} }
} }
} }

View File

@ -7,7 +7,7 @@
//! bar, layer duration bars, keyframes, the CTI, and the value graph editor. //! bar, layer duration bars, keyframes, the CTI, and the value graph editor.
use effectcraft_engine::color::BlendMode; 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::project::{Comp, GroupKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property};
use effectcraft_engine::render::EvalCtx; use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick; 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 { fn layer_drag_id() -> egui::Id {
egui::Id::new("tl-layer-drag") 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. /// Set on the frame the reorder drag is released.
fn layer_drop_id() -> egui::Id { fn layer_drop_id() -> egui::Id {
egui::Id::new("tl-layer-drop") 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 if ks => t.keyframe_selected,
None => t.keyframe, 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 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()); 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)); app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
if kresp.clicked() || kresp.drag_started() { let mods = ui.input(|i| i.modifiers);
let shift = ui.input(|i| i.modifiers.shift); let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]});
if !ks || shift { if kresp.clicked() && mods.command {
actions.push(( // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
"keys.select".into(), actions.push(("keys.select".into(), this_key.clone()));
json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "add": shift}), 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() { 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| { kresp.context_menu(|ui| {
for (lbl, cmd, params) in [ 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!({})), ("Copy", "keys.copy", json!({})),
("Paste", "keys.paste", json!({})), ("Paste", "keys.paste", json!({})),
("-", "", 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 Interpolation…", "keys.interpolation", json!({})),
("Keyframe Velocity…", "keys.velocity", json!({})), ("Keyframe Velocity…", "keys.velocity", json!({})),
("Toggle Hold Keyframe", "keys.toggleHold", 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 { if lbl == "Rove Across Time" && !prop.spatial {
continue; continue;
} }
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
continue;
}
if ui.button(lbl).clicked() { if ui.button(lbl).clicked() {
if !ks { if !ks {
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}))); 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()); super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
} }
let alt_scaling = super::graph_tools::alt_scale_active(&ctx); // A plain drag moves the selected keys. The timeline follows it (below): this
if kresp.dragged() && !alt_scaling { // widget's id changes with the key's time, so its own drag would end after
let acc_id = egui::Id::new("key-drag-acc"); // the first frame of movement.
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); // (From where the button went down: a drag starts a few pixels later.)
acc += kresp.drag_delta().x; if kresp.drag_started()
let frames = ((acc as f64 / pps) / fd).trunc() as i64; && !super::graph_tools::alt_scale_active(&ctx)
if frames != 0 { && let Some(p) = ctx.input(|i| i.pointer.press_origin())
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(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0 }));
}
ctx.data_mut(|d| d.insert_temp(acc_id, acc));
}
if kresp.drag_stopped() {
ctx.data_mut(|d| d.remove::<f32>(egui::Id::new("key-drag-acc")));
ui_actions.push(UiAct::EndMerge);
} }
} }
} }
@ -2031,6 +2069,49 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id())); ctx.data_mut(|d| d.remove::<Vec<u64>>(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::<KeyDrag>(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<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 {
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::<KeyDrag>(key_drag_id()));
ui_actions.push(UiAct::EndMerge);
}
}
}
if empty.drag_stopped() if empty.drag_stopped()
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-box"))) && let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-box")))
{ {

View File

@ -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) { fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time); let now = ctx.input(|i| i.time);
let id = egui::Id::new("prefs-memory-watch"); let id = egui::Id::new("prefs-memory-watch");
let last = ctx.data(|d| d.get_temp::<f64>(id)); let last = ctx.data(|d| d.get_temp::<f64>(id));
if last.is_some_and(|t| now - t < 10.0) { if last.is_some_and(|t| now - t < 30.0) {
return; return;
} }
ctx.data_mut(|d| d.insert_temp(id, now)); ctx.data_mut(|d| d.insert_temp(id, now));

View File

@ -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<f64> {
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<Pos2> {
let mut ks: Vec<Pos2> =
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<String> = 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<f64> {
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));
}

View File

@ -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). (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 * **Expressions**: `set_property`/`get_property` replies carry `evaluated` (what renders) next to
`value` (the keyframed value) and `expressionError` when the expression fails. `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}]}`, * **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}`. then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`.
* **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or * **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or

View File

@ -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 wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script
`Socket` validates ports, and ScriptUI resource strings accept trailing array commas. `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 ## Update: M4.9–M4.11 nested comps and motion blur
- **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and - **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and

View File

@ -124,7 +124,7 @@ Commands (CLI, MCP, control channel):
sample rate and read back instead of decoding again sample rate and read back instead of decoding again
- `memory.ramReservedGb`: the cache budgets together leave this much physical memory free - `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 - `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.enableOutput`, `video.device`, `video.outputDuringPlayback`, `video.mirrorOnMonitor`,
`video.disableWhenBackground`: Video Preview, a second window (or full screen on the display `video.disableWhenBackground`: Video Preview, a second window (or full screen on the display
it is on) showing the composition frame, Mercury Transmit-style it is on) showing the composition frame, Mercury Transmit-style