415 lines
18 KiB
Rust
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),
|
|
]
|
|
}
|