M5.2–M5.5: keyframe clipboard, easy ease, velocity/interpolation dialogs, roving, time remap/stretch/reverse/freeze, graph editor, separate dimensions, pick-whip

Engine (all commands with undo/redo + tests in engine/src/tests_timeline.rs):
- keys.copy/paste (Edit Copy/Cut/Paste route to keys when keys are selected;
  relative timing kept, paste at CTI, cross-property paste of same type),
  keys.selectAll (Cmd+Alt+A), keys.nudge* (Alt+Arrow, Alt+Shift+Arrow),
  keys.set / keys.setEase (graph editor drags), keys.info (dialog data),
  keys.timeReverse; keys.interpolation gains continuous/auto Bezier, spatial
  interpolation and roving; keys.velocity takes per-dimension values and
  Continuous.
- Roving keys (keyframe::retime_roving): constant speed along the motion path.
- Layer > Time: layer.timeStretch (percent|duration, hold in/current/out),
  layer.timeReverse (Cmd+Alt+R), layer.enableTimeRemap (Cmd+Alt+T),
  layer.freezeFrame, layer.freezeOnLastFrame; render uses Time Remap as the
  source time for precomp/footage layers.
- prop.separateDimensions (X/Y/Z Position; prop.set on Position writes them).
- layer.setParent compensates so the layer stays in place (compensate:false
  to opt out).
- prop.pickWhip writes AE-style reference expressions (thisComp.layer(..),
  effect(..)(..), mask(..).maskFeather, temp=…;[temp,temp], …[0]).
- prop.setExpression: syntax errors keep the text but disable the expression.
- expr: arrays of properties returned by an expression are resolved.

UI: Keyframe Velocity, Keyframe Interpolation and Time Stretch dialogs;
Graph Editor (value + speed graphs, draggable keys and Bezier/influence
handles, auto-zoom, fit, show selected, separate dimensions, hold/linear/
auto Bezier/Easy Ease buttons); key + layer context menus. Clears the
workspace clippy -D warnings failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 21:47:29 -04:00
co-authored by Claude Opus 5.5
parent dee16181fe
commit 951ed1fa3d
29 changed files with 2705 additions and 203 deletions
+414
View File
@@ -0,0 +1,414 @@
//! 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),
]
}
+20 -4
View File
@@ -15,7 +15,7 @@ fn can_redo(s: &Session) -> std::result::Result<(), String> {
}
fn has_clip(s: &Session) -> std::result::Result<(), String> {
has_comp(s)?;
if s.state.clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) }
if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) }
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
@@ -97,18 +97,30 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
}
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
// Keyframes selected → copy keys (pasted at the CTI).
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
return s.execute("keys.copy", json!({}));
}
let (cid, ids) = layers_p(s, p)?;
s.state.clip_is_keys = false;
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
s.state.clipboard = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect();
Ok(json!(s.state.clipboard.len()))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
s.execute("keys.copy", json!({}))?;
return s.execute("keys.delete", json!({}));
}
copy(s, p)?;
delete(s, p)
}
fn paste(s: &mut Session, p: &Value) -> Result<Value> {
if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() {
return s.execute("keys.paste", p.clone());
}
let cid = super::comp_id(s, p)?;
let clip = s.state.clipboard.clone();
let new = s.edit("Paste", None, |proj, st| {
@@ -191,10 +203,10 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?}", has_layers, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?}", has_layers, copy),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes when keys are selected)", layers_or_keys, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes when keys are selected)", layers_or_keys, copy),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{}", has_clip, paste),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", has_layers, 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.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split),
cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all),
@@ -204,6 +216,10 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
if !s.state.selected_keys.is_empty() { super::has_comp(s) } else { has_layers(s) }
}
fn always_ok(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
+52 -29
View File
@@ -456,10 +456,62 @@ fn set_parent(s: &mut Session, p: &Value) -> Result<Value> {
cur = comp.layer(c).and_then(|l| l.parent);
}
}
// Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed
// in the new parent's space (Position, Rotation, Scale), unless `compensate: false`.
let compensate = b_p(p, "compensate").unwrap_or(true);
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref() };
let space = |id: Option<LayerId>| -> effectcraft_geom::Mat3 {
id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY)
};
let decompose = |m: &effectcraft_geom::Mat3| {
let a = m.0;
let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt();
let det = a[0][0] * a[1][1] - a[0][1] * a[1][0];
(a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 })
};
// Per layer: (id, position transform, rotation delta, scale factors).
let mut fixes: Vec<(LayerId, effectcraft_geom::Mat3, f64, [f64; 2])> = vec![];
if compensate {
let new_m = space(parent);
for l in comp.layers.iter().filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) {
let old_m = space(l.parent);
let Some(inv) = new_m.inverse() else { continue };
let (ro, sxo, syo) = decompose(&old_m);
let (rn, sxn, syn) = decompose(&new_m);
let k = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }];
fixes.push((l.id, inv * old_m, ro - rn, k));
}
}
s.edit("Parent", None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
l.parent = parent;
let Some((_, m, dr, k)) = fixes.iter().find(|f| f.0 == l.id) else { continue };
let Some(tr) = l.props.sub_mut("transform") else { continue };
let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| {
pr.value = f(&pr.value);
for key in &mut pr.keys {
key.value = f(&key.value);
}
};
if tr.get("positionX").is_none()
&& let Some(pr) = tr.get_mut("position")
{
map(pr, &|v| {
let c = v.as_vec3();
let q = m.apply(effectcraft_geom::vec2(c[0], c[1]));
KV::Vec3([q.x, q.y, c[2]])
});
}
if let Some(pr) = tr.get_mut("rotation") {
map(pr, &|v| KV::Scalar(v.as_f64() + dr));
}
if let Some(pr) = tr.get_mut("scale") {
map(pr, &|v| {
let c = v.as_vec3();
KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]])
});
}
}
Ok(())
})?;
@@ -790,34 +842,6 @@ fn transform_op(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let reverse = str_p(p, "op") == Some("reverse");
let pct = f_p(p, "percent");
s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
let old = l.stretch;
let new = if reverse { -old } else { pct.unwrap_or(old) };
if new.abs() < 1.0 {
continue;
}
// Keep the in point fixed: the layer's source span scales around it.
let lin = l.layer_time(l.in_point);
let lout = l.layer_time(l.out_point);
l.stretch = new;
if reverse {
l.start_time = l.in_point + Tick((lout.0 as f64 * new.abs() / 100.0) as i64);
} else {
l.start_time = l.in_point - Tick((lin.0 as f64 * new / 100.0) as i64);
l.out_point = l.comp_time(lout);
}
}
Ok(())
})?;
Ok(Value::Null)
}
fn layer_settings(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.settings")?;
let color = color_p(p, "color");
@@ -949,7 +973,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_layers,
transform_op
),
cmd!("layer.timeStretch", "Time Stretch…", ["Layer", "Time"], None, "{layers?, percent?, op?: reverse}", has_layers, time_stretch),
]
}
+186
View File
@@ -0,0 +1,186 @@
//! Layer ▸ Time: Time Stretch, Time-Reverse Layer, Enable Time Remapping, Freeze Frame and
//! Freeze on Last Frame.
//!
//! Time Remap is a `timeRemap` property at the top of the layer's tree whose value is the
//! source time (seconds) to show; its keys live in layer time like every other property.
use effectcraft_keyframe::{Keyframe, Value as KV};
use effectcraft_project::build::Ids;
use effectcraft_project::{Layer, LayerSource, Node, ParamUi, Property};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::{CommandSpec, b_p, bad, f_p, has_layers, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
/// Layers whose source has its own time (precomps and footage) can be remapped.
fn remappable(l: &Layer) -> bool {
matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })
}
fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let reverse = str_p(p, "op") == Some("reverse");
let pct = f_p(p, "percent");
let dur = f_p(p, "duration");
let hold = str_p(p, "hold").unwrap_or("in").to_string();
let cti = s.time();
let fd = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_duration();
s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) {
let old = l.stretch;
if reverse {
// Keep the in/out points; play the visible span backwards.
let last = l.layer_time(l.out_point - fd);
l.stretch = -old;
l.start_time = l.in_point - Tick((last.0 as f64 * l.stretch / 100.0).round() as i64);
continue;
}
let span = (l.out_point - l.in_point).seconds();
let new = match (pct, dur) {
(Some(v), _) => v,
(None, Some(d)) if span > 0.0 => old * d / span,
_ => old,
};
if new.abs() < 1.0 {
return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %"));
}
// The hold point keeps showing the same layer time.
let h = match hold.as_str() {
"current" => cti,
"out" => l.out_point,
_ => l.in_point,
};
let lt_h = l.layer_time(h);
let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point));
l.stretch = new;
l.start_time = h - Tick((lt_h.0 as f64 * new / 100.0).round() as i64);
let (a, b) = (l.comp_time(lin), l.comp_time(lout));
l.in_point = a.min(b);
l.out_point = a.max(b);
}
Ok(())
})?;
Ok(Value::Null)
}
fn remap_prop(ids: &mut Ids) -> Property {
let mut p = ids.prop("timeRemap", "Time Remap", KV::Scalar(0.0)).with_ui(ParamUi::Number);
p.ui = ParamUi::Number;
p
}
/// Source time (seconds) a layer shows at comp time `t` without remapping.
fn source_secs(l: &Layer, t: Tick) -> f64 {
l.layer_time(t).seconds()
}
fn enable_time_remap(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let want = b_p(p, "value");
let fd = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_duration();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) {
return Err(bad("layer.enableTimeRemap", "time remapping needs a footage or composition layer"));
}
let on = s.edit("Enable Time Remapping", None, |proj, _| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let mut on = false;
for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) {
let has = l.props.get("timeRemap").is_some();
let target = want.unwrap_or(!has);
on |= target;
if target && !has {
let mut pr = remap_prop(&mut Ids(&mut next));
let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point));
let (a, b) = (lin.min(lout), lin.max(lout));
pr.value = KV::Scalar(source_secs(l, l.in_point));
pr.keys = vec![Keyframe::new(a, KV::Scalar(a.seconds())), Keyframe::new(b, KV::Scalar(b.seconds()))];
if lin > lout {
// Reversed layers keep showing what they showed.
pr.keys[0].value = KV::Scalar(source_secs(l, l.out_point - fd));
pr.keys[1].value = KV::Scalar(source_secs(l, l.in_point));
}
l.props.children.insert(0, Node::Prop(pr));
} else if !target && has {
l.props.children.retain(|c| c.match_id() != "timeRemap");
}
}
proj.next_id = next;
Ok(on)
})?;
Ok(json!(on))
}
/// Freeze Frame: time remapping holding the current source frame for the whole layer.
fn freeze(s: &mut Session, p: &Value, last: bool) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
let cti = s.time();
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let (fd, cdur) = (comp.frame_duration(), comp.duration);
if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) {
return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer"));
}
// What each layer shows now (honouring an existing remap) — evaluated before the edit.
let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref() };
let now: Vec<(effectcraft_project::LayerId, f64)> = comp
.layers
.iter()
.filter(|l| ids.contains(&l.id) && remappable(l))
.map(|l| {
let at = if last { ectx.at(l.out_point - fd) } else { ectx };
(l.id, at.source_time(l).seconds())
})
.collect();
s.edit(if last { "Freeze on Last Frame" } else { "Freeze Frame" }, None, |proj, _| {
let mut next = proj.next_id;
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for (lid, src) in &now {
let Some(l) = comp.layer_mut(*lid) else { continue };
if l.props.get("timeRemap").is_none() {
l.props.children.insert(0, Node::Prop(remap_prop(&mut Ids(&mut next))));
}
let lt = if last { l.layer_time(l.out_point - fd) } else { l.layer_time(cti) };
let lin = l.layer_time(l.in_point);
let pr = l.props.get_mut("timeRemap").ok_or(EngineError::NoComp)?;
pr.value = KV::Scalar(*src);
if last {
// Play normally up to the last frame, then hold it to the end of the comp.
let mut end = Keyframe::new(lt.max(lin), KV::Scalar(*src));
end.out_interp = effectcraft_keyframe::Interp::Hold;
pr.keys = vec![Keyframe::new(lin.min(lt), KV::Scalar(lin.min(lt).seconds())), end];
pr.keys.dedup_by_key(|k| k.time);
l.out_point = cdur;
} else {
pr.keys = vec![Keyframe::new(lt, KV::Scalar(*src)).hold()];
}
}
proj.next_id = next;
Ok(())
})?;
Ok(Value::Null)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"layer.timeStretch",
"Time Stretch…",
["Layer", "Time"],
None,
"{layers?, percent?|duration? (s), hold?: in|current|out, op?: reverse}",
has_layers,
time_stretch
),
cmd!("layer.timeReverse", "Time-Reverse Layer", ["Layer", "Time"], Some("Cmd+Alt+R"), "{layers?}", has_layers, |s, p| {
let mut p = p.clone();
p["op"] = json!("reverse");
time_stretch(s, &p)
}),
cmd!("layer.enableTimeRemap", "Enable Time Remapping", ["Layer", "Time"], Some("Cmd+Alt+T"), "{layers?, value?}", has_layers, enable_time_remap),
cmd!("layer.freezeFrame", "Freeze Frame", ["Layer", "Time"], None, "{layers?}", has_layers, |s, p| freeze(s, p, false)),
cmd!("layer.freezeOnLastFrame", "Freeze on Last Frame", ["Layer", "Time"], None, "{layers?}", has_layers, |s, p| freeze(s, p, true)),
]
}
+182
View File
@@ -0,0 +1,182 @@
//! Separate Dimensions and the property pick-whip (AE-style reference expressions).
use effectcraft_keyframe::{Ease, Interp, Keyframe, Value as KV};
use effectcraft_project::{Comp, Layer, Node, ParamUi, Property, Uid};
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde_json::{Value, json};
use super::prop::prop_ref;
use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p};
use crate::{EngineError, Result, Session, cmd};
const AXES: [(&str, &str); 3] = [("positionX", "X Position"), ("positionY", "Y Position"), ("positionZ", "Z Position")];
/// Per-dimension scalar keys from a (spatial) position key list: same times and interpolation;
/// Bezier sides get the per-dimension velocity the combined property had at the key.
fn split_keys(keys: &[Keyframe], spatial: bool, d: usize) -> Vec<Keyframe> {
let h = Tick(TICKS_PER_SECOND / 2000);
keys.iter()
.map(|k| {
let mut s = Keyframe::new(k.time, KV::Scalar(k.value.components().get(d).copied().unwrap_or(0.0)));
s.in_interp = k.in_interp;
s.out_interp = k.out_interp;
s.auto_bezier = k.auto_bezier;
s.continuous = k.continuous;
s.roving = false;
let vel = |t: Tick| effectcraft_keyframe::velocity(keys, t, spatial).get(d).copied().unwrap_or(0.0);
if k.in_interp == Interp::Bezier {
let inf = k.in_ease.first().map(|e| e.influence).unwrap_or(1.0 / 3.0);
s.in_ease = vec![Ease { speed: vel(k.time - h), influence: inf }];
}
if k.out_interp == Interp::Bezier {
let inf = k.out_ease.first().map(|e| e.influence).unwrap_or(1.0 / 3.0);
s.out_ease = vec![Ease { speed: vel(k.time + h), influence: inf }];
}
s
})
.collect()
}
fn separate(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "prop.separateDimensions")?;
let want = b_p(p, "value");
let on = s.edit("Separate Dimensions", None, |proj, _| {
let mut next = proj.next_id;
let l = layer_mut(proj, cid, lid)?;
let tr = l.props.sub_mut("transform").ok_or_else(|| bad("prop.separateDimensions", "this layer has no Transform"))?;
let has = tr.get("positionX").is_some();
let target = want.unwrap_or(!has);
if target && !has {
let pos = tr.get("position").ok_or_else(|| bad("prop.separateDimensions", "no Position"))?.clone();
let at = tr.children.iter().position(|c| c.uid() == pos.uid).map(|i| i + 1).unwrap_or(tr.children.len());
let comps = pos.value.components();
for (d, (m, name)) in AXES.iter().enumerate().rev() {
let mut pr = Property::new(next, m, name, KV::Scalar(comps.get(d).copied().unwrap_or(0.0))).with_ui(ParamUi::Number);
next += 1;
pr.three_d_only = d == 2;
pr.keys = split_keys(&pos.keys, pos.spatial, d);
tr.children.insert(at, Node::Prop(pr));
}
} else if !target && has {
let parts: Vec<Property> = AXES.iter().filter_map(|(m, _)| tr.get(m).cloned()).collect();
let mut times: Vec<Tick> = parts.iter().flat_map(|p| p.keys.iter().map(|k| k.time)).collect();
times.sort();
times.dedup();
let at = |t: Tick| KV::Vec3([0, 1, 2].map(|d| parts.get(d).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0)));
let pos = tr.get_mut("position").ok_or_else(|| bad("prop.separateDimensions", "no Position"))?;
pos.value = at(Tick::ZERO);
pos.keys = times.iter().map(|t| Keyframe::new(*t, at(*t))).collect();
if pos.keys.is_empty() {
pos.value = KV::Vec3([0, 1, 2].map(|d| parts.get(d).map(|p| p.value.as_f64()).unwrap_or(0.0)));
}
tr.children.retain(|c| !AXES.iter().any(|(m, _)| c.match_id() == *m));
}
proj.next_id = next;
Ok(target)
})?;
Ok(json!(on))
}
// ---------------------------------------------------------------- pick-whip
/// `X Position` → `xPosition`, `Mask Path` → `maskPath`.
fn camel(name: &str) -> String {
let mut out = String::new();
for (i, w) in name.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).enumerate() {
let mut cs = w.chars();
if let Some(f) = cs.next() {
if i == 0 {
out.extend(f.to_lowercase());
} else {
out.extend(f.to_uppercase());
}
out.push_str(cs.as_str());
}
}
out
}
fn quote(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
}
/// The AE expression reference to property `uid` of `target` as seen from a property of `from`.
pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) -> Option<String> {
let chain = target.props.node_chain(uid)?;
let mut parts: Vec<String> = vec![];
let mut pending: Option<&str> = None;
let mut in_effect = false;
for n in chain {
if let Some(f) = pending.take() {
parts.push(format!("{f}({})", quote(n.name())));
in_effect |= f == "effect";
continue;
}
match (n, n.match_id()) {
(Node::Group(_), "masks") => pending = Some("mask"),
(Node::Group(_), "effects") => pending = Some("effect"),
(Node::Group(_), "contents") => pending = Some("content"),
(Node::Group(_), "transform") if !in_effect => parts.push("transform".into()),
(Node::Group(_), "text") if !in_effect => parts.push("text".into()),
(Node::Prop(_), "sourceText") => parts.push("sourceText".into()),
_ if in_effect => parts.push(format!("({})", quote(n.name()))),
_ => parts.push(camel(n.name())),
}
}
let mut out = if from.id == target.id { String::new() } else { format!("thisComp.layer({}).", quote(&target.name)) };
let _ = comp;
for (i, p) in parts.iter().enumerate() {
if i > 0 && !p.starts_with('(') {
out.push('.');
}
out.push_str(p);
}
Some(out)
}
/// Dimensions an expression sees (2D layers see 2D Position/Scale/Anchor).
fn dims(l: &Layer, p: &Property) -> usize {
match &p.value {
KV::Vec3(_) if p.shown_dims == 2 && !l.is_3d() => 2,
v => v.components().len().max(1),
}
}
fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid, uid) = prop_ref(s, p, "prop.pickWhip")?;
let t = p.get("target").ok_or_else(|| bad("prop.pickWhip", "missing `target` {layer, path|prop}"))?;
let (_, tl, tu) = prop_ref(s, t, "prop.pickWhip")?;
if (tl, tu) == (lid, uid) {
return Err(bad("prop.pickWhip", "a property can't reference itself"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let from = comp.layer(lid).ok_or(EngineError::NoComp)?;
let target = comp.layer(tl).ok_or(EngineError::NoComp)?;
let r = reference(comp, from, target, tu).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?;
let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?);
let (dm, dt) = (dims(from, me), dims(target, them));
let text = if dt == 1 && dm > 1 {
format!("temp = {r};\n[{}]", vec!["temp"; dm].join(", "))
} else if dt > 1 && dm == 1 {
format!("{r}[0]")
} else {
r
};
s.execute("prop.setExpression", json!({"layer": lid.0, "prop": uid, "expression": text}))?;
Ok(json!(text))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("prop.separateDimensions", "Separate Dimensions", ["Animation"], None, "{layer?, value?}", has_layers, separate),
cmd!(
"prop.pickWhip",
"Pick Whip (Link Property)",
[],
None,
"{layer?, path|prop, target: {layer, path|prop}} → sets an AE reference expression",
has_layers,
pick_whip
),
]
}
+7 -1
View File
@@ -1,12 +1,15 @@
//! The command registry. Ids follow After Effects' menu structure; every menu item, panel button,
//! shortcut and viewer/timeline gesture maps to one of these.
mod anim;
mod comp;
mod edit;
mod effect;
mod file;
mod help;
mod layer;
mod layer_time;
mod link;
mod prop;
mod query;
mod time;
@@ -68,7 +71,10 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(edit::specs());
v.extend(comp::specs());
v.extend(layer::specs());
v.extend(layer_time::specs());
v.extend(prop::specs());
v.extend(anim::specs());
v.extend(link::specs());
v.extend(effect::specs());
v.extend(time::specs());
v.extend(help::specs());
@@ -182,6 +188,6 @@ pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick {
s.time()
}
pub(crate) fn layer_mut<'a>(p: &'a mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&'a mut Layer> {
pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> {
p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid)))
}
+170 -33
View File
@@ -9,7 +9,7 @@ use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mu
use crate::{EngineError, KeyRef, Result, Session, cmd};
/// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid).
fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, 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) {
@@ -40,6 +40,32 @@ fn with_prop<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 = f_p(p, "time").map(Tick::from_seconds_f64);
let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| {
@@ -113,8 +139,16 @@ fn toggle_key(s: &mut Session, p: &Value) -> Result<Value> {
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 enabled = b_p(p, "enabled");
with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| {
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);
}
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) }),
@@ -130,7 +164,12 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
}
}
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 default_expr(pr: &Property) -> String {
@@ -210,7 +249,7 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Apply `f` to every selected key; keeps the selection pointing at moved keys.
fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec<Keyframe>, usize, &mut Tick) -> bool) -> Result<Value> {
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| {
@@ -247,8 +286,14 @@ fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut
for t in keep_times {
new_sel.push(KeyRef { layer: lid, prop: uid, time: t });
}
if pr.keys.is_empty() {
// Deleting every key leaves the value at the last key.
// 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;
@@ -304,8 +349,56 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
let both = parse("interpolation");
let inn = parse("in").or(both);
let out = parse("out").or(both);
let auto = b_p(p, "autoBezier");
// 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;
@@ -324,6 +417,24 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
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
@@ -338,41 +449,67 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
})
}
/// 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 in_speed = f_p(p, "inSpeed");
let in_inf = f_p(p, "inInfluence").map(|v| v / 100.0);
let out_speed = f_p(p, "outSpeed");
let out_inf = f_p(p, "outInfluence").map(|v| v / 100.0);
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];
if in_speed.is_some() || in_inf.is_some() {
k.in_interp = Interp::Bezier;
if k.in_ease.is_empty() {
k.in_ease = vec![Ease::default(); n];
let apply = |list: &mut Vec<Ease>, base: Vec<Ease>, sp: &Option<Vec<f64>>, inf: &Option<Vec<f64>>| {
if list.len() != n {
*list = base;
}
for e in &mut k.in_ease {
if let Some(v) = in_speed {
e.speed = v;
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) = in_inf {
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;
if k.out_ease.is_empty() {
k.out_ease = vec![Ease::default(); n];
}
for e in &mut k.out_ease {
if let Some(v) = out_speed {
e.speed = v;
}
if let Some(v) = out_inf {
e.influence = v.clamp(0.001, 1.0);
}
}
apply(&mut k.out_ease, cur_out, &out_speed, &out_inf);
}
if let Some(c) = continuous {
k.continuous = c;
}
k.auto_bezier = false;
false
@@ -435,7 +572,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Keyframe Interpolation…",
["Animation"],
Some("Cmd+Alt+K"),
"{interpolation?|in?|out?: linear|bezier|hold, autoBezier?}",
"{interpolation?: linear|bezier|continuousBezier|autoBezier|hold, in?|out?: linear|bezier|hold, autoBezier?, continuous?, spatial?: linear|bezier|continuousBezier|autoBezier, roving?}",
has_keys,
interpolation
),
@@ -444,7 +581,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Keyframe Velocity…",
["Animation"],
Some("Cmd+Shift+K"),
"{inSpeed?, inInfluence? %, outSpeed?, outInfluence? %}",
"{inSpeed?, inInfluence? %, outSpeed?, outInfluence? % (numbers or per-dimension arrays), continuous?}",
has_keys,
velocity
),
+34
View File
@@ -91,6 +91,24 @@ pub struct KeyRef {
pub time: Tick,
}
/// Keyframes of one property on the keyframe clipboard.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct KeyClip {
pub layer: LayerId,
/// Match-id path relative to the layer (`transform/position`), portable between layers.
pub path: String,
/// Keys with times relative to the earliest copied key (layer time).
pub keys: Vec<effectcraft_keyframe::Keyframe>,
}
/// A selected mask vertex: layer, mask group uid, vertex index.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VertexRef {
pub layer: LayerId,
pub mask: Uid,
pub index: usize,
}
/// Editing state that commands depend on (headless-relevant, serde for agents).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct EditorState {
@@ -107,6 +125,15 @@ pub struct EditorState {
/// Layer clipboard (serialized layers) and keyframe clipboard.
#[serde(skip)]
pub clipboard: Vec<Layer>,
/// Copied keyframes (times relative to the earliest copied key).
#[serde(skip)]
pub key_clipboard: Vec<KeyClip>,
/// The last copy was keyframes (Paste pastes keys at the CTI).
#[serde(skip)]
pub clip_is_keys: bool,
/// Selected mask vertices (viewer Selection tool / pen).
#[serde(default)]
pub selected_vertices: Vec<VertexRef>,
pub snapping: bool,
/// Last applied effect id (Effect ▸ last effect).
pub last_effect: Option<String>,
@@ -131,6 +158,8 @@ pub struct Session {
pub services: Arc<dyn Services>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
/// Expression syntax checker (set by the host that links the expression engine).
pub expr_check: Option<fn(&str) -> std::result::Result<(), String>>,
pub importer: Option<Arc<dyn Importer>>,
pub events: Vec<Event>,
/// Commands executed: (id, params).
@@ -149,6 +178,7 @@ impl Default for Session {
services: Arc::new(FsServices),
footage: Arc::new(NoFootage),
expr: None,
expr_check: None,
importer: None,
events: vec![],
journal: vec![],
@@ -243,6 +273,7 @@ impl Session {
self.state.selected_layers.retain(|l| ids.contains(l));
self.state.selected_props.retain(|(l, _)| ids.contains(l));
self.state.selected_keys.retain(|k| ids.contains(&k.layer));
self.state.selected_vertices.retain(|v| ids.contains(&v.layer));
}
self.state.project_selection.retain(|i| p.item(*i).is_some());
}
@@ -287,6 +318,7 @@ impl Session {
self.state.selected_layers.clear();
self.state.selected_props.clear();
self.state.selected_keys.clear();
self.state.selected_vertices.clear();
}
self.events.push(Event::OpenComp(id));
}
@@ -324,6 +356,8 @@ impl Session {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_timeline;
/// Font families available to text layers (bundled + scanned system fonts).
pub fn text_families() -> Vec<String> {
+454
View File
@@ -0,0 +1,454 @@
//! Timeline depth: keyframe clipboard, easing, velocity/interpolation, roving, nudging, graph
//! editor key edits, time remapping/stretch/reverse/freeze, separate dimensions, pick-whips,
//! expressions and masks (pen tool). Every command is checked with undo/redo.
use effectcraft_keyframe::{Interp, Value as KV};
use effectcraft_project::Property;
use serde_json::{Value, json};
use crate::Session;
/// A 4 s, 30 fps comp with one solid; returns (session, layer id).
pub(crate) fn setup() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap();
let r = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 100, "height": 100})).unwrap();
(s, r["layer"].as_u64().unwrap())
}
pub(crate) fn prop(s: &Session, layer: u64, path: &str) -> Property {
s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().props.prop(path).unwrap().clone()
}
/// Key `path` at the given (time, value) pairs.
pub(crate) fn animate(s: &mut Session, layer: u64, path: &str, keys: &[(f64, Value)]) {
for (t, v) in keys {
s.execute("prop.addKey", json!({"layer": layer, "path": path, "time": t, "value": v})).unwrap();
}
}
fn select_prop_keys(s: &mut Session, layer: u64, path: &str) {
s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap();
}
fn undo_redo_roundtrip(s: &mut Session, check_after: impl Fn(&Session), check_before: impl Fn(&Session)) {
check_after(s);
s.execute("edit.undo", json!({})).unwrap();
check_before(s);
s.execute("edit.redo", json!({})).unwrap();
check_after(s);
}
#[test]
fn easy_ease_family_sets_influence_and_zero_speed() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
select_prop_keys(&mut s, l, "transform/opacity");
s.execute("keys.easyEase", json!({})).unwrap();
undo_redo_roundtrip(
&mut s,
|s| {
let p = prop(s, l, "transform/opacity");
assert!(p.keys.iter().all(|k| k.in_interp == Interp::Bezier && k.out_interp == Interp::Bezier));
assert!((p.keys[1].in_ease[0].influence - 1.0 / 3.0).abs() < 1e-9 && p.keys[1].in_ease[0].speed == 0.0);
},
|s| assert!(prop(s, l, "transform/opacity").keys.iter().all(|k| k.in_interp == Interp::Linear)),
);
// Easy Ease In only touches the incoming side; Out only the outgoing side.
s.execute("keys.interpolation", json!({"interpolation": "linear"})).unwrap();
s.execute("keys.easyEaseIn", json!({})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!(p.keys.iter().all(|k| k.in_interp == Interp::Bezier && k.out_interp == Interp::Linear));
s.execute("keys.interpolation", json!({"interpolation": "linear"})).unwrap();
s.execute("keys.easyEaseOut", json!({})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!(p.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Bezier));
// Eased motion is slow near the key.
s.execute("keys.easyEase", json!({})).unwrap();
let p = prop(&s, l, "transform/opacity");
let early = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.1)).as_f64();
assert!(early < 5.0, "{early}");
}
#[test]
fn keyframe_copy_paste_keeps_relative_timing_and_crosses_properties() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/rotation", &[(0.5, json!(0)), (1.0, json!(90)), (2.0, json!(45))]);
select_prop_keys(&mut s, l, "transform/rotation");
s.execute("keys.easyEase", json!({})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!(3));
assert!(s.state.clip_is_keys);
// Paste into the same property at 2.5 s: keys at 2.5, 3.0 and 4.0 (clamped by nothing).
s.execute("time.set", json!({"time": 2.5})).unwrap();
s.execute("keys.select", json!({"keys": []})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "transform/rotation");
let times: Vec<f64> = p.keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect();
assert_eq!(times, vec![0.5, 1.0, 2.0, 2.5, 3.0, 4.0]);
assert_eq!(p.keys[4].value, KV::Scalar(90.0));
assert_eq!(p.keys[4].in_interp, Interp::Bezier, "interpolation travels with the keys");
assert_eq!(s.state.selected_keys.len(), 3, "pasted keys are selected");
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/rotation").keys.len(), 3));
// Cross-property paste of one property's keys into another of the same type (Scalar).
let r = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let l2 = r["layer"].as_u64().unwrap();
select_prop_keys(&mut s, l, "transform/rotation");
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": prop(&s, l, "transform/rotation").uid, "time": 1.0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.select", json!({"layer": l2, "path": "transform/opacity", "selectKeys": false})).unwrap();
s.execute("keys.paste", json!({"layers": [l2]})).unwrap();
let op = prop(&s, l2, "transform/opacity");
assert_eq!(op.keys.len(), 1);
assert_eq!(op.keys[0].value, KV::Scalar(90.0));
// Incompatible target (Vec3 position) is refused when explicitly given.
let e = s.execute("keys.paste", json!({"layers": [l2], "path": "transform/position"}));
assert!(e.is_err() || prop(&s, l2, "transform/position").keys.is_empty());
}
#[test]
fn cut_removes_keys_and_paste_restores_them() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(10)), (1.0, json!(20))]);
select_prop_keys(&mut s, l, "transform/opacity");
s.execute("edit.cut", json!({})).unwrap();
assert!(prop(&s, l, "transform/opacity").keys.is_empty());
s.execute("time.set", json!({"time": 0.0})).unwrap();
s.execute("layer.select", json!({"layers": [l]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys.len(), 2);
}
#[test]
fn select_all_and_nudge_keys() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(10)), (1.0, json!(20))]);
animate(&mut s, l, "transform/rotation", &[(0.5, json!(10))]);
s.state.selected_props.clear();
s.execute("layer.select", json!({"layers": [l]})).unwrap();
assert_eq!(s.execute("keys.selectAll", json!({})).unwrap(), json!(3));
s.execute("keys.nudgeForward", json!({})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "transform/opacity");
assert!((p.keys[0].time.seconds() - 1.0 / 30.0).abs() < 1e-6);
assert_eq!(s.state.selected_keys.len(), 3);
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].time.0, 0));
s.execute("keys.nudgeBackward10", json!({})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!((p.keys[0].time.seconds() + 9.0 / 30.0).abs() < 1e-6, "{}", p.keys[0].time.seconds());
}
#[test]
fn velocity_dialog_values_per_dimension_and_continuous() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (2.0, json!([100, 100]))]);
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": prop(&s, l, "transform/scale").uid, "time": 1.0}]})).unwrap();
let info = s.execute("keys.info", json!({})).unwrap();
assert_eq!(info[0]["units"], "%/sec");
assert_eq!(info[0]["dims"], 2);
// Linear sides present the segment slope: +100 %/s in X on the incoming side.
assert!((info[0]["inEase"][0]["speed"].as_f64().unwrap() - 100.0).abs() < 1e-6, "{info}");
s.execute("keys.velocity", json!({"inSpeed": [10, -20], "inInfluence": 50, "continuous": true})).unwrap();
let after = |s: &Session| {
let k = &prop(s, l, "transform/scale").keys[1];
assert_eq!(k.in_ease[0].speed, 10.0);
assert_eq!(k.in_ease[1].speed, -20.0);
assert_eq!(k.out_ease[1].speed, -20.0, "continuous mirrors incoming");
assert!((k.out_ease[0].influence - 0.5).abs() < 1e-9);
assert!(k.continuous);
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear));
}
#[test]
fn interpolation_dialog_temporal_spatial_and_roving() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]);
select_prop_keys(&mut s, l, "transform/position");
s.execute("keys.interpolation", json!({"interpolation": "hold"})).unwrap();
assert!(prop(&s, l, "transform/position").keys.iter().all(|k| k.out_interp == Interp::Hold));
s.execute("keys.interpolation", json!({"interpolation": "autoBezier"})).unwrap();
assert!(prop(&s, l, "transform/position").keys.iter().all(|k| k.auto_bezier));
s.execute("keys.interpolation", json!({"interpolation": "continuousBezier", "spatial": "linear"})).unwrap();
let p = prop(&s, l, "transform/position");
assert!(p.keys.iter().all(|k| k.continuous && !k.auto_bezier && !k.spatial_auto && k.spatial_out == [0.0; 3]));
// Rove the middle key: its time follows the path length (300 of 400 px → 1.5 s).
let uid = p.uid;
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap();
s.execute("keys.interpolation", json!({"roving": true})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "transform/position");
assert!(p.keys[1].roving);
assert!((p.keys[1].time.seconds() - 1.5).abs() < 0.01, "{}", p.keys[1].time.seconds());
// The selection follows the roving key.
assert!((s.state.selected_keys[0].time.seconds() - 1.5).abs() < 0.01);
};
undo_redo_roundtrip(&mut s, after, |s| assert!(!prop(s, l, "transform/position").keys[1].roving));
// Moving the last key re-times the roving key.
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 4.0})).unwrap();
let p = prop(&s, l, "transform/position");
assert!((p.keys[1].time.seconds() - 3.0).abs() < 0.02, "{}", p.keys[1].time.seconds());
// Toggle hold on a key and back.
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.0}]})).unwrap();
s.execute("keys.toggleHold", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold);
s.execute("keys.toggleHold", json!({})).unwrap();
assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold);
}
#[test]
fn graph_editor_key_and_handle_edits() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (2.0, json!(100))]);
// Drag a key in the value graph: new time + value, merged into one undo step.
s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 2.0, "newTime": 1.0, "value": 50, "merge": "g1"})).unwrap();
s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "newTime": 1.5, "value": 60, "merge": "g1"})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!((p.keys[1].time.seconds() - 1.5).abs() < 1e-6);
assert_eq!(p.keys[1].value, KV::Scalar(60.0));
s.execute("edit.undo", json!({})).unwrap();
assert!((prop(&s, l, "transform/opacity").keys[1].time.seconds() - 2.0).abs() < 1e-6);
s.execute("edit.redo", json!({})).unwrap();
// A key can't be dragged past its neighbour.
s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 1.5, "newTime": -1.0})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!(p.keys[1].time > p.keys[0].time);
// Handle drag: out side of key 0 → speed 200 %/s, 60 % influence.
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 0.0, "side": "out", "speed": 200, "influence": 60})).unwrap();
let k = &prop(&s, l, "transform/opacity").keys[0];
assert_eq!(k.out_interp, Interp::Bezier);
assert_eq!(k.out_ease[0].speed, 200.0);
assert!((k.out_ease[0].influence - 0.6).abs() < 1e-9);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear);
}
#[test]
fn time_reverse_keyframes() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(30)), (3.0, json!(100))]);
select_prop_keys(&mut s, l, "transform/opacity");
s.execute("keys.timeReverse", json!({})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "transform/opacity");
assert_eq!(p.keys[0].value, KV::Scalar(100.0));
assert!((p.keys[1].time.seconds() - 2.0).abs() < 1e-6);
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].value, KV::Scalar(0.0)));
}
#[test]
fn separate_dimensions_split_and_rejoin() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 100])), (2.0, json!([200, 50]))]);
s.execute("prop.separateDimensions", json!({"layer": l})).unwrap();
let after = |s: &Session| {
let x = prop(s, l, "transform/positionX");
let y = prop(s, l, "transform/positionY");
assert_eq!(x.keys.len(), 2);
assert_eq!(y.keys[1].value, KV::Scalar(50.0));
// The layer still renders at the same place (render sees the separated values).
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let layer = &comp.layers[0];
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, effectcraft_time::Tick::from_seconds_f64(1.0));
let p = ctx.value(layer, layer.transform().unwrap().get("position").unwrap()).as_vec3();
assert!((p[0] - 100.0).abs() < 1e-6 && (p[1] - 75.0).abs() < 1e-6, "{p:?}");
};
undo_redo_roundtrip(&mut s, after, |s| assert!(s.active_comp().unwrap().layers[0].transform().unwrap().get("positionX").is_none()));
// X and Y animate independently.
s.execute("keys.select", json!({"keys": []})).unwrap();
s.execute("prop.select", json!({"layer": l, "path": "transform/positionX"})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/positionY").keys[0].out_interp, Interp::Linear);
// Setting Position (viewer drags) writes X/Y.
s.execute("time.set", json!({"time": 3.0})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [7, 8, 0]})).unwrap();
assert_eq!(prop(&s, l, "transform/positionX").keys.len(), 3);
// Re-join: one Position with keys at the union of times.
s.execute("prop.separateDimensions", json!({"layer": l})).unwrap();
let p = prop(&s, l, "transform/position");
assert_eq!(p.keys.len(), 3);
assert_eq!(p.keys[2].value, KV::Vec3([7.0, 8.0, 0.0]));
assert!(s.active_comp().unwrap().layers[0].transform().unwrap().get("positionX").is_none());
}
#[test]
fn parenting_keeps_the_layer_in_place() {
let (mut s, child) = setup();
let r = s.execute("layer.newNull", json!({})).unwrap();
let par = r["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": par, "path": "transform/position", "value": [300, 200]})).unwrap();
s.execute("prop.set", json!({"layer": par, "path": "transform/rotation", "value": 90})).unwrap();
s.execute("prop.set", json!({"layer": par, "path": "transform/scale", "value": [200, 200]})).unwrap();
s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [100, 50]})).unwrap();
let world = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let l = comp.layer(effectcraft_project::LayerId(child)).unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, effectcraft_time::Tick::ZERO);
let (m, _) = ctx.layer_to_comp(l);
let a = m.apply(effectcraft_geom::vec2(50.0, 50.0));
let b = m.apply(effectcraft_geom::vec2(100.0, 50.0));
(a, (b.y - a.y).atan2(b.x - a.x).to_degrees(), (b.x - a.x).hypot(b.y - a.y))
};
let before = world(&s);
s.execute("layer.setParent", json!({"layers": [child], "parent": par})).unwrap();
let after = world(&s);
assert!((before.0.x - after.0.x).abs() < 1e-6 && (before.0.y - after.0.y).abs() < 1e-6, "{before:?} {after:?}");
assert!((before.1 - after.1).abs() < 1e-6 && (before.2 - after.2).abs() < 1e-6);
assert!((prop(&s, child, "transform/rotation").value.as_f64() + 90.0).abs() < 1e-9);
// Unparenting restores comp-space values.
s.execute("layer.setParent", json!({"layers": [child], "parent": null})).unwrap();
let p = prop(&s, child, "transform/position").value.as_vec3();
assert!((p[0] - 100.0).abs() < 1e-6 && (p[1] - 50.0).abs() < 1e-6, "{p:?}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(child)).unwrap().parent.is_some());
}
#[test]
fn pick_whip_generates_ae_reference_expressions() {
let (mut s, l) = setup();
let r = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap();
let o = r["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": o, "effect": "Gaussian Blur"})).unwrap();
s.execute("layer.addMask", json!({"layer": o})).unwrap();
let cases = [
("transform/opacity", json!({"layer": o, "path": "transform/opacity"}), "thisComp.layer(\"Other\").transform.opacity"),
("transform/rotation", json!({"layer": l, "path": "transform/opacity"}), "transform.opacity"),
("transform/rotation", json!({"layer": o, "path": "effects/#1/blurriness"}), "thisComp.layer(\"Other\").effect(\"Gaussian Blur\")(\"Blurriness\")"),
("transform/opacity", json!({"layer": o, "path": "masks/#1/feather"}), "thisComp.layer(\"Other\").mask(\"Mask 1\").maskFeather[0]"),
("transform/scale", json!({"layer": o, "path": "transform/rotation"}), "temp = thisComp.layer(\"Other\").transform.rotation;\n[temp, temp]"),
("transform/position", json!({"layer": o, "path": "transform/position"}), "thisComp.layer(\"Other\").transform.position"),
];
for (path, target, want) in cases {
let got = s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": target})).unwrap();
assert_eq!(got, json!(want));
assert_eq!(prop(&s, l, path).expr.unwrap().text, want);
}
s.execute("edit.undo", json!({})).unwrap();
assert_ne!(prop(&s, l, "transform/position").expr.map(|e| e.text), Some("thisComp.layer(\"Other\").transform.position".into()));
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
}
/// Outer comp (4 s) containing a precomp whose 10×10 white solid moves x = 10 + 20·t (t in s).
/// Returns (session, precomp layer id in the outer comp).
fn precomp_setup() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
let r = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 10, "height": 10})).unwrap();
let l = r["layer"].as_u64().unwrap();
animate(&mut s, l, "transform/position", &[(0.0, json!([10, 50])), (4.0, json!([90, 50]))]);
s.execute("comp.new", json!({"name": "Outer", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
let r = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap();
let pl = r["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [pl]})).unwrap();
(s, pl)
}
/// X of the moving square's centre in the outer comp at `t`.
fn square_x(s: &Session, t: f64) -> f64 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), effectcraft_render::RenderOpts { scale: 1.0, ..Default::default() });
let xs: Vec<usize> = (0..img.width as usize).filter(|x| img.get(*x as i64, 50)[3] > 0.5).collect();
assert!(!xs.is_empty(), "nothing drawn at {t}");
(xs[0] + xs[xs.len() - 1]) as f64 / 2.0 + 0.5
}
#[test]
fn time_remap_enable_freeze_and_disable() {
let (mut s, l) = precomp_setup();
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
s.execute("layer.enableTimeRemap", json!({})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "timeRemap");
assert_eq!(p.keys.len(), 2);
assert_eq!(p.keys[1].value, KV::Scalar(4.0));
// Remapping at identity shows the same frames.
assert!((square_x(s, 1.0) - 30.0).abs() < 1.5);
};
undo_redo_roundtrip(&mut s, after, |s| assert!(s.active_comp().unwrap().layers[0].props.get("timeRemap").is_none()));
// Remap the first key to show source time 3 s at comp time 0.
s.execute("keys.set", json!({"layer": l, "path": "timeRemap", "time": 0.0, "value": 3.0})).unwrap();
assert!((square_x(&s, 0.0) - 70.0).abs() < 1.5, "{}", square_x(&s, 0.0));
// Freeze Frame at 2 s: one hold key; every frame shows source time 2 s.
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("keys.set", json!({"layer": l, "path": "timeRemap", "time": 0.0, "value": 0.0})).unwrap();
s.execute("layer.freezeFrame", json!({})).unwrap();
let p = prop(&s, l, "timeRemap");
assert_eq!(p.keys.len(), 1);
assert_eq!(p.keys[0].out_interp, Interp::Hold);
for t in [0.0, 1.0, 3.5] {
assert!((square_x(&s, t) - 50.0).abs() < 1.5, "{t}: {}", square_x(&s, t));
}
// Ctrl+Alt+T again disables remapping.
s.execute("layer.enableTimeRemap", json!({})).unwrap();
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
// Solids can't be remapped.
let r = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
assert!(s.execute("layer.enableTimeRemap", json!({"layers": [r["layer"]]})).is_err());
}
#[test]
fn freeze_on_last_frame_extends_the_layer() {
let (mut s, l) = precomp_setup();
s.execute("layer.timing", json!({"layers": [l], "out": 2.0})).unwrap();
s.execute("layer.freezeOnLastFrame", json!({})).unwrap();
let layer = s.active_comp().unwrap().layers[0].clone();
assert_eq!(layer.out_point, s.active_comp().unwrap().duration);
// Plays normally, then holds the frame before the old out point.
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
let hold = 10.0 + 20.0 * (2.0 - 1.0 / 30.0);
assert!((square_x(&s, 3.5) - hold).abs() < 1.5, "{}", square_x(&s, 3.5));
}
#[test]
fn time_reverse_layer_plays_backwards_and_round_trips() {
let (mut s, l) = precomp_setup();
let before = s.active_comp().unwrap().layers[0].clone();
s.execute("layer.timeReverse", json!({})).unwrap();
let after = |s: &Session| {
let ly = &s.active_comp().unwrap().layers[0];
assert_eq!(ly.stretch, -100.0);
assert_eq!((ly.in_point, ly.out_point), (before.in_point, before.out_point));
// First frame shows the last source frame; the end shows the start.
let last = 10.0 + 20.0 * (4.0 - 1.0 / 30.0);
assert!((square_x(s, 0.0) - last).abs() < 1.5, "{}", square_x(s, 0.0));
assert!((square_x(s, 4.0 - 1.0 / 30.0) - 10.0).abs() < 1.5);
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(s.active_comp().unwrap().layers[0].stretch, 100.0));
// Reversing twice restores normal playback.
s.execute("layer.timeReverse", json!({"layers": [l]})).unwrap();
let ly = &s.active_comp().unwrap().layers[0];
assert_eq!(ly.stretch, 100.0);
assert_eq!(ly.start_time, before.start_time);
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
}
#[test]
fn time_stretch_holds_in_current_or_out() {
let (mut s, l) = precomp_setup();
s.execute("layer.timeStretch", json!({"percent": 50})).unwrap();
let ly = s.active_comp().unwrap().layers[0].clone();
assert_eq!(ly.stretch, 50.0);
assert!((ly.out_point.seconds() - 2.0).abs() < 1e-6);
// Twice as fast: comp 1 s shows source 2 s.
assert!((square_x(&s, 1.0) - 50.0).abs() < 1.5);
s.execute("edit.undo", json!({})).unwrap();
// Hold the current frame (2 s) in place while doubling the duration.
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("layer.timeStretch", json!({"layers": [l], "duration": 8.0, "hold": "current"})).unwrap();
let ly = s.active_comp().unwrap().layers[0].clone();
assert!((ly.stretch - 200.0).abs() < 1e-6);
assert!((ly.in_point.seconds() + 2.0).abs() < 1e-6, "{}", ly.in_point.seconds());
assert!((square_x(&s, 2.0) - 50.0).abs() < 1.5);
// Hold the out point.
s.execute("edit.undo", json!({})).unwrap();
s.execute("layer.timeStretch", json!({"layers": [l], "percent": 50, "hold": "out"})).unwrap();
let ly = s.active_comp().unwrap().layers[0].clone();
assert!((ly.out_point.seconds() - 4.0).abs() < 1e-6 && (ly.in_point.seconds() - 2.0).abs() < 1e-6);
assert!(s.execute("layer.timeStretch", json!({"layers": [l], "percent": 0})).is_err());
}