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:
parent
dee16181fe
commit
951ed1fa3d
|
|
@ -22,7 +22,8 @@ fn open(s: &mut Session, args: &[String]) {
|
|||
eprintln!("effectcraft: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
} else if args.iter().any(|a| a == "--demo") || true {
|
||||
} else {
|
||||
// `--demo` or no project: the demo project.
|
||||
let _ = s.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,10 +54,10 @@ fn main() -> eframe::Result {
|
|||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect();
|
||||
if !media.is_empty() {
|
||||
if let Err(e) = session.execute("file.import", json!({"paths": media})) {
|
||||
eprintln!("effectcraft: {e}");
|
||||
}
|
||||
if !media.is_empty()
|
||||
&& let Err(e) = session.execute("file.import", json!({"paths": media}))
|
||||
{
|
||||
eprintln!("effectcraft: {e}");
|
||||
}
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.ui.start_screen = home;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -27,6 +27,9 @@ function __begin(t, v, c, l, path, uid, idx, fd) {
|
|||
function __finish(r) {
|
||||
r = __v(r);
|
||||
if (r !== null && typeof r === 'object' && r.__isLayer) return r.index;
|
||||
// `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while
|
||||
// host requests can still be answered and the script re-run.
|
||||
if (Array.isArray(r)) return r.map(function (x) { return __v(x); });
|
||||
return r;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ impl Importer for MediaImporter {
|
|||
|
||||
/// A new session with media, import and expressions enabled.
|
||||
pub fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.footage = Arc::new(effectcraft_media::MediaPool::new());
|
||||
s.importer = Some(Arc::new(MediaImporter));
|
||||
s.expr = Some(Arc::new(effectcraft_expr::Expressions));
|
||||
s
|
||||
Session {
|
||||
footage: Arc::new(effectcraft_media::MediaPool::new()),
|
||||
importer: Some(Arc::new(MediaImporter)),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
..Session::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -37,4 +39,61 @@ mod tests {
|
|||
let img = s.render(cid, s.time(), effectcraft_engine::render::RenderOpts { scale: 0.25, ..Default::default() });
|
||||
assert!(img.data.iter().any(|p| p[3] > 0.5));
|
||||
}
|
||||
|
||||
/// Value of a property of layer `l` at time 0 (keys + expression).
|
||||
fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_engine::keyframe::Value {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap();
|
||||
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref() };
|
||||
ctx.value(layer, layer.props.prop(path).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_whip_expressions_evaluate() {
|
||||
let mut s = super::session();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 200, "height": 200, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"name": "Src", "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/rotation", "value": 33})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [12, 34]})).unwrap();
|
||||
s.execute("effect.apply", json!({"layer": b, "effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "effects/#1/blurriness", "value": 21})).unwrap();
|
||||
s.execute("layer.addMask", json!({"layer": b})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": b, "path": "masks/#1/feather", "value": [7, 7]})).unwrap();
|
||||
let link = |s: &mut effectcraft_engine::Session, from: &str, to: &str| {
|
||||
s.execute("prop.pickWhip", json!({"layer": a, "path": from, "target": {"layer": b, "path": to}})).unwrap();
|
||||
};
|
||||
link(&mut s, "transform/opacity", "transform/rotation");
|
||||
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 33.0);
|
||||
link(&mut s, "transform/scale", "transform/rotation");
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let ep = effectcraft_expr::eval_property(&s.project, cid, effectcraft_engine::project::LayerId(a), "transform/scale", 0.0);
|
||||
assert!(ep.is_ok(), "{ep:?}");
|
||||
assert_eq!(value(&s, a, "transform/scale").as_vec2(), [33.0, 33.0]);
|
||||
link(&mut s, "transform/position", "transform/position");
|
||||
assert_eq!(value(&s, a, "transform/position").as_vec2(), [12.0, 34.0]);
|
||||
link(&mut s, "transform/rotation", "effects/#1/blurriness");
|
||||
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 21.0);
|
||||
link(&mut s, "transform/opacity", "masks/#1/feather");
|
||||
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 7.0);
|
||||
// Separated dimensions: X Position reference.
|
||||
s.execute("prop.separateDimensions", json!({"layer": b})).unwrap();
|
||||
link(&mut s, "transform/rotation", "transform/positionX");
|
||||
assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_syntax_errors_disable_the_expression() {
|
||||
let mut s = super::session();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "1 +* ("})).unwrap();
|
||||
assert!(r["error"].is_string(), "{r}");
|
||||
let pr = s.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().clone();
|
||||
assert!(!pr.expr.unwrap().enabled);
|
||||
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 100.0);
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
|
||||
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 50.0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -302,6 +302,110 @@ fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: boo
|
|||
list.get(d).or(list.first()).copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The ease a key side presents in the Graph Editor and the Keyframe Velocity dialog: stored
|
||||
/// Bezier eases, auto-Bezier slopes, or the equivalent of a linear side (speed of the straight
|
||||
/// segment, 33.3 % influence). `None` for hold sides and sides without a neighbour.
|
||||
pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Option<Ease> {
|
||||
let k = keys.get(i)?;
|
||||
let j = if out { i + 1 } else { i.checked_sub(1)? };
|
||||
let n = keys.get(j)?;
|
||||
let interp = if out { k.out_interp } else { k.in_interp };
|
||||
if interp == Interp::Hold || (!out && n.out_interp == Interp::Hold) || !k.value.interpolates() {
|
||||
return None;
|
||||
}
|
||||
let is_spatial = spatial && matches!(k.value, Value::Vec2(_) | Value::Vec3(_));
|
||||
if interp == Interp::Linear {
|
||||
let dur = (secs(k.time) - secs(n.time)).abs().max(1e-12);
|
||||
let dv = if is_spatial {
|
||||
let (a, b) = if out { (i, j) } else { (j, i) };
|
||||
spatial_segment_length(keys, a.min(b))
|
||||
} else {
|
||||
let a = k.value.components();
|
||||
let b = n.value.components();
|
||||
let dv = b.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0);
|
||||
if out { dv } else { -dv }
|
||||
};
|
||||
return Some(Ease { speed: dv / dur, influence: 1.0 / 3.0 });
|
||||
}
|
||||
Some(effective_ease(keys, i, if is_spatial { 0 } else { d }, is_spatial, out))
|
||||
}
|
||||
|
||||
/// Arc length of the spatial segment `i` → `i + 1` (motion-path length).
|
||||
pub fn spatial_segment_length(keys: &[Keyframe], i: usize) -> f64 {
|
||||
let (Some(a), Some(b)) = (keys.get(i), keys.get(i + 1)) else { return 0.0 };
|
||||
let p0 = v3(&a.value);
|
||||
let p3 = v3(&b.value);
|
||||
let (_, out_t) = spatial_tangents(keys, i);
|
||||
let (in_t, _) = spatial_tangents(keys, i + 1);
|
||||
SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]).length()
|
||||
}
|
||||
|
||||
/// Re-time roving keyframes: each run of roving keys between two fixed (non-roving) keys gets
|
||||
/// times proportional to the motion-path length, so the layer moves through them at one
|
||||
/// constant speed (After Effects' "Rove Across Time"). The first and last keys never rove.
|
||||
/// Returns true when any key time changed. Key order is preserved.
|
||||
pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool {
|
||||
let n = keys.len();
|
||||
if n < 3 {
|
||||
for k in keys.iter_mut() {
|
||||
k.roving = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
keys[0].roving = false;
|
||||
keys[n - 1].roving = false;
|
||||
if !keys.iter().any(|k| k.roving) {
|
||||
return false;
|
||||
}
|
||||
let mut changed = false;
|
||||
let mut a = 0;
|
||||
while a < n - 1 {
|
||||
let Some(b) = (a + 1..n).find(|&j| !keys[j].roving) else { break };
|
||||
if b > a + 1 {
|
||||
// Cumulative path length (spatial) or value distance (other) of the run a..=b.
|
||||
let mut acc = vec![0.0];
|
||||
for i in a..b {
|
||||
let len = if spatial && matches!(keys[i].value, Value::Vec2(_) | Value::Vec3(_)) {
|
||||
spatial_segment_length(keys, i)
|
||||
} else {
|
||||
let x = keys[i].value.components();
|
||||
let y = keys[i + 1].value.components();
|
||||
x.iter().zip(&y).map(|(p, q)| (q - p) * (q - p)).sum::<f64>().sqrt()
|
||||
};
|
||||
acc.push(acc.last().unwrap_or(&0.0) + len);
|
||||
}
|
||||
let total = *acc.last().unwrap_or(&0.0);
|
||||
let (t0, t1) = (keys[a].time, keys[b].time);
|
||||
for (o, i) in (a + 1..b).enumerate() {
|
||||
let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 };
|
||||
let nt = Tick(t0.0 + ((t1.0 - t0.0) as f64 * f).round() as i64);
|
||||
if nt != keys[i].time {
|
||||
keys[i].time = nt;
|
||||
changed = true;
|
||||
}
|
||||
// Roving keys pass speed straight through.
|
||||
keys[i].in_interp = Interp::Linear;
|
||||
keys[i].out_interp = Interp::Linear;
|
||||
}
|
||||
}
|
||||
a = b;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Reverse a run of keys in time within `[first, last]` (Time-Reverse Keyframes): key `i` moves
|
||||
/// to `first + last - t` and in/out sides swap.
|
||||
pub fn time_reverse(keys: &mut [Keyframe]) {
|
||||
let (Some(first), Some(last)) = (keys.first().map(|k| k.time), keys.last().map(|k| k.time)) else { return };
|
||||
for k in keys.iter_mut() {
|
||||
k.time = Tick(first.0 + last.0 - k.time.0);
|
||||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||||
}
|
||||
keys.reverse();
|
||||
}
|
||||
|
||||
/// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values.
|
||||
pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option<Value> {
|
||||
let first = keys.first()?;
|
||||
|
|
@ -524,6 +628,50 @@ mod tests {
|
|||
assert_eq!(v.as_path().unwrap().vertices[0], [-7.5, -7.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roving_keys_get_constant_speed_times() {
|
||||
let mut keys = vec![
|
||||
Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])),
|
||||
Keyframe::new(s(0.2), Value::Vec2([300.0, 0.0])),
|
||||
Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])),
|
||||
];
|
||||
for k in &mut keys {
|
||||
k.spatial_auto = false;
|
||||
}
|
||||
keys[1].roving = true;
|
||||
assert!(retime_roving(&mut keys, true));
|
||||
// 300 of 400 px → 75 % of the 2 s span.
|
||||
assert!((keys[1].time.seconds() - 1.5).abs() < 1e-6, "{}", keys[1].time.seconds());
|
||||
let v0 = speed(&keys, s(0.5), true);
|
||||
let v1 = speed(&keys, s(1.8), true);
|
||||
assert!((v0 - 200.0).abs() < 1.0 && (v1 - 200.0).abs() < 1.0, "{v0} {v1}");
|
||||
// First/last keys can't rove.
|
||||
keys[0].roving = true;
|
||||
retime_roving(&mut keys, true);
|
||||
assert!(!keys[0].roving);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_ease_reports_linear_and_eased_sides() {
|
||||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()];
|
||||
let out = side_ease(&keys, 0, 0, false, true).unwrap();
|
||||
assert!((out.speed - 50.0).abs() < 1e-9);
|
||||
let inn = side_ease(&keys, 1, 0, false, false).unwrap();
|
||||
assert_eq!(inn, Ease::EASY);
|
||||
assert!(side_ease(&keys, 0, 0, false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_reverse_mirrors_keys() {
|
||||
let mut keys =
|
||||
vec![Keyframe::new(s(1.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(5.0)), Keyframe::new(s(4.0), Value::Scalar(10.0))];
|
||||
keys[0].out_interp = Interp::Hold;
|
||||
time_reverse(&mut keys);
|
||||
assert_eq!(keys.iter().map(|k| k.time.seconds().round() as i64).collect::<Vec<_>>(), vec![1, 3, 4]);
|
||||
assert_eq!(keys[0].value, Value::Scalar(10.0));
|
||||
assert_eq!(keys[2].in_interp, Interp::Hold);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn eased_values_stay_in_range(t in 0.0f64..1.0, i0 in 0.01f64..1.0, i1 in 0.01f64..1.0) {
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ impl Value {
|
|||
}
|
||||
}
|
||||
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
|
||||
Value::Path(_) => serde_json::from_value::<ShapePath>(j.clone()).ok().map(Value::Path).or_else(|| serde_json::from_value::<Value>(j.clone()).ok()),
|
||||
_ => serde_json::from_value::<Value>(j.clone()).ok(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -399,6 +399,38 @@ impl PropGroup {
|
|||
}
|
||||
None
|
||||
}
|
||||
/// Match-id path (`transform/position`, `effects/ec.blur.gaussian#2/blurriness`) of the node
|
||||
/// with `uid`: portable between layers with the same structure (keyframe paste, pick-whip).
|
||||
pub fn match_path_of(&self, uid: Uid) -> Option<String> {
|
||||
for (i, c) in self.children.iter().enumerate() {
|
||||
let n = self.children[..i].iter().filter(|o| o.match_id() == c.match_id()).count() + 1;
|
||||
let seg = if n == 1 { c.match_id().to_string() } else { format!("{}#{n}", c.match_id()) };
|
||||
if c.uid() == uid {
|
||||
return Some(seg);
|
||||
}
|
||||
if let Node::Group(g) = c
|
||||
&& let Some(p) = g.match_path_of(uid)
|
||||
{
|
||||
return Some(format!("{seg}/{p}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Chain of nodes from this group's children down to `uid` (inclusive).
|
||||
pub fn node_chain(&self, uid: Uid) -> Option<Vec<&Node>> {
|
||||
for c in &self.children {
|
||||
if c.uid() == uid {
|
||||
return Some(vec![c]);
|
||||
}
|
||||
if let Node::Group(g) = c
|
||||
&& let Some(mut rest) = g.node_chain(uid)
|
||||
{
|
||||
rest.insert(0, c);
|
||||
return Some(rest);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Human path (`Transform/Position`) of the node with `uid`.
|
||||
pub fn name_path_of(&self, uid: Uid) -> Option<String> {
|
||||
for c in &self.children {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,16 @@ impl<'a> EvalCtx<'a> {
|
|||
}
|
||||
/// Value of a property at the context time (keyframes, then expression).
|
||||
pub fn value(&self, layer: &Layer, prop: &Property) -> Value {
|
||||
// Separated Position reads as the combination of X/Y/Z Position.
|
||||
if prop.match_id == "position"
|
||||
&& let Some(tr) = layer.transform()
|
||||
&& let Some(px) = tr.get("positionX")
|
||||
&& tr.get("position").is_some_and(|p| p.uid == prop.uid)
|
||||
{
|
||||
let z = tr.get("positionZ").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0);
|
||||
let y = tr.get("positionY").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0);
|
||||
return Value::Vec3([self.value(layer, px).as_f64(), y, z]);
|
||||
}
|
||||
let lt = layer.layer_time(self.time);
|
||||
let v = prop.value_at(lt);
|
||||
if prop.has_expression()
|
||||
|
|
@ -63,6 +73,21 @@ impl<'a> EvalCtx<'a> {
|
|||
pub fn b(&self, layer: &Layer, g: &PropGroup, m: &str) -> bool {
|
||||
self.group_value(layer, g, m).map(|v| v.as_bool()).unwrap_or(false)
|
||||
}
|
||||
/// Layer Position, honouring Separate Dimensions (X/Y/Z Position properties).
|
||||
pub fn position(&self, layer: &Layer, tr: &PropGroup) -> [f64; 3] {
|
||||
match tr.get("positionX") {
|
||||
Some(px) => [self.value(layer, px).as_f64(), self.f(layer, tr, "positionY", 0.0), self.f(layer, tr, "positionZ", 0.0)],
|
||||
None => self.v3(layer, tr, "position", [0.0; 3]),
|
||||
}
|
||||
}
|
||||
/// Source time of a layer at the context time: Time Remap's value when enabled, else the
|
||||
/// (stretch-aware) layer time.
|
||||
pub fn source_time(&self, layer: &Layer) -> Tick {
|
||||
if let Some(tr) = layer.props.get("timeRemap") {
|
||||
return Tick::from_seconds_f64(self.value(layer, tr).as_f64());
|
||||
}
|
||||
layer.layer_time(self.time)
|
||||
}
|
||||
pub fn layer(&self, id: LayerId) -> Option<&'a Layer> {
|
||||
self.comp.layer(id)
|
||||
}
|
||||
|
|
@ -71,7 +96,7 @@ impl<'a> EvalCtx<'a> {
|
|||
pub fn local_matrix(&self, layer: &Layer) -> Mat4 {
|
||||
let Some(tr) = layer.transform() else { return Mat4::IDENTITY };
|
||||
let three = layer.is_3d();
|
||||
let pos = self.v3(layer, tr, "position", [0.0; 3]);
|
||||
let pos = self.position(layer, tr);
|
||||
let rz = self.f(layer, tr, "rotation", 0.0);
|
||||
if layer.is_camera() || layer.is_light() {
|
||||
return Mat4::translate(Vec3::from(pos)) * Mat4::rotate_z(rz);
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ impl<'a> Renderer<'a> {
|
|||
return None;
|
||||
}
|
||||
let sub = Renderer { project: self.project, footage: self.footage, expr: self.expr, opts: self.opts, depth: self.depth + 1 };
|
||||
let lt = layer.layer_time(ctx.time);
|
||||
let lt = ctx.source_time(layer);
|
||||
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s })
|
||||
}
|
||||
LayerSource::Footage { item } => {
|
||||
|
|
@ -175,7 +175,7 @@ impl<'a> Renderer<'a> {
|
|||
if !f.has_video {
|
||||
return None;
|
||||
}
|
||||
let lt = layer.layer_time(ctx.time);
|
||||
let lt = ctx.source_time(layer);
|
||||
let img = self.footage.frame(*item, f, lt)?;
|
||||
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
|
||||
// Keep native pixels when downsampling is small; resample otherwise.
|
||||
|
|
@ -332,6 +332,8 @@ pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Ima
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_remap;
|
||||
|
||||
/// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size
|
||||
/// for solids/footage/precomps, glyph bounds for text, painted bounds for shapes). Used for viewer
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
//! Time remapping and separated Position dimensions.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{Comp, ItemKind, LayerSource, Node, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::render_frame;
|
||||
|
||||
fn s(x: f64) -> Tick {
|
||||
Tick::from_seconds_f64(x)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_position_drives_the_transform() {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(200, 100, FrameRate::FPS_30, s(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 20, height: 20, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (20, 20), None);
|
||||
let mut next = p.next_id;
|
||||
let mut ids = Ids(&mut next);
|
||||
let mut px = ids.prop("positionX", "X Position", Value::Scalar(20.0));
|
||||
px.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(20.0)), Keyframe::new(s(1.0), Value::Scalar(180.0))];
|
||||
let py = ids.prop("positionY", "Y Position", Value::Scalar(50.0));
|
||||
let pz = ids.prop("positionZ", "Z Position", Value::Scalar(0.0));
|
||||
p.next_id = next;
|
||||
let tr = l.props.sub_mut("transform").unwrap();
|
||||
// The combined property is ignored while separated.
|
||||
tr.get_mut("position").unwrap().value = Value::Vec3([100.0, 10.0, 0.0]);
|
||||
tr.children.extend([Node::Prop(px), Node::Prop(py), Node::Prop(pz)]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
assert!(a.get(20, 50)[3] > 0.99 && a.get(100, 10)[3] < 0.01);
|
||||
let b = render_frame(&p, cid, s(0.5), 1.0);
|
||||
assert!(b.get(100, 50)[3] > 0.99 && b.get(20, 50)[3] < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_remap_picks_the_source_frame() {
|
||||
let mut p = Project::default();
|
||||
let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
|
||||
let mut inner = Comp::new(100, 100, FrameRate::FPS_30, s(4.0));
|
||||
// Inner comp: a solid that moves from x=10 (t=0) to x=90 (t=2).
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 10, height: 10, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 10), None);
|
||||
l.props.prop_mut("transform/position").unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 50.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([90.0, 50.0, 0.0]))];
|
||||
inner.layers.push(l);
|
||||
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
|
||||
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
|
||||
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
|
||||
let mut next = p.next_id;
|
||||
// Freeze on source time 2 s.
|
||||
let tr = Ids(&mut next).prop("timeRemap", "Time Remap", Value::Scalar(2.0));
|
||||
p.next_id = next;
|
||||
let mut tr = tr;
|
||||
tr.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(2.0)).hold()];
|
||||
pre.props.children.push(Node::Prop(tr));
|
||||
p.comp_mut(cid).unwrap().layers.push(pre);
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
assert!(img.get(90, 50)[3] > 0.99, "frozen at the end position");
|
||||
assert!(img.get(10, 50)[3] < 0.01);
|
||||
}
|
||||
|
|
@ -41,6 +41,9 @@ pub enum Dialog {
|
|||
CompSettings,
|
||||
SolidSettings,
|
||||
CommandPalette,
|
||||
KeyVelocity,
|
||||
KeyInterpolation,
|
||||
TimeStretch,
|
||||
}
|
||||
|
||||
/// Host hooks provided by the native app (file pickers etc.).
|
||||
|
|
|
|||
|
|
@ -245,6 +245,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
"app.compSettings" | "comp.settings" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
crate::panels::dialogs::open_comp_settings(app)?;
|
||||
}
|
||||
"keys.velocity" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_velocity(app)?,
|
||||
"keys.interpolation" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_interpolation(app)?,
|
||||
"layer.timeStretch" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_time_stretch(app)?,
|
||||
"app.solidSettings" | "layer.newSolid" if params.as_object().is_none_or(|m| m.is_empty()) => {
|
||||
crate::panels::dialogs::open_new_solid(app)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use serde_json::json;
|
|||
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp, widgets};
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompDraft {
|
||||
|
|
@ -34,6 +34,9 @@ pub struct DialogState {
|
|||
pub solid_size: [u32; 2],
|
||||
pub palette_query: String,
|
||||
pub palette_sel: usize,
|
||||
pub velocity: super::key_dialogs::VelocityDraft,
|
||||
pub interp: super::key_dialogs::InterpDraft,
|
||||
pub stretch: super::key_dialogs::StretchDraft,
|
||||
}
|
||||
|
||||
pub fn open_new_comp(app: &mut EffectcraftApp) {
|
||||
|
|
@ -72,7 +75,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
|
||||
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
|
||||
// Dim the app.
|
||||
let screen = ctx.content_rect();
|
||||
ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("modal-dim"))).rect_filled(screen, 0.0, Color32::from_black_alpha(120));
|
||||
|
|
@ -95,6 +98,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings),
|
||||
Dialog::SolidSettings => solid(app, ctx, &t),
|
||||
Dialog::CommandPalette => palette(app, ctx, &t),
|
||||
Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t),
|
||||
Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t),
|
||||
Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,7 +268,7 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
|
|||
}
|
||||
}
|
||||
|
||||
fn ui_enter(ctx: &egui::Context) -> bool {
|
||||
pub(crate) fn ui_enter(ctx: &egui::Context) -> bool {
|
||||
!ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,452 @@
|
|||
//! The Graph Editor (Timeline ▸ Shift+F3): value graph and speed graph of the selected (or all
|
||||
//! animated) properties, keyframes you can drag in time and value, Bezier/influence handles on
|
||||
//! selected keys, auto-zoom / fit, and the bottom button bar (graph type, show selected,
|
||||
//! auto-zoom, fit, separate dimensions, hold/linear/auto Bezier, Easy Ease family).
|
||||
//!
|
||||
//! Every edit is an engine command (`keys.set`, `keys.setEase`, `keys.select`, `keys.*`).
|
||||
|
||||
use effectcraft_engine::KeyRef;
|
||||
use effectcraft_engine::keyframe::{Interp, Value, side_ease};
|
||||
use effectcraft_engine::project::{Comp, Layer, LayerId, Property};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use super::timeline::TMap;
|
||||
use crate::EffectcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
type Actions = Vec<(String, serde_json::Value)>;
|
||||
|
||||
const DIM_COLORS: [Color32; 4] =
|
||||
[Color32::from_rgb(0xe0, 0x55, 0x55), Color32::from_rgb(0x5c, 0xc8, 0x5c), Color32::from_rgb(0x50, 0x8c, 0xf0), Color32::from_rgb(0xe8, 0xc8, 0x40)];
|
||||
const PROP_COLORS: [Color32; 6] = [
|
||||
Color32::from_rgb(0xe8, 0xc8, 0x40),
|
||||
Color32::from_rgb(0xd0, 0x70, 0xe0),
|
||||
Color32::from_rgb(0x40, 0xc8, 0xc8),
|
||||
Color32::from_rgb(0xf0, 0x90, 0x40),
|
||||
Color32::from_rgb(0x9c, 0xd8, 0x50),
|
||||
Color32::from_rgb(0xf0, 0x70, 0x90),
|
||||
];
|
||||
|
||||
struct Handle {
|
||||
out: bool,
|
||||
/// Comp seconds / graph value of the handle end.
|
||||
t: f64,
|
||||
v: f64,
|
||||
/// Original speed (sign is kept when dragging in the speed graph).
|
||||
speed: f64,
|
||||
/// Segment duration (layer seconds) the influence is relative to.
|
||||
dur: f64,
|
||||
}
|
||||
|
||||
struct KeyPt {
|
||||
idx: usize,
|
||||
time: Tick,
|
||||
/// Comp seconds.
|
||||
t: f64,
|
||||
v: f64,
|
||||
sel: bool,
|
||||
handles: Vec<Handle>,
|
||||
}
|
||||
|
||||
struct Curve<'a> {
|
||||
layer: &'a Layer,
|
||||
prop: &'a Property,
|
||||
dim: usize,
|
||||
color: Color32,
|
||||
pts: Vec<(f64, f64)>,
|
||||
keys: Vec<KeyPt>,
|
||||
/// Values editable in the value graph (not spatial / combined dimensions).
|
||||
editable: bool,
|
||||
}
|
||||
|
||||
/// Properties shown: the selected ones, or every animated property of the selected layers.
|
||||
fn shown_props(app: &EffectcraftApp, comp: &Comp) -> Vec<(LayerId, u64)> {
|
||||
let st = &app.session.state;
|
||||
let numeric = |pr: &Property| !pr.value.components().is_empty() && pr.value.interpolates();
|
||||
if app.ui.timeline.graph_show_selected && !st.selected_props.is_empty() {
|
||||
return st.selected_props.iter().filter(|(l, u)| comp.layer(*l).and_then(|l| l.props.find(*u)).is_some_and(numeric)).copied().collect();
|
||||
}
|
||||
let mut v = vec![];
|
||||
for l in comp.layers.iter().filter(|l| st.selected_layers.contains(&l.id)) {
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
if (pr.is_animated() || pr.has_expression()) && numeric(pr) {
|
||||
v.push((l.id, pr.uid));
|
||||
}
|
||||
});
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn shown_dims(l: &Layer, pr: &Property) -> usize {
|
||||
let n = pr.value.components().len();
|
||||
if pr.shown_dims > 0 && !l.is_3d() { (pr.shown_dims as usize).min(n) } else { n.min(4) }
|
||||
}
|
||||
|
||||
/// Build the curves for the visible time span.
|
||||
fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, plot: Rect, speed: bool) -> Vec<Curve<'a>> {
|
||||
let sel = &app.session.state.selected_keys;
|
||||
let n = ((plot.width() / 2.0) as usize).max(2);
|
||||
let mut out = vec![];
|
||||
for (pi, (lid, uid)) in shown_props(app, comp).into_iter().enumerate() {
|
||||
let Some(l) = comp.layer(lid) else { continue };
|
||||
let Some(pr) = l.props.find(uid) else { continue };
|
||||
let spatial = pr.spatial && matches!(pr.value, Value::Vec2(_) | Value::Vec3(_));
|
||||
let dims = if speed && spatial { 1 } else { shown_dims(l, pr) };
|
||||
for d in 0..dims {
|
||||
let color = if dims > 1 { DIM_COLORS[d % 4] } else { PROP_COLORS[pi % PROP_COLORS.len()] };
|
||||
let mut pts = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let x = plot.min.x + plot.width() * i as f32 / (n - 1) as f32;
|
||||
let ct = tm.t(x);
|
||||
let tt = Tick::from_seconds_f64(ct);
|
||||
let v = if speed {
|
||||
let lt = l.layer_time(tt);
|
||||
if spatial {
|
||||
effectcraft_engine::keyframe::speed(&pr.keys, lt, true)
|
||||
} else {
|
||||
effectcraft_engine::keyframe::velocity(&pr.keys, lt, false).get(d).copied().unwrap_or(0.0).abs()
|
||||
}
|
||||
} else {
|
||||
ectx.at(tt).value(l, pr).components().get(d).copied().unwrap_or(0.0)
|
||||
};
|
||||
pts.push((ct, v));
|
||||
}
|
||||
let mut keys = vec![];
|
||||
for (i, k) in pr.keys.iter().enumerate() {
|
||||
let ks = sel.contains(&KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
let kt = l.comp_time(k.time).seconds();
|
||||
let ed = if spatial { 0 } else { d };
|
||||
let mut handles = vec![];
|
||||
let mut side_speeds = vec![];
|
||||
for out_side in [false, true] {
|
||||
let Some(e) = side_ease(&pr.keys, i, ed, pr.spatial, out_side) else { continue };
|
||||
let j = if out_side { i + 1 } else { i - 1 };
|
||||
let dur = (pr.keys[j].time.seconds() - k.time.seconds()).abs();
|
||||
side_speeds.push(e.speed.abs());
|
||||
let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 };
|
||||
let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) };
|
||||
let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier };
|
||||
if ks && show && (bezier || speed) {
|
||||
handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur });
|
||||
}
|
||||
}
|
||||
let v = if speed {
|
||||
if side_speeds.is_empty() { 0.0 } else { side_speeds.iter().sum::<f64>() / side_speeds.len() as f64 }
|
||||
} else {
|
||||
k.value.components().get(d).copied().unwrap_or(0.0)
|
||||
};
|
||||
keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles });
|
||||
}
|
||||
out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: &mut f32, y: f32, label: &str, on: bool, id: &str, tip: &str) -> bool {
|
||||
let t = app.tokens;
|
||||
let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0;
|
||||
let r = Rect::from_min_size(pos2(*x, y - 9.0), vec2(w, 18.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip);
|
||||
p.rect_filled(
|
||||
r,
|
||||
3.0,
|
||||
if on {
|
||||
t.accent.gamma_multiply(0.55)
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
Color32::from_rgb(0x2c, 0x2c, 0x2c)
|
||||
},
|
||||
);
|
||||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text });
|
||||
app.auto.add(&format!("timeline.graph.{id}"), r, tip);
|
||||
*x += w + 4.0;
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
|
||||
pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect, actions: &mut Actions) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let footer_h = 28.0;
|
||||
let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
|
||||
let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
|
||||
// Background first so keys and handles (added later) sit on top for hit-testing.
|
||||
let bg = ui.interact(plot, egui::Id::new("graph-bg"), Sense::click_and_drag());
|
||||
p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
|
||||
app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
|
||||
let speed = app.ui.timeline.graph_mode == "speed";
|
||||
let cs = curves(app, comp, ectx, tm, plot, speed);
|
||||
|
||||
// Vertical range: auto-zoom to the curves + handles, or the stored manual range.
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for c in &cs {
|
||||
for (_, v) in &c.pts {
|
||||
lo = lo.min(*v);
|
||||
hi = hi.max(*v);
|
||||
}
|
||||
for k in &c.keys {
|
||||
for v in std::iter::once(k.v).chain(k.handles.iter().map(|h| h.v)) {
|
||||
lo = lo.min(v);
|
||||
hi = hi.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !lo.is_finite() {
|
||||
(lo, hi) = (0.0, 100.0);
|
||||
}
|
||||
if speed {
|
||||
lo = lo.min(0.0);
|
||||
}
|
||||
if (hi - lo).abs() < 1e-9 {
|
||||
hi = lo + 1.0;
|
||||
}
|
||||
let pad = (hi - lo) * 0.08;
|
||||
let auto = (lo - pad, hi + pad);
|
||||
// While a key or handle is being dragged the scale stays put (no feedback loop).
|
||||
let drag_id = egui::Id::new("graph-dragging");
|
||||
let dragging = ctx.data(|d| d.get_temp::<bool>(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down());
|
||||
let (lo, hi) = if app.ui.timeline.graph_auto_zoom && !dragging { auto } else { app.ui.timeline.graph_range.unwrap_or(auto) };
|
||||
if app.ui.timeline.graph_auto_zoom && !dragging {
|
||||
app.ui.timeline.graph_range = Some((lo, hi));
|
||||
}
|
||||
let ymap = |v: f64| plot.max.y - ((v - lo) / (hi - lo)) as f32 * plot.height();
|
||||
let vmap = |y: f32| lo + (plot.max.y - y) as f64 / plot.height() as f64 * (hi - lo);
|
||||
|
||||
// Grid + value labels.
|
||||
let pc = p.with_clip_rect(plot);
|
||||
let step = nice_step((hi - lo) / 6.0);
|
||||
let mut gv = (lo / step).ceil() * step;
|
||||
while gv <= hi {
|
||||
let y = ymap(gv);
|
||||
pc.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], Stroke::new(1.0, Color32::from_white_alpha(12)));
|
||||
pc.text(pos2(plot.min.x + 4.0, y - 1.0), Align2::LEFT_BOTTOM, fmt(gv, step), Tokens::ui(10.0), t.text_faint);
|
||||
gv += step;
|
||||
}
|
||||
if cs.is_empty() {
|
||||
p.text(plot.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint);
|
||||
}
|
||||
|
||||
// Curves.
|
||||
for c in &cs {
|
||||
let pts: Vec<Pos2> = c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), ymap(*v))).collect();
|
||||
pc.add(egui::Shape::line(pts, Stroke::new(1.5, c.color)));
|
||||
}
|
||||
// Keys and handles.
|
||||
let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![];
|
||||
let fr = comp.frame_rate;
|
||||
for c in &cs {
|
||||
let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim);
|
||||
for k in &c.keys {
|
||||
let kp = pos2(tm.x(k.t), ymap(k.v));
|
||||
if !plot.expand(6.0).contains(kp) {
|
||||
continue;
|
||||
}
|
||||
let kjson = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds()});
|
||||
key_screens.push((kjson.clone(), kp));
|
||||
// Handles (selected keys).
|
||||
for h in &k.handles {
|
||||
let hp = pos2(tm.x(h.t), ymap(h.v));
|
||||
pc.line_segment([kp, hp], Stroke::new(1.0, Color32::from_rgb(0xd8, 0xd8, 0x60)));
|
||||
pc.circle_filled(hp, 3.5, Color32::from_rgb(0xd8, 0xd8, 0x60));
|
||||
let side = if h.out { "out" } else { "in" };
|
||||
let hr = Rect::from_center_size(hp, vec2(10.0, 10.0));
|
||||
let hresp = ui.interact(hr, egui::Id::new(("ghandle", uid, d, k.idx, side)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle");
|
||||
if hresp.dragged()
|
||||
&& let Some(pt) = hresp.interact_pointer_pos()
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
|
||||
let mut dt = (tm.t(pt.x) - k.t) * if h.out { 1.0 } else { -1.0 };
|
||||
dt = dt.max(h.dur * 0.001);
|
||||
let influence = (dt / h.dur.max(1e-9) * 100.0).clamp(0.1, 100.0);
|
||||
let sp = if speed {
|
||||
let mag = vmap(pt.y).max(0.0);
|
||||
if h.speed < 0.0 { -mag } else { mag }
|
||||
} else {
|
||||
let dv = vmap(pt.y) - k.v;
|
||||
dv / dt * if h.out { 1.0 } else { -1.0 }
|
||||
};
|
||||
let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "side": side, "speed": sp, "influence": influence, "merge": format!("ghandle-{uid}-{}", k.idx)});
|
||||
if c.editable {
|
||||
params["dim"] = json!(d);
|
||||
}
|
||||
actions.push(("keys.setEase".into(), params));
|
||||
}
|
||||
if hresp.drag_stopped() {
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
}
|
||||
}
|
||||
// The key itself.
|
||||
let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) };
|
||||
pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col);
|
||||
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside);
|
||||
let kr = Rect::from_center_size(kp, vec2(11.0, 11.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new(("gkey", uid, d, k.idx)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
if (kresp.clicked() || kresp.drag_started()) && (!k.sel || shift) {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": shift})));
|
||||
}
|
||||
if kresp.dragged()
|
||||
&& let Some(pt) = kresp.interact_pointer_pos()
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
|
||||
let merge = format!("gkey-{uid}-{}", k.idx);
|
||||
let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0))));
|
||||
let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge});
|
||||
if !speed && c.editable {
|
||||
let mut comps = c.prop.keys[k.idx].value.components();
|
||||
if d < comps.len() {
|
||||
comps[d] = vmap(pt.y);
|
||||
}
|
||||
params["value"] = if comps.len() == 1 { json!(comps[0]) } else { json!(comps) };
|
||||
}
|
||||
actions.push(("keys.set".into(), params));
|
||||
}
|
||||
if kresp.drag_stopped() {
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Background: click deselects keys, drag box-selects.
|
||||
if bg.clicked() {
|
||||
actions.push(("keys.select".into(), json!({"keys": []})));
|
||||
}
|
||||
if bg.dragged()
|
||||
&& let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
|
||||
{
|
||||
let br = Rect::from_two_pos(o, cur);
|
||||
pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
|
||||
pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("graph-box"), br));
|
||||
}
|
||||
if bg.drag_stopped()
|
||||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("graph-box")))
|
||||
{
|
||||
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::new("graph-box")));
|
||||
let keys: Vec<serde_json::Value> = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
|
||||
actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
|
||||
}
|
||||
// Wheel over the graph with Alt/Ctrl zooms the value axis (turns auto-zoom off).
|
||||
if ui.rect_contains_pointer(plot) {
|
||||
let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
|
||||
if zoom && dy.abs() > 0.0 {
|
||||
let k = (-dy as f64 / 200.0).exp();
|
||||
let mid = (lo + hi) / 2.0;
|
||||
app.ui.timeline.graph_auto_zoom = false;
|
||||
app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
|
||||
}
|
||||
}
|
||||
|
||||
// Button bar.
|
||||
p.rect_filled(bar, 0.0, t.panel_bg);
|
||||
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
|
||||
let y = bar.center().y;
|
||||
let mut x = bar.min.x + 8.0;
|
||||
let tl = &app.ui.timeline;
|
||||
let (is_value, show_sel, auto_on) = (tl.graph_mode != "speed", tl.graph_show_selected, tl.graph_auto_zoom);
|
||||
if text_button(app, ui, p, &mut x, y, "Value", is_value, "valueGraph", "Edit Value Graph") {
|
||||
app.ui.timeline.graph_mode = "value".into();
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Speed", !is_value, "speedGraph", "Edit Speed Graph") {
|
||||
app.ui.timeline.graph_mode = "speed".into();
|
||||
}
|
||||
x += 6.0;
|
||||
if text_button(app, ui, p, &mut x, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") {
|
||||
app.ui.timeline.graph_show_selected = !show_sel;
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") {
|
||||
app.ui.timeline.graph_auto_zoom = !auto_on;
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Fit", false, "fit", "Fit all graphs to view") {
|
||||
app.ui.timeline.graph_auto_zoom = true;
|
||||
app.ui.timeline.pps = None;
|
||||
}
|
||||
if text_button(app, ui, p, &mut x, y, "Fit Selection", false, "fitSelection", "Fit selection to view") {
|
||||
fit_selection(app, &cs, tm, plot);
|
||||
}
|
||||
x += 6.0;
|
||||
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| {
|
||||
let layer = comp.layer(*l)?;
|
||||
let tr = layer.transform()?;
|
||||
(tr.get("position").is_some_and(|p| p.uid == *u) || ["positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == *u)))
|
||||
.then_some(l.0)
|
||||
});
|
||||
if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
|
||||
&& let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
|
||||
{
|
||||
actions.push(("prop.separateDimensions".into(), json!({"layer": l})));
|
||||
}
|
||||
x += 6.0;
|
||||
for (label, cmd, params, tip) in [
|
||||
("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"),
|
||||
("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"),
|
||||
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"}), "Convert selected keyframes to Auto Bezier"),
|
||||
("Easy Ease", "keys.easyEase", json!({}), "Easy Ease (F9)"),
|
||||
("Ease In", "keys.easyEaseIn", json!({}), "Easy Ease In (Shift+F9)"),
|
||||
("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"),
|
||||
] {
|
||||
let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or("");
|
||||
if text_button(app, ui, p, &mut x, y, label, false, &id, tip) {
|
||||
actions.push((cmd.into(), params));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Zoom time and value to the selected keys (and their handles).
|
||||
fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect) {
|
||||
let mut t0 = f64::INFINITY;
|
||||
let mut t1 = f64::NEG_INFINITY;
|
||||
let mut v0 = f64::INFINITY;
|
||||
let mut v1 = f64::NEG_INFINITY;
|
||||
for c in cs {
|
||||
for k in c.keys.iter().filter(|k| k.sel) {
|
||||
for (t, v) in std::iter::once((k.t, k.v)).chain(k.handles.iter().map(|h| (h.t, h.v))) {
|
||||
t0 = t0.min(t);
|
||||
t1 = t1.max(t);
|
||||
v0 = v0.min(v);
|
||||
v1 = v1.max(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !t0.is_finite() {
|
||||
return;
|
||||
}
|
||||
let span = (t1 - t0).max(0.2);
|
||||
let pps = (plot.width() as f64 * 0.8 / span).clamp(5.0, 4000.0);
|
||||
app.ui.timeline.pps = Some(pps);
|
||||
app.ui.timeline.start = (t0 - span * 0.1).max(0.0);
|
||||
let pad = ((v1 - v0) * 0.15).max(1.0);
|
||||
app.ui.timeline.graph_auto_zoom = false;
|
||||
app.ui.timeline.graph_range = Some((v0 - pad, v1 + pad));
|
||||
let _ = tm;
|
||||
}
|
||||
|
||||
fn nice_step(raw: f64) -> f64 {
|
||||
let raw = raw.abs().max(1e-9);
|
||||
let mag = 10f64.powf(raw.log10().floor());
|
||||
let n = raw / mag;
|
||||
mag * if n < 1.5 {
|
||||
1.0
|
||||
} else if n < 3.5 {
|
||||
2.0
|
||||
} else if n < 7.5 {
|
||||
5.0
|
||||
} else {
|
||||
10.0
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt(v: f64, step: f64) -> String {
|
||||
let dec = if step >= 1.0 { 0 } else { (-step.log10()).ceil() as usize };
|
||||
let s = format!("{v:.dec$}");
|
||||
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s }
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
//! Animation dialogs: Keyframe Velocity, Keyframe Interpolation and Time Stretch. Each one is
|
||||
//! pre-filled from the engine (`keys.info`, the layer) and commits through one engine command.
|
||||
|
||||
use egui::{Color32, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct VelocityDraft {
|
||||
pub dims: usize,
|
||||
pub units: String,
|
||||
pub name: String,
|
||||
pub in_speed: Vec<f64>,
|
||||
pub in_inf: Vec<f64>,
|
||||
pub out_speed: Vec<f64>,
|
||||
pub out_inf: Vec<f64>,
|
||||
/// Which sides exist (first key has no incoming side, last none outgoing).
|
||||
pub has_in: bool,
|
||||
pub has_out: bool,
|
||||
pub continuous: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct InterpDraft {
|
||||
/// Index into TEMPORAL / SPATIAL / ROVING (0 = Current Settings).
|
||||
pub temporal: usize,
|
||||
pub spatial: usize,
|
||||
pub roving: usize,
|
||||
pub has_spatial: bool,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct StretchDraft {
|
||||
pub percent: f64,
|
||||
pub duration: f64,
|
||||
/// 0 = Layer In-point, 1 = Current Frame, 2 = Layer Out-point.
|
||||
pub hold: usize,
|
||||
pub orig_duration: f64,
|
||||
pub orig_percent: f64,
|
||||
}
|
||||
|
||||
pub const TEMPORAL: [(&str, &str); 6] = [
|
||||
("Current Settings", ""),
|
||||
("Linear", "linear"),
|
||||
("Bezier", "bezier"),
|
||||
("Continuous Bezier", "continuousBezier"),
|
||||
("Auto Bezier", "autoBezier"),
|
||||
("Hold", "hold"),
|
||||
];
|
||||
pub const SPATIAL: [(&str, &str); 5] =
|
||||
[("Current Settings", ""), ("Linear", "linear"), ("Bezier", "bezier"), ("Continuous Bezier", "continuousBezier"), ("Auto Bezier", "autoBezier")];
|
||||
pub const ROVING: [&str; 3] = ["Current Settings", "Lock To Time", "Rove Across Time"];
|
||||
|
||||
fn num(v: &Value) -> f64 {
|
||||
v.as_f64().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Animation ▸ Keyframe Velocity… (⇧⌘K) with keys selected.
|
||||
pub fn open_velocity(app: &mut EffectcraftApp) -> Result<(), String> {
|
||||
let info = app.session.execute("keys.info", json!({})).map_err(|e| e.to_string())?;
|
||||
let first = info.as_array().and_then(|a| a.first()).ok_or("select keyframes first")?;
|
||||
let dims = first["dims"].as_u64().unwrap_or(1).max(1) as usize;
|
||||
let side = |k: &str| -> (Vec<f64>, Vec<f64>, bool) {
|
||||
let a = first[k].as_array().cloned().unwrap_or_default();
|
||||
let has = a.iter().any(|e| !e.is_null());
|
||||
let sp = (0..dims).map(|d| a.get(d).map(|e| num(&e["speed"])).unwrap_or(0.0)).collect();
|
||||
let inf = (0..dims).map(|d| a.get(d).map(|e| num(&e["influence"])).filter(|v| *v > 0.0).unwrap_or(33.33)).collect();
|
||||
(sp, inf, has)
|
||||
};
|
||||
let (in_speed, in_inf, has_in) = side("inEase");
|
||||
let (out_speed, out_inf, has_out) = side("outEase");
|
||||
app.dialog_state.velocity = VelocityDraft {
|
||||
dims,
|
||||
units: first["units"].as_str().unwrap_or("units/sec").into(),
|
||||
name: first["name"].as_str().unwrap_or("").into(),
|
||||
in_speed,
|
||||
in_inf,
|
||||
out_speed,
|
||||
out_inf,
|
||||
has_in,
|
||||
has_out,
|
||||
continuous: first["continuous"].as_bool().unwrap_or(false),
|
||||
};
|
||||
app.dialog = Some(Dialog::KeyVelocity);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Animation ▸ Keyframe Interpolation… (⌥⌘K).
|
||||
pub fn open_interpolation(app: &mut EffectcraftApp) -> Result<(), String> {
|
||||
let info = app.session.execute("keys.info", json!({})).map_err(|e| e.to_string())?;
|
||||
let a = info.as_array().cloned().unwrap_or_default();
|
||||
if a.is_empty() {
|
||||
return Err("select keyframes first".into());
|
||||
}
|
||||
app.dialog_state.interp = InterpDraft { has_spatial: a.iter().any(|k| !k["spatial"].is_null()), count: a.len(), ..Default::default() };
|
||||
app.dialog = Some(Dialog::KeyInterpolation);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Layer ▸ Time ▸ Time Stretch…
|
||||
pub fn open_time_stretch(app: &mut EffectcraftApp) -> Result<(), String> {
|
||||
let lid = *app.session.state.selected_layers.first().ok_or("select a layer first")?;
|
||||
let l = app.session.active_comp().and_then(|c| c.layer(lid)).ok_or("no layer")?;
|
||||
let dur = (l.out_point - l.in_point).seconds();
|
||||
app.dialog_state.stretch = StretchDraft { percent: l.stretch, duration: dur, hold: 0, orig_duration: dur, orig_percent: l.stretch };
|
||||
app.dialog = Some(Dialog::TimeStretch);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str) -> (bool, bool) {
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
ui.add_space(14.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
|
||||
app.auto.add(&format!("{prefix}.ok"), r.rect, "OK");
|
||||
ok = r.clicked();
|
||||
let r = ui.button("Cancel");
|
||||
app.auto.add(&format!("{prefix}.cancel"), r.rect, "Cancel");
|
||||
cancel = r.clicked();
|
||||
});
|
||||
(ok, cancel)
|
||||
}
|
||||
|
||||
fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive<f64>, suffix: &str, enabled: bool) {
|
||||
let r = ui.add_enabled(enabled, egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix));
|
||||
app.auto.add(id, r.rect, id);
|
||||
}
|
||||
|
||||
pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut d = app.dialog_state.velocity.clone();
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
let title = if d.name.is_empty() { "Keyframe Velocity".to_string() } else { format!("Keyframe Velocity: {}", d.name) };
|
||||
let h = 250.0 + 2.0 * 26.0 * d.dims as f32;
|
||||
super::dialogs::modal(ctx, &title, vec2(440.0, h), t, |ui| {
|
||||
for (label, out) in [("Incoming Velocity", false), ("Outgoing Velocity", true)] {
|
||||
ui.label(egui::RichText::new(label).font(Tokens::medium(12.5)));
|
||||
let enabled = if out { d.has_out } else { d.has_in };
|
||||
egui::Grid::new(("vel", out)).num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
for dim in 0..d.dims {
|
||||
let tag = if d.dims > 1 { format!("Dimension {}", ["X", "Y", "Z", "W"][dim.min(3)]) } else { String::new() };
|
||||
ui.label(egui::RichText::new(tag).color(t.text_dim));
|
||||
let side = if out { "out" } else { "in" };
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Speed:");
|
||||
let v = if out { &mut d.out_speed[dim] } else { &mut d.in_speed[dim] };
|
||||
drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, 1.0, -1e7..=1e7, "", enabled);
|
||||
ui.label(egui::RichText::new(&d.units).color(t.text_dim));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Influence:");
|
||||
let v = if out { &mut d.out_inf[dim] } else { &mut d.in_inf[dim] };
|
||||
drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, 0.2, 0.1..=100.0, " %", enabled);
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
let r = ui.checkbox(&mut d.continuous, "Continuous (Lock Outgoing to Incoming)");
|
||||
app.auto.add("dialog.velocity.continuous", r.rect, "Continuous");
|
||||
if d.continuous {
|
||||
d.out_speed = d.in_speed.clone();
|
||||
}
|
||||
(ok, cancel) = buttons(ui, app, t, "dialog.velocity");
|
||||
});
|
||||
app.dialog_state.velocity = d.clone();
|
||||
if ok || super::dialogs::ui_enter(ctx) {
|
||||
let mut params = json!({"continuous": d.continuous});
|
||||
if d.has_in {
|
||||
params["inSpeed"] = json!(d.in_speed);
|
||||
params["inInfluence"] = json!(d.in_inf);
|
||||
}
|
||||
if d.has_out {
|
||||
params["outSpeed"] = json!(d.out_speed);
|
||||
params["outInfluence"] = json!(d.out_inf);
|
||||
}
|
||||
if let Err(e) = app.session.execute("keys.velocity", params) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
cancel = true;
|
||||
}
|
||||
if cancel {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn combo(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, sel: &mut usize, opts: &[&str], enabled: bool) {
|
||||
ui.add_enabled_ui(enabled, |ui| {
|
||||
let r = egui::ComboBox::from_id_salt(id).width(200.0).selected_text(opts[*sel]).show_ui(ui, |ui| {
|
||||
for (i, o) in opts.iter().enumerate() {
|
||||
ui.selectable_value(sel, i, *o);
|
||||
}
|
||||
});
|
||||
app.auto.add(id, r.response.rect, id);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn interpolation(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut d = app.dialog_state.interp.clone();
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
super::dialogs::modal(ctx, "Keyframe Interpolation", vec2(440.0, 250.0), t, |ui| {
|
||||
egui::Grid::new("interp-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| {
|
||||
ui.label("Temporal Interpolation:");
|
||||
combo(ui, app, "dialog.interpolation.temporal", &mut d.temporal, &TEMPORAL.map(|x| x.0), true);
|
||||
ui.end_row();
|
||||
ui.label("Spatial Interpolation:");
|
||||
combo(ui, app, "dialog.interpolation.spatial", &mut d.spatial, &SPATIAL.map(|x| x.0), d.has_spatial);
|
||||
ui.end_row();
|
||||
ui.label("Roving:");
|
||||
combo(ui, app, "dialog.interpolation.roving", &mut d.roving, &ROVING, d.has_spatial);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(format!("{} keyframe{} selected", d.count, if d.count == 1 { "" } else { "s" })).color(t.text_dim));
|
||||
(ok, cancel) = buttons(ui, app, t, "dialog.interpolation");
|
||||
});
|
||||
app.dialog_state.interp = d.clone();
|
||||
if ok || super::dialogs::ui_enter(ctx) {
|
||||
let mut params = json!({});
|
||||
if d.temporal > 0 {
|
||||
params["interpolation"] = json!(TEMPORAL[d.temporal].1);
|
||||
}
|
||||
if d.spatial > 0 && d.has_spatial {
|
||||
params["spatial"] = json!(SPATIAL[d.spatial].1);
|
||||
}
|
||||
if d.roving > 0 && d.has_spatial {
|
||||
params["roving"] = json!(d.roving == 2);
|
||||
}
|
||||
if params.as_object().is_some_and(|m| !m.is_empty())
|
||||
&& let Err(e) = app.session.execute("keys.interpolation", params)
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
cancel = true;
|
||||
}
|
||||
if cancel {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut d = app.dialog_state.stretch.clone();
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
super::dialogs::modal(ctx, "Time Stretch", vec2(420.0, 300.0), t, |ui| {
|
||||
ui.label(egui::RichText::new("Stretch").font(Tokens::medium(12.5)));
|
||||
egui::Grid::new("stretch-grid").num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| {
|
||||
ui.label("Original Duration:");
|
||||
ui.label(format!("{:.2} s", d.orig_duration));
|
||||
ui.end_row();
|
||||
ui.label("Stretch Factor:");
|
||||
let before = d.percent;
|
||||
drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, 0.5, -10000.0..=10000.0, " %", true);
|
||||
if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 {
|
||||
d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs();
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("New Duration:");
|
||||
let before = d.duration;
|
||||
drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, 0.05, 0.01..=100000.0, " s", true);
|
||||
if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 {
|
||||
d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration;
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("Hold In Place").font(Tokens::medium(12.5)));
|
||||
for (i, l) in ["Layer In-point", "Current Frame", "Layer Out-point"].into_iter().enumerate() {
|
||||
let r = ui.radio_value(&mut d.hold, i, l);
|
||||
app.auto.add(&format!("dialog.timeStretch.hold.{i}"), r.rect, l);
|
||||
}
|
||||
(ok, cancel) = buttons(ui, app, t, "dialog.timeStretch");
|
||||
});
|
||||
app.dialog_state.stretch = d.clone();
|
||||
if ok || super::dialogs::ui_enter(ctx) {
|
||||
let hold = ["in", "current", "out"][d.hold.min(2)];
|
||||
if let Err(e) = app.session.execute("layer.timeStretch", json!({"percent": d.percent, "hold": hold})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
cancel = true;
|
||||
}
|
||||
if cancel {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,9 @@
|
|||
pub mod dialogs;
|
||||
pub mod effect_controls;
|
||||
pub mod effects_presets;
|
||||
pub mod graph;
|
||||
pub mod info;
|
||||
pub mod key_dialogs;
|
||||
pub mod misc;
|
||||
pub mod project;
|
||||
pub mod text_panels;
|
||||
|
|
|
|||
|
|
@ -167,10 +167,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if resp.double_clicked() {
|
||||
match &it.kind {
|
||||
ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
|
||||
ItemKind::Folder => {
|
||||
if !app.ui.project_open_folders.remove(&id.0) {
|
||||
app.ui.project_open_folders.insert(id.0);
|
||||
}
|
||||
ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
|
||||
app.ui.project_open_folders.insert(id.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ struct Cols {
|
|||
label: f32,
|
||||
num: f32,
|
||||
name: f32,
|
||||
name_w: f32,
|
||||
switches: f32,
|
||||
mode: f32,
|
||||
trkmat: f32,
|
||||
|
|
@ -61,22 +60,22 @@ fn cols(x0: f32, width: f32, show_modes: bool) -> Cols {
|
|||
let mode = switches + SW * 8.0 + 6.0;
|
||||
let trkmat = mode + if show_modes { 92.0 } else { 0.0 };
|
||||
let parent = trkmat + if show_modes { 112.0 } else { 0.0 };
|
||||
Cols { av, label, num, name, name_w, switches, mode, trkmat, parent, end: parent + 116.0 }
|
||||
Cols { av, label, num, name, switches, mode, trkmat, parent, end: parent + 116.0 }
|
||||
}
|
||||
|
||||
/// Timeline horizontal mapping.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct TMap {
|
||||
x0: f32,
|
||||
start: f64,
|
||||
pps: f64,
|
||||
pub(crate) struct TMap {
|
||||
pub x0: f32,
|
||||
pub start: f64,
|
||||
pub pps: f64,
|
||||
}
|
||||
|
||||
impl TMap {
|
||||
fn x(&self, secs: f64) -> f32 {
|
||||
pub fn x(&self, secs: f64) -> f32 {
|
||||
self.x0 + ((secs - self.start) * self.pps) as f32
|
||||
}
|
||||
fn t(&self, x: f32) -> f64 {
|
||||
pub fn t(&self, x: f32) -> f64 {
|
||||
self.start + (x - self.x0) as f64 / self.pps
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +140,10 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool {
|
|||
if p.two_d_only && layer.is_3d() {
|
||||
return false;
|
||||
}
|
||||
// Separate Dimensions: X/Y/Z Position replace Position.
|
||||
if p.match_id == "position" && layer.transform().is_some_and(|tr| tr.get("positionX").is_some() && tr.get("position").is_some_and(|q| q.uid == p.uid)) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
|
|
@ -168,7 +171,7 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec<Row> {
|
|||
}
|
||||
if let Some(kind) = tl.reveal.first() {
|
||||
let (group, props): (&str, Vec<&str>) = match kind.as_str() {
|
||||
"position" => ("transform", vec!["position"]),
|
||||
"position" => ("transform", vec!["position", "positionX", "positionY", "positionZ"]),
|
||||
"scale" => ("transform", vec!["scale"]),
|
||||
"rotation" => ("transform", vec!["rotation", "rotationX", "rotationY", "orientation"]),
|
||||
"opacity" => ("transform", vec!["opacity"]),
|
||||
|
|
@ -439,7 +442,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let fd = comp.frame_duration().seconds();
|
||||
let cy0 = ruler.min.y + 11.0;
|
||||
let mut run: Option<(i64, i64)> = None;
|
||||
let mut draw_run = |a: i64, b: i64| {
|
||||
let draw_run = |a: i64, b: i64| {
|
||||
let x0 = tm.x(a as f64 * fd);
|
||||
let x1 = tm.x((b + 1) as f64 * fd);
|
||||
p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_green);
|
||||
|
|
@ -785,10 +788,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
if body.clicked() || body.drag_started() {
|
||||
if !is_sel {
|
||||
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)})));
|
||||
}
|
||||
if (body.clicked() || body.drag_started()) && !is_sel {
|
||||
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)})));
|
||||
}
|
||||
layer_context_menu(&body, layer, &mut actions);
|
||||
let drag_key = format!("bar-{}", layer.id.0);
|
||||
|
|
@ -1001,15 +1002,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
kresp.context_menu(|ui| {
|
||||
for (lbl, cmd, params) in [
|
||||
("Copy", "keys.copy", json!({})),
|
||||
("Paste", "keys.paste", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Keyframe Interpolation…", "keys.interpolation", json!({})),
|
||||
("Keyframe Velocity…", "keys.velocity", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
|
||||
("-", "", json!({})),
|
||||
("Easy Ease", "keys.easyEase", json!({})),
|
||||
("Easy Ease In", "keys.easyEaseIn", json!({})),
|
||||
("Easy Ease Out", "keys.easyEaseOut", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
|
||||
("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
|
||||
("Auto Bezier", "keys.interpolation", json!({"autoBezier": true})),
|
||||
("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
|
||||
("Select All Keyframes", "keys.selectAll", json!({})),
|
||||
("Delete", "keys.delete", json!({})),
|
||||
] {
|
||||
if lbl == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
if lbl == "Rove Across Time" && !prop.spatial {
|
||||
continue;
|
||||
}
|
||||
if ui.button(lbl).clicked() {
|
||||
if !ks {
|
||||
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
|
||||
|
|
@ -1041,15 +1059,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
// Graph editor.
|
||||
if graph_on {
|
||||
graph_editor(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
|
||||
super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
|
||||
}
|
||||
// Empty-area click in the graph: deselect keys; drag: box-select keys.
|
||||
let empty = ui.interact(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), egui::Id::new("tl-graph-bg"), Sense::click_and_drag());
|
||||
let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
|
||||
let empty = ui.interact(empty_rect, egui::Id::new("tl-graph-bg"), Sense::click_and_drag());
|
||||
if empty.clicked() {
|
||||
actions.push(("keys.select".into(), json!({"keys": []})));
|
||||
}
|
||||
if let (true, Some(origin), Some(cur)) =
|
||||
(empty.dragged(), empty.interact_pointer_pos().map(|_| ctx.input(|i| i.pointer.press_origin())).flatten(), empty.interact_pointer_pos())
|
||||
(empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos())
|
||||
{
|
||||
let br = Rect::from_two_pos(origin, cur);
|
||||
gp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
|
||||
|
|
@ -1188,6 +1207,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
for (id, params) in actions {
|
||||
if id == "__endMerge" {
|
||||
app.session.history.merge_key = None;
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
|
|
@ -1257,7 +1280,11 @@ fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(S
|
|||
item(ui, "Flip Vertical", "layer.transform", json!({"layers": [id], "op": "flipV"}));
|
||||
});
|
||||
ui.menu_button("Time", |ui| {
|
||||
item(ui, "Time-Reverse Layer", "layer.timeStretch", json!({"layers": [id], "op": "reverse"}));
|
||||
item(ui, "Enable Time Remapping", "layer.enableTimeRemap", json!({"layers": [id]}));
|
||||
item(ui, "Time-Reverse Layer", "layer.timeReverse", json!({"layers": [id]}));
|
||||
item(ui, "Time Stretch…", "layer.timeStretch", json!({}));
|
||||
item(ui, "Freeze Frame", "layer.freezeFrame", json!({"layers": [id]}));
|
||||
item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": [id]}));
|
||||
});
|
||||
ui.menu_button("Blending Mode", |ui| {
|
||||
for m in BlendMode::ALL {
|
||||
|
|
@ -1412,86 +1439,3 @@ fn value_editor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Value graph of the selected properties (numeric dimensions over time) with key markers.
|
||||
fn graph_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect) {
|
||||
let t = app.tokens;
|
||||
let props: Vec<(LayerId, u64)> = if app.session.state.selected_props.is_empty() {
|
||||
// Default: animated properties of selected layers.
|
||||
let mut v = vec![];
|
||||
for l in comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id)) {
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
if pr.is_animated() && !pr.value.components().is_empty() {
|
||||
v.push((l.id, pr.uid));
|
||||
}
|
||||
});
|
||||
}
|
||||
v
|
||||
} else {
|
||||
app.session.state.selected_props.clone()
|
||||
};
|
||||
p.rect_filled(area, 0.0, Color32::from_rgb(0x1e, 0x1e, 0x1e));
|
||||
// Grid.
|
||||
for i in 1..8 {
|
||||
let y = area.min.y + area.height() * i as f32 / 8.0;
|
||||
p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, Color32::from_white_alpha(10)));
|
||||
}
|
||||
if props.is_empty() {
|
||||
p.text(area.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint);
|
||||
return;
|
||||
}
|
||||
let colors =
|
||||
[Color32::from_rgb(0xe0, 0x50, 0x50), Color32::from_rgb(0x60, 0xd0, 0x60), Color32::from_rgb(0x50, 0x8c, 0xf0), Color32::from_rgb(0xe8, 0xc8, 0x40)];
|
||||
// Sample all curves over the visible span; normalise to a common range.
|
||||
let n = ((area.width() / 2.0) as usize).max(2);
|
||||
let mut curves: Vec<(Vec<Vec<f64>>, &Layer, &Property)> = vec![];
|
||||
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
|
||||
for (lid, uid) in &props {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let Some(pr) = l.props.find(*uid) else { continue };
|
||||
let mut dims: Vec<Vec<f64>> = vec![];
|
||||
for i in 0..n {
|
||||
let x = area.min.x + area.width() * i as f32 / (n - 1) as f32;
|
||||
let tt = Tick::from_seconds_f64(tm.t(x));
|
||||
let v = ectx.at(tt).value(l, pr).components();
|
||||
if dims.is_empty() {
|
||||
dims = vec![Vec::with_capacity(n); v.len().min(3)];
|
||||
}
|
||||
for (d, c) in v.iter().take(dims.len()).enumerate() {
|
||||
dims[d].push(*c);
|
||||
lo = lo.min(*c);
|
||||
hi = hi.max(*c);
|
||||
}
|
||||
}
|
||||
curves.push((dims, l, pr));
|
||||
}
|
||||
if !lo.is_finite() || !hi.is_finite() {
|
||||
return;
|
||||
}
|
||||
if (hi - lo).abs() < 1e-9 {
|
||||
hi = lo + 1.0;
|
||||
}
|
||||
let pad = (hi - lo) * 0.1;
|
||||
let (lo, hi) = (lo - pad, hi + pad);
|
||||
let ymap = |v: f64| area.max.y - ((v - lo) / (hi - lo)) as f32 * area.height();
|
||||
for (dims, l, pr) in &curves {
|
||||
for (d, vals) in dims.iter().enumerate() {
|
||||
let pts: Vec<Pos2> = vals.iter().enumerate().map(|(i, v)| pos2(area.min.x + area.width() * i as f32 / (n - 1) as f32, ymap(*v))).collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.5, colors[d % colors.len()])));
|
||||
}
|
||||
for k in &pr.keys {
|
||||
let x = tm.x(l.comp_time(k.time).seconds());
|
||||
for (d, v) in k.value.components().iter().take(dims.len()).enumerate() {
|
||||
let c = pos2(x, ymap(*v));
|
||||
p.rect_filled(Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, Color32::WHITE);
|
||||
p.rect_stroke(Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, colors[d % colors.len()]), StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Value labels.
|
||||
for i in 0..=4 {
|
||||
let v = lo + (hi - lo) * i as f64 / 4.0;
|
||||
p.text(pos2(area.min.x + 4.0, ymap(v)), Align2::LEFT_BOTTOM, format!("{v:.1}"), Tokens::ui(10.0), t.text_faint);
|
||||
}
|
||||
let _ = ui;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ pub fn last_fit(ctx: &egui::Context) -> f32 {
|
|||
#[derive(Clone, Debug)]
|
||||
enum Gesture {
|
||||
Move { layers: Vec<(LayerId, [f64; 3], Mat3)>, start: [f64; 2] },
|
||||
Scale { layer: LayerId, anchor_screen: Pos2, start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3, uniform: bool },
|
||||
Scale { layer: LayerId, start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3 },
|
||||
Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64 },
|
||||
Anchor { layer: LayerId, start_anchor: [f64; 3], start_pos: [f64; 3], start: [f64; 2], inv: Mat3, l2p: Mat3 },
|
||||
Pan { start_pan: [f32; 2] },
|
||||
|
|
@ -436,17 +436,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let (l2c, _) = ectx.layer_to_comp(&layer);
|
||||
let inv = l2c.inverse()?;
|
||||
let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
|
||||
let ac = l2c.apply(gv2(a[0], a[1]));
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
let _ = hi;
|
||||
Some(Gesture::Scale {
|
||||
layer: lid,
|
||||
anchor_screen: map.to_screen([ac.x, ac.y]),
|
||||
start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]),
|
||||
start_local: [lp.x - a[0], lp.y - a[1]],
|
||||
inv,
|
||||
uniform: hi < 4,
|
||||
})
|
||||
Some(Gesture::Scale { layer: lid, start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), start_local: [lp.x - a[0], lp.y - a[1]], inv })
|
||||
})
|
||||
} else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
|
||||
let layers: Vec<(LayerId, [f64; 3], Mat3)> = app
|
||||
|
|
@ -491,14 +483,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge}));
|
||||
}
|
||||
}
|
||||
Gesture::Scale { layer, start_scale, start_local, inv, uniform, .. } => {
|
||||
Gesture::Scale { layer, start_scale, start_local, inv, .. } => {
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
// Local point relative to the anchor in *unscaled* layer space.
|
||||
let a = comp.layer(layer).and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "anchor", [0.0; 3]))).unwrap_or([0.0; 3]);
|
||||
let cur = [lp.x - a[0], lp.y - a[1]];
|
||||
let fx = if start_local[0].abs() > 1e-6 { cur[0] / start_local[0] } else { 1.0 };
|
||||
let fy = if start_local[1].abs() > 1e-6 { cur[1] / start_local[1] } else { 1.0 };
|
||||
let (fx, fy) = if mods.shift || uniform && start_local[0].abs() > 1e-6 && start_local[1].abs() > 1e-6 && mods.shift {
|
||||
let (fx, fy) = if mods.shift {
|
||||
let f = (fx + fy) / 2.0;
|
||||
(f, f)
|
||||
} else {
|
||||
|
|
@ -847,7 +839,7 @@ fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32,
|
|||
app.ui.viewer.res = Resolution::ALL[i];
|
||||
}
|
||||
x = r.max.x + 6.0;
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest") {}
|
||||
let _ = tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest");
|
||||
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") {
|
||||
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -249,6 +249,28 @@ pub struct TimelineState {
|
|||
pub open_groups: BTreeSet<u64>,
|
||||
/// "Reveal" filter: only show these property match ids (P/S/R/T/A…) — empty = normal.
|
||||
pub reveal: Vec<String>,
|
||||
/// Graph Editor: `value` or `speed` graph.
|
||||
#[serde(default = "value_graph")]
|
||||
pub graph_mode: String,
|
||||
/// Show only the selected properties (else every animated property of the selected layers).
|
||||
#[serde(default = "yes")]
|
||||
pub graph_show_selected: bool,
|
||||
/// Auto-zoom the graph height to the visible curves.
|
||||
#[serde(default = "yes")]
|
||||
pub graph_auto_zoom: bool,
|
||||
/// Manual graph value range (when auto-zoom is off).
|
||||
#[serde(default)]
|
||||
pub graph_range: Option<(f64, f64)>,
|
||||
/// Properties whose inline expression editor is collapsed.
|
||||
#[serde(default)]
|
||||
pub expr_closed: BTreeSet<u64>,
|
||||
}
|
||||
|
||||
fn value_graph() -> String {
|
||||
"value".into()
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for TimelineState {
|
||||
|
|
@ -264,6 +286,11 @@ impl Default for TimelineState {
|
|||
open_layers: BTreeSet::new(),
|
||||
open_groups: BTreeSet::new(),
|
||||
reveal: vec![],
|
||||
graph_mode: value_graph(),
|
||||
graph_show_selected: true,
|
||||
graph_auto_zoom: true,
|
||||
graph_range: None,
|
||||
expr_closed: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue