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

816 lines
35 KiB
Rust

//! Properties, keyframes and expressions (Animation menu + timeline/Effect Controls gestures).
use effectcraft_keyframe::{Ease, Interp, Keyframe, easy_ease, key_at, set_key};
use effectcraft_project::{GroupKind, ItemId, Layer, LayerId, Property, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, merge_p, str_p};
use crate::{EngineError, KeyRef, Result, Session, VertexRef, cmd, query};
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> {
let (cid, lid) = layer_p(s, p, cmd)?;
let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
if let Some(u) = p.get("prop").and_then(Value::as_u64) {
return layer.props.find(u).map(|pr| (cid, lid, pr.uid)).ok_or_else(|| bad(cmd, format!("no property @{u}")));
}
let path = str_p(p, "path").ok_or_else(|| bad(cmd, "missing `path` (e.g. transform/position) or `prop` uid"))?;
let pr = layer.props.prop(path).ok_or_else(|| bad(cmd, format!("no property `{path}`")))?;
Ok((cid, lid, pr.uid))
}
fn with_prop<T>(
s: &mut Session,
label: &str,
merge: Option<&str>,
cid: ItemId,
lid: LayerId,
uid: Uid,
f: impl FnOnce(&mut Property, Tick) -> Result<T>,
) -> Result<T> {
let t = s.time();
let linear = s.prefs.general.default_spatial_linear;
s.edit(label, merge, |proj, _| {
let l = layer_mut(proj, cid, lid)?;
let lt = l.layer_time(t);
let pr = l.props.find_mut(uid).ok_or_else(|| bad("prop", "property vanished"))?;
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
let r = f(pr, lt)?;
// Settings ▸ General ▸ Default Spatial Interpolation to Linear: new keys of spatial
// properties get straight motion paths instead of Auto Bezier.
if linear && pr.spatial {
for k in pr.keys.iter_mut().filter(|k| !before.contains(&k.time)) {
k.spatial_auto = false;
k.spatial_in = [0.0; 3];
k.spatial_out = [0.0; 3];
}
}
Ok(r)
})
}
/// An explicit key time (layer seconds) moved onto the nearest comp frame.
fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Option<Tick> {
let t = Tick::from_seconds_f64(f_p(p, "time")?);
let comp = s.project.comp(cid)?;
let l = comp.layer(lid)?;
Some(l.layer_time(comp.frame_rate.snap_nearest(l.comp_time(t))))
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.set")?;
// Position with Separate Dimensions: write the X/Y/Z Position properties instead.
if let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid))
&& let Some(tr) = l.transform()
&& tr.get("position").is_some_and(|pr| pr.uid == uid)
&& tr.get("positionX").is_some()
&& let Some(Value::Array(a)) = p.get("value")
{
let mut out = vec![];
let three = l.is_3d();
for (d, m) in ["positionX", "positionY", "positionZ"].iter().enumerate() {
if d == 2 && (!three || a.len() < 3) {
continue;
}
if let Some(v) = a.get(d) {
let mut q = p.clone();
q["path"] = json!(format!("transform/{m}"));
q["layer"] = json!(lid.0);
if let Some(o) = q.as_object_mut() {
o.remove("prop");
}
q["value"] = v.clone();
out.push(set(s, &q)?);
}
}
return Ok(json!(out));
}
let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone();
let at = snapped_key_time(s, cid, lid, p);
let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| {
let cur = pr.value_at(lt);
let mut nv = cur.coerce_json(&v).ok_or_else(|| bad("prop.set", format!("can't use {v} for a {} property", cur.kind_name())))?;
// Clamp to slider ranges.
if let effectcraft_project::ParamUi::Slider { min, max, .. } = pr.ui
&& let effectcraft_keyframe::Value::Scalar(x) = nv
{
nv = effectcraft_keyframe::Value::Scalar(x.clamp(min, max));
}
pr.set_value_at(at.unwrap_or(lt), nv.clone());
Ok(nv.to_json())
})?;
Ok(out)
}
/// Read one property: value at `time` (comp seconds, default the CTI), keyframes and expression.
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.get")?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let pr = l.props.find(uid).ok_or_else(|| bad("prop.get", "property vanished"))?;
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
let keys: Vec<Value> =
pr.keys.iter().map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})).collect();
let raw = pr.value_at(l.layer_time(t));
let mut out = json!({
"layer": lid.0, "uid": pr.uid, "match": pr.match_id, "name": pr.name, "type": pr.value.kind_name(),
"time": t.seconds(), "value": raw.to_json(), "animated": !pr.keys.is_empty(),
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
});
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
// expression makes of it (what renders), or why it fails. A disabled one renders `value`; one
// disabled by its syntax error (prop.setExpression) reports that error.
if let Some(e) = pr.expr.as_ref().filter(|e| !e.enabled && !e.text.trim().is_empty()) {
out["evaluated"] = raw.to_json();
if let Some(err) = s.expr_check.and_then(|check| check(&e.text).err()) {
out["expressionError"] = json!(err);
}
} else if pr.has_expression()
&& let (Some(h), Some(comp)) = (s.expr.as_deref(), s.project.comp(cid))
{
let mut ctx = crate::render::EvalCtx::new(&s.project, cid, comp, t);
ctx.expr = Some(h);
match h.eval(&ctx, l, pr, &raw) {
Ok(v) => out["evaluated"] = v.to_json(),
Err(e) => {
out["evaluated"] = raw.to_json();
out["expressionError"] = json!(e);
}
}
}
Ok(out)
}
fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.toggleAnimation")?;
let on = b_p(p, "value");
with_prop(s, "Toggle Animation", None, cid, lid, uid, |pr, lt| {
if pr.static_only {
return Err(bad("prop.toggleAnimation", "this property can't be animated"));
}
let target = on.unwrap_or(pr.keys.is_empty());
pr.set_animated(target, lt);
Ok(json!(target))
})
}
fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.addKey")?;
let at = snapped_key_time(s, cid, lid, p);
let value = p.get("value").cloned();
with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| {
let t = at.unwrap_or(lt);
let cur = pr.value_at(t);
let v = match &value {
Some(j) => cur.coerce_json(j).ok_or_else(|| bad("prop.addKey", "bad value"))?,
None => cur,
};
let mut k = Keyframe::new(t, v);
if pr.hold_only {
k = k.hold();
}
set_key(&mut pr.keys, k);
Ok(json!(pr.keys.len()))
})
}
fn toggle_key(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.toggleKey")?;
with_prop(s, "Add/Remove Keyframe", None, cid, lid, uid, |pr, lt| Ok(json!(toggle_key_at(pr, lt))))
}
/// Add a keyframe at layer time `lt` (true), or remove the one there (false).
fn toggle_key_at(pr: &mut Property, lt: Tick) -> bool {
if let Some(i) = key_at(&pr.keys, lt) {
if pr.keys.len() == 1 {
pr.value = pr.keys[0].value.clone();
}
pr.keys.remove(i);
false
} else {
let mut k = Keyframe::new(lt, pr.value_at(lt));
if pr.hold_only {
k = k.hold();
}
set_key(&mut pr.keys, k);
true
}
}
/// Alt+Shift+A / P / S / R / T: add or remove a keyframe at the current time on that Transform
/// property of every selected layer, in one undo step. Position covers separated X / Y / Z;
/// Rotation on a 3D layer covers Orientation and X / Y / Z Rotation.
fn toggle_transform_key(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "keys.toggleTransform";
let which = str_p(p, "prop").ok_or_else(|| bad(C, "missing `prop` (anchor | position | scale | rotation | opacity)"))?.to_string();
let (cid, layers) = layers_p(s, p)?;
let t = s.time_of(cid);
let mut added = vec![];
s.edit("Add/Remove Keyframe", None, |proj, _| {
for lid in &layers {
let l = layer_mut(proj, cid, *lid)?;
let lt = l.layer_time(t);
let three = l.is_3d();
let Some(tr) = l.props.sub_mut("transform") else { continue };
let ids: Vec<&str> = match which.as_str() {
"anchor" => vec!["anchor"],
"position" if tr.get("positionX").is_some() => vec!["positionX", "positionY", "positionZ"],
"position" => vec!["position"],
"scale" => vec!["scale"],
"rotation" if three => vec!["orientation", "rotationX", "rotationY", "rotation"],
"rotation" => vec!["rotation"],
"opacity" => vec!["opacity"],
other => return Err(bad(C, format!("unknown prop `{other}` (anchor | position | scale | rotation | opacity)"))),
};
for id in ids.into_iter().filter(|id| three || *id != "positionZ") {
if let Some(pr) = tr.get_mut(id) {
added.push(json!({"layer": lid.0, "prop": id, "key": toggle_key_at(pr, lt)}));
}
}
}
Ok(())
})?;
Ok(Value::Array(added))
}
fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.setExpression")?;
let text = p.get("expression").and_then(Value::as_str).map(str::to_string);
let mut enabled = b_p(p, "enabled");
// A syntax error keeps the text but disables the expression (AE shows the warning bar).
let error = match (&text, s.expr_check) {
(Some(t), Some(check)) if !t.trim().is_empty() => check(t).err(),
_ => None,
};
if error.is_some() {
enabled = Some(false);
}
// Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`).
let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid, true))).unwrap_or_else(|| "value".into());
let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| {
match (&text, enabled) {
(Some(t), _) if t.trim().is_empty() => pr.expr = None,
(Some(t), e) => pr.expr = Some(effectcraft_project::Expression { text: t.clone(), enabled: e.unwrap_or(true) }),
(None, Some(e)) => {
if let Some(x) = &mut pr.expr {
x.enabled = e;
} else if e {
pr.expr = Some(effectcraft_project::Expression { text: def.clone(), enabled: true });
}
}
(None, None) => {
pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: def.clone(), enabled: true }) };
}
}
Ok(json!(pr.expr.as_ref().map(|e| e.text.clone())))
})?;
if let Some(e) = error {
s.toast(format!("Expression disabled: {e}"));
return Ok(json!({"expression": r, "error": e}));
}
Ok(r)
}
fn reset(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.reset")?;
let def = p.get("default").cloned();
with_prop(s, "Reset Property", None, cid, lid, uid, |pr, _| {
pr.keys.clear();
pr.expr = None;
if let Some(d) = def.as_ref().and_then(|d| pr.value.coerce_json(d)) {
pr.value = d;
}
Ok(Value::Null)
})
}
fn select_prop(s: &mut Session, p: &Value) -> Result<Value> {
let (_, lid) = layer_p(s, p, "prop.select")?;
let uid = match p.get("prop").and_then(Value::as_u64) {
Some(u) => u,
None => prop_ref(s, p, "prop.select")?.2,
};
let add = b_p(p, "add").unwrap_or(false);
if !add {
s.state.selected_props.clear();
s.state.selected_vertices.clear();
}
if !s.state.selected_layers.contains(&lid) {
s.state.selected_layers = vec![lid];
}
s.state.selected_props.push((lid, uid));
// Selecting a mask (or its Mask Path, or a shape's Path) selects all its points, so the
// viewer drags the whole mask (as in AE, #203).
let t = s.time();
if let Some((mask, n)) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| path_points(l, uid, l.layer_time(t))) {
s.state.selected_vertices.retain(|v| !(v.layer == lid && v.mask == mask));
s.state.selected_vertices.extend((0..n).map(|index| VertexRef { layer: lid, mask, index }));
}
// Selecting a property selects all its keys (as in AE).
if b_p(p, "selectKeys").unwrap_or(true)
&& let Some(pr) = s.active_comp().and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid))
{
let keys: Vec<KeyRef> = pr.keys.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
if !add {
s.state.selected_keys.clear();
}
s.state.selected_keys.extend(keys);
}
Ok(Value::Null)
}
/// The mask or shape Path item that `uid` (the group, or its Path property) is, with its number
/// of points at layer time `lt`.
fn path_points(l: &Layer, uid: Uid, lt: Tick) -> Option<(Uid, usize)> {
let owner = match l.props.find_group(uid) {
Some(g) => g,
None => l.props.parent_of(uid).filter(|g| g.get("path").is_some_and(|p| p.uid == uid))?,
};
let in_contents = owner.match_id == "path" && l.props.sub("contents").is_some_and(|c| c.find_group(owner.uid).is_some());
if !matches!(owner.kind, GroupKind::Mask { .. }) && !in_contents {
return None;
}
match owner.get("path")?.value_at(lt) {
effectcraft_keyframe::Value::Path(p) => Some((owner.uid, p.vertices.len())),
_ => None,
}
}
// ---------------------------------------------------------------- keyframes
fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
let mut sel = vec![];
if let Some(Value::Array(keys)) = p.get("keys") {
for k in keys {
// `{layer, prop: uid | path, time}` (or `path`); anything that matches nothing is an
// error rather than a silently empty selection.
let lv = k.get("layer").ok_or_else(|| bad("keys.select", format!("{k}: missing `layer`")))?;
let l = super::resolve_layer(comp, lv).ok_or_else(|| bad("keys.select", format!("no layer {lv}")))?;
let ly = comp.layer(l).ok_or(EngineError::NoComp)?;
let pr = match (k.get("prop"), k.get("path").and_then(Value::as_str)) {
(Some(Value::Number(n)), _) => n.as_u64().and_then(|u| ly.props.find(u)),
(Some(Value::String(path)), _) => ly.props.prop(path),
(None, Some(path)) => ly.props.prop(path),
_ => return Err(bad("keys.select", format!("{k}: give `prop` (uid) or `path`"))),
}
.ok_or_else(|| bad("keys.select", format!("{k}: no such property on layer {lv}")))?;
let t = k.get("time").and_then(Value::as_f64).map(Tick::from_seconds_f64).ok_or_else(|| bad("keys.select", format!("{k}: missing `time`")))?;
// Snap to the stored key time.
let kt = pr
.keys
.iter()
.map(|k| k.time)
.min_by_key(|kt| (kt.0 - t.0).abs())
.ok_or_else(|| bad("keys.select", format!("`{}` has no keyframes", pr.name)))?;
sel.push(KeyRef { layer: l, prop: pr.uid, time: kt });
}
}
// Keys of locked layers can't be selected (so nothing edits them).
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
if b_p(p, "toggle").unwrap_or(false) {
// Shift+click: in and out of the selection.
for k in sel {
match s.state.selected_keys.iter().position(|x| *x == k) {
Some(i) => {
s.state.selected_keys.remove(i);
}
None => s.state.selected_keys.push(k),
}
}
} else if b_p(p, "add").unwrap_or(false) {
for k in sel {
if !s.state.selected_keys.contains(&k) {
s.state.selected_keys.push(k);
}
}
} else {
s.state.selected_keys = sel;
}
Ok(json!(s.state.selected_keys.len()))
}
/// Apply `f` to every selected key; keeps the selection pointing at moved keys.
pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec<Keyframe>, usize, &mut Tick) -> bool) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let sel = s.state.selected_keys.clone();
s.edit(label, merge, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut new_sel = vec![];
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);
}
for ((lid, uid), times) in groups {
let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue };
// Process in an order that avoids collisions when moving.
let mut idx: Vec<(usize, Tick)> = times.iter().filter_map(|t| key_at(&pr.keys, *t).map(|i| (i, *t))).collect();
idx.sort_by_key(|x| x.0);
let mut moved: Vec<Keyframe> = vec![];
let mut keep_times = vec![];
for (i, _) in idx.iter().rev() {
let mut t = pr.keys[*i].time;
if f(&mut pr.keys, *i, &mut t) {
let mut k = pr.keys.remove(*i);
k.time = t;
moved.push(k);
} else if *i < pr.keys.len() {
keep_times.push(pr.keys[*i].time);
}
}
for k in moved {
new_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),
}
}
for t in keep_times {
new_sel.push(KeyRef { layer: lid, prop: uid, time: t });
}
// Roving keys follow their neighbours: re-time and 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 new_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 = new_sel;
Ok(json!(st.selected_keys.len()))
})
}
/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys
/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of
/// locked layers stay. A key landing on another key of its property replaces it.
pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
// A drag (steps with one merge key) starts again from the keys before it each step.
let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
let (from, total, base) = match (&s.key_move, s.history.undo.last()) {
(Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())),
_ => (s.state.selected_keys.clone(), d, None),
};
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
s.edit(label, merge, |proj, st| {
if let Some(b) = base {
*proj = (*b).clone();
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &from {
groups.entry((k.layer, k.prop)).or_default().push(k.time);
}
let mut sel = vec![];
for ((lid, uid), times) in groups {
let Some(l) = comp.layer_mut(lid) else { continue };
let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total));
let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect();
if l.switches.locked {
sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t }));
continue;
}
let Some(pr) = l.props.find_mut(uid) else { continue };
// Take the moving keys out, then put them back at their new times.
let mut moved = vec![];
for (old, new) in &moves {
if let Some(i) = key_at(&pr.keys, *old) {
let mut k = pr.keys.remove(i);
k.time = *new;
moved.push(k);
}
}
for k in moved {
sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
Ok(j) => pr.keys[j] = k,
Err(j) => pr.keys.insert(j, k),
}
}
// Roving keys follow their neighbours: keep the selection on them.
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) {
for r in sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) {
if let Some(i) = before.iter().position(|t| *t == r.time) {
r.time = pr.keys[i].time;
}
}
}
}
st.selected_keys = sel;
Ok(json!(st.selected_keys.len()))
})
}
fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
let d = Tick::from_seconds_f64(f_p(p, "delta").ok_or_else(|| bad("keys.move", "missing `delta` (seconds)"))?);
shift_keys(s, "Move Keyframes", merge_p(p), d)
}
fn delete_keys(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("Delete Keyframes", None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for k in &sel {
if let Some(pr) = comp.layer_mut(k.layer).and_then(|l| l.props.find_mut(k.prop))
&& let Some(i) = key_at(&pr.keys, k.time)
{
if pr.keys.len() == 1 {
pr.value = pr.keys[0].value.clone();
}
pr.keys.remove(i);
}
}
st.selected_keys.clear();
Ok(json!(sel.len()))
})
}
fn ease(s: &mut Session, p: &Value) -> Result<Value> {
let which = str_p(p, "which").unwrap_or("both").to_string();
edit_keys(s, "Easy Ease", None, move |keys, i, _| {
easy_ease(&mut keys[i], which != "out", which != "in");
false
})
}
fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
let parse = |k: &str| match str_p(p, k).map(|v| v.to_ascii_lowercase()) {
Some(v) if v == "linear" => Some(Interp::Linear),
Some(v) if v == "bezier" => Some(Interp::Bezier),
Some(v) if v == "hold" => Some(Interp::Hold),
_ => None,
};
let both = parse("interpolation");
let inn = parse("in").or(both);
let out = parse("out").or(both);
// Temporal interpolation by dialog name: linear|bezier|continuousBezier|autoBezier|hold.
let temporal = str_p(p, "interpolation").map(|v| v.to_ascii_lowercase());
let mut auto = b_p(p, "autoBezier");
let mut continuous = b_p(p, "continuous");
match temporal.as_deref() {
Some("autobezier" | "auto") => auto = Some(true),
Some("continuousbezier" | "continuous") => {
continuous = Some(true);
auto = Some(false);
}
Some("linear" | "bezier" | "hold") => {
auto = auto.or(Some(false));
continuous = continuous.or(Some(false));
}
_ => {}
}
let inn = if matches!(temporal.as_deref(), Some("autobezier" | "auto" | "continuousbezier" | "continuous")) { inn.or(Some(Interp::Bezier)) } else { inn };
let out = if matches!(temporal.as_deref(), Some("autobezier" | "auto" | "continuousbezier" | "continuous")) { out.or(Some(Interp::Bezier)) } else { out };
let spatial = str_p(p, "spatial").map(|v| v.to_ascii_lowercase());
let roving = b_p(p, "roving");
edit_keys(s, "Keyframe Interpolation", None, move |keys, i, _| {
if let Some(sp) = spatial.as_deref() {
let (tin, tout) = effectcraft_keyframe::spatial_tangents(keys, i);
let k = &mut keys[i];
match sp {
"linear" => {
k.spatial_auto = false;
k.spatial_continuous = false;
k.spatial_in = [0.0; 3];
k.spatial_out = [0.0; 3];
}
"bezier" | "continuous" | "continuousbezier" => {
k.spatial_auto = false;
k.spatial_in = tin;
k.spatial_out = tout;
k.spatial_continuous = sp != "bezier";
}
"auto" | "autobezier" => {
k.spatial_auto = true;
k.spatial_continuous = false;
}
_ => {}
}
}
if let Some(r) = roving {
keys[i].roving = r;
}
if let Some(c) = continuous {
keys[i].continuous = c;
}
let n = keys[i].value.dims().max(1);
if let Some(x) = inn {
keys[i].in_interp = x;
if x == Interp::Bezier && keys[i].in_ease.is_empty() {
keys[i].in_ease = vec![Ease::default(); n];
}
}
if let Some(x) = out {
keys[i].out_interp = x;
if x == Interp::Bezier && keys[i].out_ease.is_empty() {
keys[i].out_ease = vec![Ease::default(); n];
}
}
if let Some(a) = auto {
keys[i].auto_bezier = a;
if a {
keys[i].in_interp = Interp::Bezier;
keys[i].out_interp = Interp::Bezier;
keys[i].continuous = false;
}
}
if keys[i].continuous && !keys[i].auto_bezier {
// Continuous Bezier: one speed through the key (average of both sides).
let k = &mut keys[i];
k.in_interp = Interp::Bezier;
k.out_interp = Interp::Bezier;
if k.in_ease.len() != n {
k.in_ease = vec![Ease::default(); n];
}
if k.out_ease.len() != n {
k.out_ease = vec![Ease::default(); n];
}
for d in 0..n {
let sp = (k.in_ease[d].speed + k.out_ease[d].speed) / 2.0;
k.in_ease[d].speed = sp;
k.out_ease[d].speed = sp;
}
}
false
})
}
fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| {
let k = &mut keys[i];
// 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
})
}
/// A number or a per-dimension array of numbers.
fn nums_p(p: &Value, k: &str, scale: f64) -> Option<Vec<f64>> {
match p.get(k)? {
Value::Array(a) => Some(a.iter().filter_map(Value::as_f64).map(|v| v * scale).collect()),
v => v.as_f64().map(|v| vec![v * scale]),
}
}
fn velocity(s: &mut Session, p: &Value) -> Result<Value> {
let mut in_speed = nums_p(p, "inSpeed", 1.0);
let mut in_inf = nums_p(p, "inInfluence", 0.01);
let mut out_speed = nums_p(p, "outSpeed", 1.0);
let mut out_inf = nums_p(p, "outInfluence", 0.01);
// "Continuous" in the dialog: outgoing velocity follows incoming.
let continuous = b_p(p, "continuous");
if continuous == Some(true) {
if out_speed.is_none() {
out_speed = in_speed.clone();
}
if in_speed.is_none() {
in_speed = out_speed.clone();
}
if out_inf.is_none() {
out_inf = in_inf.clone();
}
if in_inf.is_none() {
in_inf = out_inf.clone();
}
}
edit_keys(s, "Keyframe Velocity", merge_p(p), move |keys, i, _| {
let n = keys[i].value.dims().max(1);
// Current presentation of each side, so unspecified parts keep their values.
let cur = |keys: &[Keyframe], out: bool| -> Vec<Ease> {
(0..n).map(|d| effectcraft_keyframe::side_ease(keys, i, d, false, out).unwrap_or(Ease::EASY)).collect()
};
let cur_in = cur(keys, false);
let cur_out = cur(keys, true);
let k = &mut keys[i];
let apply = |list: &mut Vec<Ease>, base: Vec<Ease>, sp: &Option<Vec<f64>>, inf: &Option<Vec<f64>>| {
if list.len() != n {
*list = base;
}
for (d, e) in list.iter_mut().enumerate() {
if let Some(v) = sp.as_ref().and_then(|v| v.get(d).or(v.first())) {
e.speed = *v;
}
if let Some(v) = inf.as_ref().and_then(|v| v.get(d).or(v.first())) {
e.influence = v.clamp(0.001, 1.0);
}
}
};
if in_speed.is_some() || in_inf.is_some() {
k.in_interp = Interp::Bezier;
apply(&mut k.in_ease, cur_in, &in_speed, &in_inf);
}
if out_speed.is_some() || out_inf.is_some() {
k.out_interp = Interp::Bezier;
apply(&mut k.out_ease, cur_out, &out_speed, &out_inf);
}
if let Some(c) = continuous {
k.continuous = c;
}
k.auto_bezier = false;
false
})
}
fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.convertExpressionToKeyframes")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let layer = comp.layer(lid).ok_or(EngineError::NoComp)?.clone();
let pr = layer.props.find(uid).ok_or_else(|| bad("prop.convertExpressionToKeyframes", "no property"))?.clone();
let mut keys = vec![];
let fd = comp.frame_duration();
let mut t = layer.in_point;
while t < layer.out_point {
let ctx =
effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) };
keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr)));
t += fd;
}
let n = keys.len();
with_prop(s, "Convert Expression to Keyframes", None, cid, lid, uid, move |pr, _| {
pr.keys = keys;
if let Some(e) = &mut pr.expr {
e.enabled = false;
}
Ok(json!(n))
})
}
pub fn specs() -> Vec<CommandSpec> {
vec![
query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get),
cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set),
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim),
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key),
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
cmd!(
"keys.toggleTransform",
"Add or Remove Transform Keyframe",
[],
None,
"{layers?, prop: anchor|position|scale|rotation|opacity} (Alt+Shift+A/P/S/R/T)",
has_layers,
toggle_transform_key
),
cmd!("prop.setExpression", "Add Expression", ["Animation"], Some("Alt+Shift+="), "{layer?, path|prop, expression?, enabled?}", has_layers, set_expr),
cmd!("prop.reset", "Reset Property", [], None, "{layer?, path|prop, default?}", has_layers, reset),
cmd!("prop.select", "Select Property", [], None, "{layer?, path|prop, add?, selectKeys?}", has_layers, select_prop),
cmd!(
"prop.convertExpressionToKeyframes",
"Convert Expression to Keyframes",
["Animation", "Keyframe Assistant"],
None,
"{layer?, path|prop}",
has_layers,
convert_expr_to_keys
),
cmd!(
"keys.select",
"Select Keyframes",
[],
None,
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection)}",
has_comp,
select_keys
),
cmd!("keys.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys),
cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_keys),
cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease),
cmd!("keys.easyEaseIn", "Easy Ease In", ["Animation", "Keyframe Assistant"], Some("Shift+F9"), "{}", has_keys, |s, _| ease(s, &json!({"which": "in"}))),
cmd!("keys.easyEaseOut", "Easy Ease Out", ["Animation", "Keyframe Assistant"], Some("Cmd+Shift+F9"), "{}", has_keys, |s, _| ease(
s,
&json!({"which": "out"})
)),
cmd!("keys.toggleHold", "Toggle Hold Keyframe", ["Animation"], Some("Cmd+Alt+H"), "{}", has_keys, toggle_hold),
cmd!(
"keys.interpolation",
"Keyframe Interpolation...",
["Animation"],
Some("Cmd+Alt+K"),
"{interpolation?: linear|bezier|continuousBezier|autoBezier|hold, in?|out?: linear|bezier|hold, autoBezier?, continuous?, spatial?: linear|bezier|continuousBezier|autoBezier, roving?}",
has_keys,
interpolation
),
cmd!(
"keys.velocity",
"Keyframe Velocity...",
["Animation"],
Some("Cmd+Shift+K"),
"{inSpeed?, inInfluence? %, outSpeed?, outInfluence? % (numbers or per-dimension arrays), continuous?}",
has_keys,
velocity
),
]
}