mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 18:31:30 -06:00
Merge branch 'worktree-agent-a549a3d1a8812e372'
# Conflicts: # apps/effectcraft-cli/src/main.rs # crates/host/src/lib.rs # crates/render/src/lib.rs
This commit is contained in:
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -62,7 +62,8 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
||||
}
|
||||
if let Some(d) = f_p(p, "duration") {
|
||||
let old = c.duration;
|
||||
c.duration = Tick::from_seconds_f64(d.max(c.frame_rate.frame_duration().seconds()));
|
||||
// Whole frames, at least one.
|
||||
c.duration = c.frame_rate.snap_nearest(Tick::from_seconds_f64(d)).max(c.frame_rate.frame_duration());
|
||||
if c.work_area.1 == old {
|
||||
c.work_area.1 = c.duration;
|
||||
}
|
||||
@@ -91,7 +92,7 @@ fn apply_settings(c: &mut Comp, p: &Value) {
|
||||
fn new_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "name").unwrap_or("Comp 1").to_string();
|
||||
let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97);
|
||||
let mut c = Comp::new(1920, 1080, rate, Tick::from_seconds_f64(10.0));
|
||||
let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0)));
|
||||
apply_settings(&mut c, p);
|
||||
let id = s.edit("New Composition", None, |proj, st| {
|
||||
let mut name = name.clone();
|
||||
|
||||
@@ -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> {
|
||||
@@ -39,6 +39,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.state.selected_layers.clear();
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_keys.clear();
|
||||
s.state.selected_vertices.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -72,10 +73,13 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Keyframes selected → delete keys; else layers.
|
||||
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
|
||||
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("keys.delete", json!({}));
|
||||
}
|
||||
if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() {
|
||||
return s.execute("mask.deleteVertices", json!({}));
|
||||
}
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Clear", None, |proj, st| {
|
||||
let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?;
|
||||
@@ -97,18 +101,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 +207,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 +220,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn layers_or_keys(s: &Session) -> std::result::Result<(), String> {
|
||||
if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) }
|
||||
}
|
||||
|
||||
fn always_ok(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -263,12 +263,13 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
|
||||
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),
|
||||
};
|
||||
let start = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO);
|
||||
let fr = comp.frame_rate;
|
||||
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
|
||||
let id = s.edit("Add Footage to Comp", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &it.name, src, size, dur);
|
||||
l.start_time = start;
|
||||
l.in_point = start;
|
||||
l.out_point = (start + dur.unwrap_or(comp.duration)).min(comp.duration);
|
||||
l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration());
|
||||
insert_layer(proj, st, cid, l)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
@@ -302,6 +303,8 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.state.selected_layers = ids;
|
||||
s.state.selected_props.clear();
|
||||
s.state.selected_keys.clear();
|
||||
let keep = s.state.selected_layers.clone();
|
||||
s.state.selected_vertices.retain(|v| keep.contains(&v.layer));
|
||||
}
|
||||
Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::<Vec<_>>()))
|
||||
}
|
||||
@@ -484,10 +487,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(())
|
||||
})?;
|
||||
@@ -498,10 +553,10 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let t = s.time();
|
||||
let op = str_p(p, "op").unwrap_or("set").to_string();
|
||||
let delta = f_p(p, "delta").map(Tick::from_seconds_f64);
|
||||
let start = f_p(p, "start").map(Tick::from_seconds_f64);
|
||||
let inp = f_p(p, "in").map(Tick::from_seconds_f64);
|
||||
let outp = f_p(p, "out").map(Tick::from_seconds_f64);
|
||||
// Layer times stay on frame boundaries of the comp (as in AE).
|
||||
let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
|
||||
let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v)));
|
||||
let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out"));
|
||||
s.edit("Layer Timing", merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fd = comp.frame_duration();
|
||||
@@ -818,34 +873,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");
|
||||
@@ -977,7 +1004,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),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! 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 comp_rate = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate;
|
||||
let fd = comp_rate.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));
|
||||
let fr = comp_rate;
|
||||
l.in_point = fr.snap_nearest(a.min(b));
|
||||
l.out_point = fr.snap_nearest(a.max(b)).max(l.in_point + fr.frame_duration());
|
||||
}
|
||||
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)),
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Masks drawn and edited in the viewer: Pen tool (new mask, add vertex, Bezier tangents, close
|
||||
//! path), vertex selection, moving and deleting vertices, and Layer ▸ Mask ▸ Remove (All).
|
||||
//!
|
||||
//! Coordinates are in layer space. Edits of an animated Mask Path set a key at the current time.
|
||||
|
||||
use effectcraft_keyframe::{ShapePath, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Layer, MaskMode, PropGroup, Uid};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, b_p, bad, has_comp, has_layers, layer_mut, layer_p, merge_p, resolve_layer, str_p};
|
||||
use crate::{EngineError, Result, Session, VertexRef, cmd};
|
||||
|
||||
fn pt(v: Option<&Value>) -> Option<[f64; 2]> {
|
||||
let a = v?.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
|
||||
}
|
||||
|
||||
fn pts(v: Option<&Value>) -> Vec<[f64; 2]> {
|
||||
v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| pt(Some(p))).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Find a mask group by uid, 1-based index or name.
|
||||
fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> {
|
||||
let i = match key {
|
||||
Value::Number(n) => {
|
||||
let n = n.as_u64()?;
|
||||
masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))?
|
||||
}
|
||||
Value::String(s) => masks.children.iter().position(|c| c.name() == s)?,
|
||||
_ => return None,
|
||||
};
|
||||
masks.children.get_mut(i)?.as_group_mut()
|
||||
}
|
||||
|
||||
/// Edit the mask path of (layer, mask) at the current time.
|
||||
fn edit_path<T>(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result<T>) -> Result<(Uid, T)> {
|
||||
let (cid, lid) = layer_p(s, p, label)?;
|
||||
let key = p.get("mask").cloned().ok_or_else(|| bad(label, "missing `mask` (uid, index or name)"))?;
|
||||
let t = s.time();
|
||||
s.edit(label, merge_p(p), |proj, _| {
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?;
|
||||
let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?;
|
||||
let uid = g.uid;
|
||||
let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?;
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) };
|
||||
let r = f(&mut sp)?;
|
||||
pr.set_value_at(lt, KV::Path(sp));
|
||||
Ok((uid, r))
|
||||
})
|
||||
}
|
||||
|
||||
/// Pen tool: a new (open by default) mask from points.
|
||||
fn new_mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "mask.new")?;
|
||||
let v = pts(p.get("vertices"));
|
||||
if v.is_empty() {
|
||||
return Err(bad("mask.new", "need `vertices` [[x,y], …] in layer space"));
|
||||
}
|
||||
let n = v.len();
|
||||
let mut ins = pts(p.get("inTangents"));
|
||||
let mut outs = pts(p.get("outTangents"));
|
||||
ins.resize(n, [0.0; 2]);
|
||||
outs.resize(n, [0.0; 2]);
|
||||
let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) };
|
||||
let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add);
|
||||
let uid = s.edit("New Mask", None, |proj, st| {
|
||||
let mut next = proj.next_id;
|
||||
let l = layer_mut(proj, cid, lid)?;
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.new", "this layer can't have masks"))?;
|
||||
let k = masks.children.len();
|
||||
let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, build::MASK_COLORS[k % build::MASK_COLORS.len()]);
|
||||
let uid = g.uid;
|
||||
masks.children.push(g.into());
|
||||
proj.next_id = next;
|
||||
st.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: n - 1 }];
|
||||
Ok(uid)
|
||||
})?;
|
||||
Ok(json!({"mask": uid}))
|
||||
}
|
||||
|
||||
fn add_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let point = pt(p.get("point")).ok_or_else(|| bad("mask.addVertex", "missing `point` [x,y]"))?;
|
||||
let tin = pt(p.get("in")).unwrap_or([0.0; 2]);
|
||||
let tout = pt(p.get("out")).unwrap_or([0.0; 2]);
|
||||
let at = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
|
||||
let (_, lid) = layer_p(s, p, "mask.addVertex")?;
|
||||
let (uid, i) = edit_path(s, p, "mask.addVertex", |sp| {
|
||||
let i = at.unwrap_or(sp.vertices.len()).min(sp.vertices.len());
|
||||
sp.vertices.insert(i, point);
|
||||
sp.in_tangents.insert(i, tin);
|
||||
sp.out_tangents.insert(i, tout);
|
||||
Ok(i)
|
||||
})?;
|
||||
s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }];
|
||||
Ok(json!(i))
|
||||
}
|
||||
|
||||
fn set_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.setVertex", "missing `index`"))? as usize;
|
||||
let (point, tin, tout) = (pt(p.get("point")), pt(p.get("in")), pt(p.get("out")));
|
||||
edit_path(s, p, "mask.setVertex", |sp| {
|
||||
if i >= sp.vertices.len() {
|
||||
return Err(bad("mask.setVertex", "no such vertex"));
|
||||
}
|
||||
if let Some(v) = point {
|
||||
sp.vertices[i] = v;
|
||||
}
|
||||
if let Some(v) = tin {
|
||||
sp.in_tangents[i] = v;
|
||||
}
|
||||
if let Some(v) = tout {
|
||||
sp.out_tangents[i] = v;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn set_closed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = b_p(p, "closed").unwrap_or(true);
|
||||
edit_path(s, p, "mask.setClosed", |sp| {
|
||||
sp.closed = c;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(c))
|
||||
}
|
||||
|
||||
/// Selected (or given) vertices grouped per (layer, mask).
|
||||
fn vertex_groups(s: &Session, p: &Value) -> Result<std::collections::BTreeMap<(u64, Uid), Vec<usize>>> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let mut g: std::collections::BTreeMap<(u64, Uid), Vec<usize>> = Default::default();
|
||||
if let Some(Value::Array(a)) = p.get("vertices") {
|
||||
for v in a {
|
||||
let l = v.get("layer").and_then(|x| resolve_layer(comp, x)).ok_or_else(|| bad("mask", "vertex needs `layer`"))?;
|
||||
let m = v.get("mask").and_then(Value::as_u64).ok_or_else(|| bad("mask", "vertex needs `mask` uid"))?;
|
||||
let i = v.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask", "vertex needs `index`"))?;
|
||||
g.entry((l.0, m)).or_default().push(i as usize);
|
||||
}
|
||||
} else {
|
||||
for v in &s.state.selected_vertices {
|
||||
g.entry((v.layer.0, v.mask)).or_default().push(v.index);
|
||||
}
|
||||
}
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
fn select_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = vertex_groups(s, &json!({"vertices": p.get("vertices").cloned().unwrap_or(json!([]))}))?;
|
||||
let sel: Vec<VertexRef> =
|
||||
g.into_iter().flat_map(|((l, m), is)| is.into_iter().map(move |i| VertexRef { layer: effectcraft_project::LayerId(l), mask: m, index: i })).collect();
|
||||
if b_p(p, "add").unwrap_or(false) {
|
||||
for v in sel {
|
||||
if let Some(i) = s.state.selected_vertices.iter().position(|x| *x == v) {
|
||||
if b_p(p, "toggle").unwrap_or(false) {
|
||||
s.state.selected_vertices.remove(i);
|
||||
}
|
||||
} else {
|
||||
s.state.selected_vertices.push(v);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.state.selected_vertices = sel;
|
||||
}
|
||||
Ok(json!(s.state.selected_vertices.len()))
|
||||
}
|
||||
|
||||
fn has_vertices(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
if s.state.selected_vertices.is_empty() { Err("select mask vertices first".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Apply `f` to the selected vertices' paths (each mask once).
|
||||
fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapePath, &[usize])) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let groups = vertex_groups(s, p)?;
|
||||
let t = s.time();
|
||||
s.edit(label, merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for ((l, m), idx) in &groups {
|
||||
let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue };
|
||||
let lt = layer.layer_time(t);
|
||||
let Some(pr) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)).and_then(|g| g.get_mut("path")) else { continue };
|
||||
let KV::Path(mut sp) = pr.value_at(lt) else { continue };
|
||||
f(&mut sp, idx);
|
||||
pr.set_value_at(lt, KV::Path(sp));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!(groups.values().map(Vec::len).sum::<usize>()))
|
||||
}
|
||||
|
||||
fn move_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = pt(p.get("delta")).ok_or_else(|| bad("mask.moveVertices", "missing `delta` [dx,dy] (layer space)"))?;
|
||||
edit_vertices(s, p, "Move Mask Vertices", |sp, idx| {
|
||||
for &i in idx {
|
||||
if let Some(v) = sp.vertices.get_mut(i) {
|
||||
v[0] += d[0];
|
||||
v[1] += d[1];
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = edit_vertices(s, p, "Delete Mask Vertices", |sp, idx| {
|
||||
let mut idx = idx.to_vec();
|
||||
idx.sort_unstable();
|
||||
idx.dedup();
|
||||
for &i in idx.iter().rev() {
|
||||
if i < sp.vertices.len() {
|
||||
sp.vertices.remove(i);
|
||||
sp.in_tangents.remove(i);
|
||||
sp.out_tangents.remove(i);
|
||||
}
|
||||
}
|
||||
if sp.vertices.len() < 3 {
|
||||
sp.closed = false;
|
||||
}
|
||||
})?;
|
||||
s.state.selected_vertices.clear();
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn remove_masks(s: &mut Session, p: &Value, all: bool) -> Result<Value> {
|
||||
let (cid, lid) = layer_p(s, p, "mask.remove")?;
|
||||
let key = p.get("mask").cloned();
|
||||
if !all && key.is_none() {
|
||||
return Err(bad("mask.remove", "missing `mask`"));
|
||||
}
|
||||
s.edit(if all { "Remove All Masks" } else { "Remove Mask" }, None, |proj, st| {
|
||||
let l: &mut Layer = layer_mut(proj, cid, lid)?;
|
||||
let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.remove", "this layer has no masks"))?;
|
||||
if all {
|
||||
masks.children.clear();
|
||||
} else if let Some(k) = &key {
|
||||
let uid = find_mask(masks, k).map(|g| g.uid).ok_or_else(|| bad("mask.remove", "no such mask"))?;
|
||||
masks.children.retain(|c| c.uid() != uid);
|
||||
}
|
||||
st.selected_vertices.retain(|v| v.layer != lid);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("mask.new", "New Mask from Points", [], None, "{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, mode?}", has_layers, new_mask),
|
||||
cmd!("mask.addVertex", "Add Mask Vertex", [], None, "{layer?, mask, point: [x,y], in?, out?, index?}", has_layers, add_vertex),
|
||||
cmd!("mask.setVertex", "Set Mask Vertex", [], None, "{layer?, mask, index, point?, in?, out?, merge?}", has_layers, set_vertex),
|
||||
cmd!("mask.setClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{layer?, mask, closed?}", has_layers, set_closed),
|
||||
cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices),
|
||||
cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices),
|
||||
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
|
||||
cmd!("mask.remove", "Remove Mask", ["Layer", "Mask"], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)),
|
||||
cmd!("mask.removeAll", "Remove All Masks", ["Layer", "Mask"], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)),
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
//! 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 mask;
|
||||
mod prop;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
@@ -69,7 +73,11 @@ 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(mask::specs());
|
||||
v.extend(effect::specs());
|
||||
v.extend(time::specs());
|
||||
v.extend(render_queue::specs());
|
||||
|
||||
@@ -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, query};
|
||||
|
||||
/// 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) {
|
||||
@@ -38,10 +38,44 @@ fn with_prop<T>(
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 = f_p(p, "time").map(Tick::from_seconds_f64);
|
||||
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())))?;
|
||||
@@ -87,7 +121,7 @@ fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn add_key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.addKey")?;
|
||||
let at = f_p(p, "time").map(Tick::from_seconds_f64);
|
||||
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);
|
||||
@@ -128,8 +162,18 @@ 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);
|
||||
}
|
||||
// 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))).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) }),
|
||||
@@ -137,20 +181,20 @@ fn set_expr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = &mut pr.expr {
|
||||
x.enabled = e;
|
||||
} else if e {
|
||||
pr.expr = Some(effectcraft_project::Expression { text: default_expr(pr), enabled: true });
|
||||
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: default_expr(pr), enabled: true }) };
|
||||
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())))
|
||||
})
|
||||
}
|
||||
|
||||
fn default_expr(pr: &Property) -> String {
|
||||
let _ = pr;
|
||||
"value".to_string()
|
||||
})?;
|
||||
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> {
|
||||
@@ -225,7 +269,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| {
|
||||
@@ -262,8 +306,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;
|
||||
@@ -319,8 +369,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;
|
||||
@@ -339,6 +437,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
|
||||
@@ -353,41 +469,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
|
||||
@@ -451,7 +593,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
|
||||
),
|
||||
@@ -460,7 +602,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
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user