effectcraft/crates/engine/src/commands/anim.rs

415 lines
18 KiB
Rust

//! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key
//! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs.
use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease};
use effectcraft_project::{LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::prop::{edit_keys, 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};
fn has_key_clip(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.key_clipboard.is_empty() { Err("no keyframes have been copied".into()) } else { Ok(()) }
}
/// Copy the selected keyframes (Edit ▸ Copy with keys selected).
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &s.state.selected_keys {
groups.entry((k.layer, k.prop)).or_default().push(k.time);
}
// Earliest selected key in comp time: pasted keys keep their offsets from it.
let mut earliest: Option<Tick> = None;
for ((lid, _), times) in &groups {
if let Some(l) = comp.layer(*lid) {
for t in times {
let ct = l.comp_time(*t);
earliest = Some(earliest.map_or(ct, |e: Tick| e.min(ct)));
}
}
}
let earliest = earliest.ok_or_else(|| bad("keys.copy", "select keyframes first"))?;
let mut clip = vec![];
for ((lid, uid), times) in groups {
let Some(l) = comp.layer(lid) else { continue };
let Some(pr) = l.props.find(uid) else { continue };
let Some(path) = l.props.match_path_of(uid) else { continue };
let mut keys: Vec<Keyframe> = pr.keys.iter().filter(|k| times.contains(&k.time)).cloned().collect();
for k in &mut keys {
k.time = l.comp_time(k.time) - earliest;
}
clip.push(KeyClip { layer: lid, path, keys });
}
let n: usize = clip.iter().map(|c| c.keys.len()).sum();
s.state.key_clipboard = clip;
s.state.clip_is_keys = true;
Ok(json!(n))
}
/// Values of the same kind (and dimensionality) can receive each other's keys.
fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool {
a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len()
}
/// Paste copied keyframes at the current time into the target layers' properties.
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
let clip = s.state.key_clipboard.clone();
if clip.is_empty() {
return Err(bad("keys.paste", "no keyframes have been copied"));
}
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let t = time_p(s, p, Some(comp));
let (_, mut targets) = layers_p(s, p)?;
if targets.is_empty() {
targets = clip.iter().map(|c| c.layer).collect();
targets.dedup();
}
let explicit = p.get("prop").and_then(Value::as_u64).or_else(|| {
let path = str_p(p, "path")?;
comp.layer(*targets.first()?)?.props.prop(path).map(|pr| pr.uid)
});
// (layer, prop uid, clip index)
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
for lid in &targets {
let Some(l) = comp.layer(*lid) else { continue };
for (ci, c) in clip.iter().enumerate() {
let Some(sample) = c.keys.first().map(|k| &k.value) else { continue };
let target = if clip.len() == 1 {
explicit
.and_then(|u| l.props.find(u))
.or_else(|| {
s.state.selected_props.iter().filter(|(sl, _)| sl == lid).filter_map(|(_, u)| l.props.find(*u)).find(|pr| compatible(pr, sample))
})
.or_else(|| l.props.prop(&c.path))
} else {
l.props.prop(&c.path)
};
if let Some(pr) = target.filter(|pr| compatible(pr, sample) && !pr.static_only) {
plan.push((*lid, pr.uid, ci));
}
}
}
if plan.is_empty() {
return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)"));
}
s.edit("Paste Keyframes", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
for (lid, uid, ci) in &plan {
let Some(l) = comp.layer_mut(*lid) else { continue };
let times: Vec<Tick> = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect();
let Some(pr) = l.props.find_mut(*uid) else { continue };
for (k, lt) in clip[*ci].keys.iter().zip(times) {
let mut k = k.clone();
k.time = lt;
if let Some(v) = pr.value.coerce_json(&k.value.to_json()) {
k.value = v;
}
if pr.hold_only {
k = k.hold();
}
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
Ok(i) => pr.keys[i] = k,
Err(i) => pr.keys.insert(i, k),
}
sel.push(KeyRef { layer: *lid, prop: *uid, time: lt });
}
retime_roving(&mut pr.keys, pr.spatial);
}
st.selected_keys = sel;
Ok(json!(st.selected_keys.len()))
})
}
/// Select every keyframe of the selected properties, or of the given / selected layers.
fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, mut layers) = layers_p(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let mut sel = vec![];
if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() {
for (lid, uid) in &s.state.selected_props {
if let Some(pr) = comp.layer(*lid).and_then(|l| l.props.find(*uid)) {
sel.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
}
}
} else {
if layers.is_empty() {
layers = comp.layers.iter().map(|l| l.id).collect();
}
for l in comp.layers.iter().filter(|l| layers.contains(&l.id)) {
l.props.walk("", &mut |_, pr| sel.extend(pr.keys.iter().map(|k| KeyRef { layer: l.id, prop: pr.uid, time: k.time })));
}
}
s.state.selected_keys = sel;
Ok(json!(s.state.selected_keys.len()))
}
/// Move the selected keys by whole frames (Alt+→ / Alt+←, Shift for 10 frames).
fn nudge(s: &mut Session, p: &Value) -> Result<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
})
}
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
fn set_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "keys.set")?;
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.set", "missing `time` (layer time of the key)"))?);
let new_t = f_p(p, "newTime").map(Tick::from_seconds_f64);
let value = p.get("value").cloned();
let fr = s.project.comp(cid).map(|c| c.frame_rate);
s.edit("Edit Keyframe", merge_p(p), |proj, st| {
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.set", "no property"))?;
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.set", "the property has no keyframes"))?;
let old_t = pr.keys[i].time;
if let Some(v) = &value {
let nv = pr.keys[i].value.coerce_json(v).ok_or_else(|| bad("keys.set", format!("can't use {v} here")))?;
pr.keys[i].value = nv;
}
let mut nt = old_t;
if let Some(n) = new_t {
nt = fr.map(|r| r.snap_nearest(n)).unwrap_or(n);
// Keys can't pass their neighbours or share a time.
let lo = if i > 0 { pr.keys[i - 1].time.0 + 1 } else { i64::MIN };
let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0 - 1 } else { i64::MAX };
nt = Tick(nt.0.clamp(lo, hi));
pr.keys[i].time = nt;
}
retime_roving(&mut pr.keys, pr.spatial);
let nt = pr.keys.get(i).map(|k| k.time).unwrap_or(nt);
for k in st.selected_keys.iter_mut().filter(|k| k.layer == lid && k.prop == uid && k.time == old_t) {
k.time = nt;
}
Ok(json!({"time": nt.seconds()}))
})
}
fn nearest_key(keys: &[Keyframe], t: Tick) -> Option<usize> {
if let Some(i) = key_at(keys, t) {
return Some(i);
}
keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i)
}
/// Set one side's temporal ease of a key (Graph Editor handle drags, scripting).
fn set_ease(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "keys.setEase")?;
let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setEase", "missing `time`"))?);
let out = match str_p(p, "side") {
Some("in") => false,
Some("out") => true,
_ => return Err(bad("keys.setEase", "`side` must be in or out")),
};
let dim = p.get("dim").and_then(Value::as_u64).map(|d| d as usize);
let speed = f_p(p, "speed");
let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0));
s.edit("Keyframe Velocity", merge_p(p), |proj, _| {
let l = super::layer_mut(proj, cid, lid)?;
let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?;
let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?;
let spatial = pr.spatial;
let n = if spatial && matches!(pr.keys[i].value, effectcraft_keyframe::Value::Vec2(_) | effectcraft_keyframe::Value::Vec3(_)) {
1
} else {
pr.keys[i].value.dims().max(1)
};
// Start from what the side presents now so untouched dimensions keep their shape.
let cur: Vec<Ease> = (0..n).map(|d| side_ease(&pr.keys, i, d, spatial, out).unwrap_or_default()).collect();
let k = &mut pr.keys[i];
let list = if out { &mut k.out_ease } else { &mut k.in_ease };
if list.len() != n {
*list = cur.clone();
}
for (d, e) in list.iter_mut().enumerate() {
if dim.is_some_and(|x| x != d) && n > 1 {
continue;
}
if let Some(v) = speed {
e.speed = v;
}
if let Some(v) = influence {
e.influence = v;
}
}
if out {
k.out_interp = Interp::Bezier;
} else {
k.in_interp = Interp::Bezier;
}
k.auto_bezier = false;
// Continuous Bezier keys keep matching speeds on both sides.
if k.continuous {
let src = if out { k.out_ease.clone() } else { k.in_ease.clone() };
let other = if out { &mut k.in_ease } else { &mut k.out_ease };
if other.len() != src.len() {
*other = src.clone();
}
for (o, sv) in other.iter_mut().zip(&src) {
o.speed = sv.speed;
}
k.in_interp = Interp::Bezier;
k.out_interp = Interp::Bezier;
}
Ok(Value::Null)
})
}
/// Speed units for a property (Keyframe Velocity dialog).
fn units(pr: &Property) -> &'static str {
use effectcraft_project::ParamUi;
if pr.spatial {
return "pixels/sec";
}
match pr.ui {
ParamUi::Angle => "degrees/sec",
ParamUi::Percent => "%/sec",
ParamUi::Slider { .. } if pr.match_id == "opacity" => "%/sec",
_ if pr.match_id == "opacity" => "%/sec",
ParamUi::Pixels => "pixels/sec",
_ => "units/sec",
}
}
fn interp_name(i: Interp) -> &'static str {
match i {
Interp::Linear => "linear",
Interp::Bezier => "bezier",
Interp::Hold => "hold",
}
}
/// Describe the selected (or given) keys: interpolation, eases per side and dimension, units.
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let refs: Vec<KeyRef> = match p.get("keys") {
Some(Value::Array(a)) => a
.iter()
.filter_map(|k| {
let l = super::resolve_layer(comp, k.get("layer")?)?;
Some(KeyRef { layer: l, prop: k.get("prop")?.as_u64()?, time: Tick::from_seconds_f64(k.get("time")?.as_f64()?) })
})
.collect(),
_ => s.state.selected_keys.clone(),
};
let mut out = vec![];
for r in refs {
let Some(l) = comp.layer(r.layer) else { continue };
let Some(pr) = l.props.find(r.prop) else { continue };
let Some(i) = nearest_key(&pr.keys, r.time) else { continue };
let k = &pr.keys[i];
let spatial = pr.spatial && matches!(k.value, effectcraft_keyframe::Value::Vec2(_) | effectcraft_keyframe::Value::Vec3(_));
let dims = if spatial { 1 } else { k.value.dims().max(1).min(if pr.shown_dims > 0 && !l.is_3d() { pr.shown_dims as usize } else { 4 }) };
let side = |out: bool| -> Value {
json!(
(0..dims)
.map(|d| side_ease(&pr.keys, i, d, pr.spatial, out)
.map(|e| json!({"speed": e.speed, "influence": e.influence * 100.0}))
.unwrap_or(Value::Null))
.collect::<Vec<_>>()
)
};
let temporal = if k.auto_bezier {
"autoBezier"
} else if k.continuous {
"continuousBezier"
} else if k.in_interp == k.out_interp {
interp_name(k.in_interp)
} else {
"mixed"
};
let spatial_kind = if !spatial {
Value::Null
} else if k.spatial_auto {
json!("autoBezier")
} else if k.spatial_continuous {
json!("continuousBezier")
} else if k.spatial_in == [0.0; 3] && k.spatial_out == [0.0; 3] {
json!("linear")
} else {
json!("bezier")
};
out.push(json!({
"layer": l.id.0, "prop": pr.uid, "name": pr.name, "path": l.props.match_path_of(pr.uid),
"time": k.time.seconds(), "compTime": l.comp_time(k.time).seconds(), "value": k.value.to_json(),
"in": interp_name(k.in_interp), "out": interp_name(k.out_interp), "temporal": temporal,
"autoBezier": k.auto_bezier, "continuous": k.continuous, "roving": k.roving, "spatial": spatial_kind,
"dims": dims, "units": units(pr), "inEase": side(false), "outEase": side(true),
}));
}
Ok(json!(out))
}
/// Keyframe Assistant ▸ Time-Reverse Keyframes: mirror the selected keys of each property.
fn time_reverse(s: &mut Session, _: &Value) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let sel = s.state.selected_keys.clone();
s.edit("Time-Reverse Keyframes", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &sel {
groups.entry((k.layer, k.prop)).or_default().push(k.time);
}
let mut new_sel = vec![];
for ((lid, uid), times) in groups {
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
let (mut picked, rest): (Vec<Keyframe>, Vec<Keyframe>) = pr.keys.drain(..).partition(|k| times.contains(&k.time));
effectcraft_keyframe::time_reverse(&mut picked);
new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }));
pr.keys = rest;
for k in picked {
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
Ok(i) => pr.keys[i] = k,
Err(i) => pr.keys.insert(i, k),
}
}
}
st.selected_keys = new_sel;
Ok(json!(st.selected_keys.len()))
})
}
fn keys_or_layers(s: &Session) -> std::result::Result<(), String> {
has_comp(s)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("keys.copy", "Copy Keyframes", [], None, "{}", has_keys, copy),
cmd!("keys.paste", "Paste Keyframes", [], None, "{layers?, prop?|path?, time?}", has_key_clip, paste),
cmd!("keys.selectAll", "Select All Keyframes", ["Edit"], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all),
cmd!("keys.nudge", "Nudge Keyframes", [], None, "{frames, merge?}", has_keys, nudge),
cmd!("keys.nudgeForward", "Move Keyframes 1 Frame Later", [], Some("Alt+ArrowRight"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": 1}))),
cmd!("keys.nudgeBackward", "Move Keyframes 1 Frame Earlier", [], Some("Alt+ArrowLeft"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": -1}))),
cmd!("keys.nudgeForward10", "Move Keyframes 10 Frames Later", [], Some("Alt+Shift+ArrowRight"), "{}", has_keys, |s, _| nudge(
s,
&json!({"frames": 10})
)),
cmd!("keys.nudgeBackward10", "Move Keyframes 10 Frames Earlier", [], Some("Alt+Shift+ArrowLeft"), "{}", has_keys, |s, _| nudge(
s,
&json!({"frames": -10})
)),
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
cmd!(
"keys.setEase",
"Set Keyframe Ease",
[],
None,
"{layer?, path|prop, time (layer s), side: in|out, dim?, speed?, influence? %, merge?}",
has_comp,
set_ease
),
cmd!("keys.timeReverse", "Time-Reverse Keyframes", ["Animation", "Keyframe Assistant"], None, "{}", has_keys, time_reverse),
query!("keys.info", "Keyframe Info", "{keys?: [{layer, prop, time}]}", info),
]
}