Files
effectcraft/crates/engine/src/commands/anim_tools.rs
T
Brandon ThomasandClaude Opus 5.5 3cdbf22aac ANM-6, CMP-5, CMP-3: Wiggler/Smoother/Motion Sketch, marker dialog + timeline markers, pre-compose options + comp navigator
- keys.wiggle / keys.smooth / motion.sketch engine commands (seeded noise, curve-fit
  smoothing within tolerance, pointer-path capture to Position keys), Wiggler, Smoother and
  Motion Sketch panels, viewer capture overlay.
- markers.list/set/delete/convert, Update Markers From Source; Marker dialog (time, duration,
  comment, chapter, URL + frame target, cue point, protected region, label); timeline marker
  drag with snapping, Alt-drag duration, Cmd/Ctrl-click delete, context menu, protected
  regions shaded.
- layer.precompose mode leave|move, adjustDuration, open; Pre-compose dialog; Composition
  Navigator bar and Mini-Flowchart (Tab).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:47:02 -04:00

412 lines
17 KiB
Rust

//! Keyframe assistants of the Wiggler, Smoother and Motion Sketch panels (Window ▸ Wiggler /
//! Smoother / Motion Sketch). Each is one undoable command, so agents get the same results as the
//! panels:
//!
//! * `keys.wiggle` adds random deviation keys between the first and last selected keys of each
//! property (Spatial or Temporal path, smooth or jagged noise, one / all-the-same / independent
//! dimensions, frequency in keys per second, magnitude in the property's units). Seeded, so a
//! given selection and seed always produce the same keys.
//! * `keys.smooth` replaces the selected run of keys by the fewest auto-Bezier keys that stay
//! within `tolerance` of the original curve (sampled every frame).
//! * `motion.sketch` turns a recorded pointer path (comp pixels, with capture timestamps) into
//! Position keys, one per frame, then smooths them like the Smoother.
use effectcraft_keyframe::{Interp, Keyframe, Value as KValue, evaluate};
use effectcraft_project::{LayerId, Uid};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use super::{CommandSpec, bad, f_p, has_comp, has_keys, layer_mut, layer_p, str_p};
use crate::{EngineError, KeyRef, Result, Session, cmd};
// ---------------------------------------------------------------- noise
fn splitmix(mut x: u64) -> u64 {
x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
x ^ (x >> 31)
}
/// Deterministic uniform noise in [-1, 1] for (seed, stream, index).
fn rnd(seed: u64, stream: u64, i: i64) -> f64 {
let h = splitmix(seed ^ splitmix(stream.wrapping_mul(0x1000_0000_01b3) ^ (i as u64)));
(h >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
}
/// Wiggler noise type.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Noise {
/// Gradual deviations: lattice noise at half the key rate with cosine interpolation.
Smooth,
/// Independent deviations per key.
Jagged,
}
/// Noise sample `k` (0-based key index) in [-1, 1].
fn noise(kind: Noise, seed: u64, stream: u64, k: i64) -> f64 {
match kind {
Noise::Jagged => rnd(seed, stream, k),
Noise::Smooth => {
let (i, f) = (k.div_euclid(2), (k.rem_euclid(2)) as f64 / 2.0);
let w = (1.0 - (f * std::f64::consts::PI).cos()) / 2.0;
rnd(seed, stream, i) * (1.0 - w) + rnd(seed, stream, i + 1) * w
}
}
}
/// Which dimensions the Wiggler affects.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dims {
/// Only dimension `n`.
One(usize),
/// All dimensions by the same amount.
Same,
/// Each dimension independently.
Independent,
}
/// Wiggler settings.
#[derive(Clone, Copy, Debug)]
pub struct WiggleOpts {
/// Spatial path (deviate values along the motion path) or temporal path (deviate timing on
/// spatial properties; values on others).
pub spatial: bool,
pub noise: Noise,
pub dims: Dims,
/// Keys per second.
pub frequency: f64,
pub magnitude: f64,
pub seed: u64,
}
/// Wiggle `keys` between keys `a` and `b` (indices, `a < b`): returns the new key list. New keys
/// fall on whole frames of `fr` (via `to_layer`/`to_comp` time maps). `spatial_prop` selects
/// motion-path semantics.
#[allow(clippy::too_many_arguments)]
pub fn wiggle_keys(
keys: &[Keyframe],
a: usize,
b: usize,
spatial_prop: bool,
fr: FrameRate,
to_comp: &dyn Fn(Tick) -> Tick,
to_layer: &dyn Fn(Tick) -> Tick,
o: &WiggleOpts,
stream: u64,
) -> Vec<Keyframe> {
let (t0, t1) = (keys[a].time, keys[b].time);
let (f0, f1) = (fr.frame_at(to_comp(t0)), fr.frame_at(to_comp(t1)));
let fps = fr.as_f64();
let step = ((fps / o.frequency.max(0.01)).round() as i64).max(1);
let orig = keys.to_vec();
let mut out: Vec<Keyframe> = keys.iter().filter(|k| k.time <= t0 || k.time >= t1).cloned().collect();
let template = keys[a].clone();
let dims = template.value.components().len();
let mut k = 1;
loop {
let f = f0 + k * step;
if f >= f1 {
break;
}
let lt = to_layer(fr.tick_of(f));
let base_t = if o.spatial || !spatial_prop {
lt
} else {
// Temporal path on a spatial property: sample the path at a jittered time (magnitude in
// percent of the key spacing), so the speed along the path wiggles.
let jit = noise(o.noise, o.seed, stream, k) * o.magnitude / 100.0 * step as f64 / fps;
Tick::from_seconds_f64((lt.seconds() + jit).clamp(t0.seconds(), t1.seconds()))
};
let Some(v) = evaluate(&orig, base_t, spatial_prop) else { break };
let mut c = v.components();
if o.spatial || !spatial_prop {
for (d, x) in c.iter_mut().enumerate().take(dims) {
let n = match o.dims {
Dims::One(i) if i != d => 0.0,
Dims::One(_) | Dims::Same => noise(o.noise, o.seed, stream, k),
Dims::Independent => noise(o.noise, o.seed, stream.wrapping_add(d as u64 * 7919 + 1), k),
};
*x += n * o.magnitude;
}
}
let mut key = Keyframe::new(lt, v.with_components(&c));
if o.noise == Noise::Smooth {
key.in_interp = Interp::Bezier;
key.out_interp = Interp::Bezier;
key.auto_bezier = true;
}
out.push(key);
k += 1;
}
out.sort_by_key(|k| k.time);
out
}
/// Largest distance between the curve of `keys` and `samples`.
fn max_error(keys: &[Keyframe], samples: &[(Tick, Vec<f64>)], spatial: bool) -> (f64, usize) {
let mut worst = (0.0, 0);
for (i, (t, v)) in samples.iter().enumerate() {
let Some(e) = evaluate(keys, *t, spatial) else { continue };
let d = e.components().iter().zip(v).map(|(a, b)| (a - b) * (a - b)).sum::<f64>().sqrt();
if d > worst.0 {
worst = (d, i);
}
}
worst
}
/// Smoother: replace the keys strictly between `a` and `b` (indices) by the fewest auto-Bezier
/// keys at sample times that keep the curve within `tolerance` of `samples` (the original
/// curve, usually one sample per frame). Keys outside `a..=b` are kept.
pub fn smooth_keys(keys: &[Keyframe], a: usize, b: usize, samples: &[(Tick, Vec<f64>)], tolerance: f64, spatial: bool) -> Vec<Keyframe> {
let template = keys[a].value.clone();
let make = |t: Tick, c: &[f64]| {
let mut k = Keyframe::new(t, template.with_components(c));
if !spatial {
k.in_interp = Interp::Bezier;
k.out_interp = Interp::Bezier;
k.auto_bezier = true;
}
k
};
let mut chosen: Vec<usize> = vec![];
let build = |chosen: &[usize]| {
let mut v: Vec<Keyframe> = keys[..=a].to_vec();
v.extend(chosen.iter().map(|&i| make(samples[i].0, &samples[i].1)));
v.extend(keys[b..].iter().cloned());
v.sort_by_key(|k| k.time);
v
};
loop {
let cur = build(&chosen);
let (err, at) = max_error(&cur, samples, spatial);
let t = samples[at].0;
if err <= tolerance || chosen.contains(&at) || t <= keys[a].time || t >= keys[b].time || chosen.len() >= samples.len() {
return cur;
}
chosen.push(at);
chosen.sort_unstable();
}
}
/// The curve of `keys` sampled at every comp frame between key times `t0` and `t1` (layer time).
fn sample_curve(
keys: &[Keyframe],
t0: Tick,
t1: Tick,
spatial: bool,
fr: FrameRate,
to_comp: &dyn Fn(Tick) -> Tick,
to_layer: &dyn Fn(Tick) -> Tick,
) -> Vec<(Tick, Vec<f64>)> {
let (f0, f1) = (fr.frame_at(to_comp(t0)), fr.frame_at(to_comp(t1)));
(f0..=f1)
.filter_map(|f| {
let t = to_layer(fr.tick_of(f));
(t >= t0 && t <= t1).then(|| evaluate(keys, t, spatial).map(|v| (t, v.components())))?
})
.collect()
}
// ---------------------------------------------------------------- commands
fn grouped_selection(s: &Session) -> std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> {
let mut g: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
for k in &s.state.selected_keys {
g.entry((k.layer, k.prop)).or_default().push(k.time);
}
for v in g.values_mut() {
v.sort();
}
g
}
fn parse_wiggle(p: &Value) -> Result<WiggleOpts> {
let c = "keys.wiggle";
let spatial = match str_p(p, "apply").unwrap_or("spatial") {
"spatial" | "Spatial Path" => true,
"temporal" | "Temporal Path" => false,
x => return Err(bad(c, format!("apply: spatial|temporal, got `{x}`"))),
};
let noise = match str_p(p, "noise").unwrap_or("smooth") {
"smooth" | "Smooth" => Noise::Smooth,
"jagged" | "Jagged" => Noise::Jagged,
x => return Err(bad(c, format!("noise: smooth|jagged, got `{x}`"))),
};
let dims = match str_p(p, "dimensions").unwrap_or("independent") {
"one" => Dims::One(p.get("dimension").and_then(Value::as_u64).unwrap_or(0) as usize),
"same" | "all" => Dims::Same,
"independent" => Dims::Independent,
x => return Err(bad(c, format!("dimensions: one|same|independent, got `{x}`"))),
};
let frequency = f_p(p, "frequency").unwrap_or(5.0);
if frequency.is_nan() || frequency <= 0.0 {
return Err(bad(c, "frequency must be > 0"));
}
Ok(WiggleOpts {
spatial,
noise,
dims,
frequency,
magnitude: f_p(p, "magnitude").unwrap_or(1.0).abs(),
seed: p.get("seed").and_then(Value::as_u64).unwrap_or(0),
})
}
/// Shared driver: for every property with ≥ `min` selected keys, replace its keys with
/// `f(keys, first, last, spatial, layer_maps)`; select every key in each range afterwards.
#[allow(clippy::type_complexity)]
fn run_on_selection(
s: &mut Session,
label: &str,
cmd: &str,
min: usize,
f: &dyn Fn(&[Keyframe], usize, usize, bool, FrameRate, &dyn Fn(Tick) -> Tick, &dyn Fn(Tick) -> Tick, Uid) -> Vec<Keyframe>,
) -> Result<Value> {
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let groups = grouped_selection(s);
if !groups.values().any(|v| v.len() >= min) {
return Err(bad(cmd, format!("select at least {min} keyframes of a property")));
}
let (mut before, mut after) = (0usize, 0usize);
s.edit(label, None, |proj, st| {
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let mut sel = vec![];
for ((lid, uid), times) in groups {
let Some(l) = comp.layer_mut(lid) else { continue };
let lc = l.clone();
let to_comp = |t: Tick| lc.comp_time(t);
let to_layer = |t: Tick| lc.layer_time(t);
let Some(pr) = l.props.find_mut(uid) else { continue };
if times.len() < min || !pr.value.interpolates() {
continue;
}
let (Some(a), Some(b)) =
(effectcraft_keyframe::key_at(&pr.keys, times[0]), effectcraft_keyframe::key_at(&pr.keys, *times.last().unwrap_or(&times[0])))
else {
continue;
};
let in_range = |k: &Keyframe| k.time >= pr.keys[a].time && k.time <= pr.keys[b].time;
before += pr.keys.iter().filter(|k| in_range(k)).count();
let (t0, t1) = (pr.keys[a].time, pr.keys[b].time);
pr.keys = f(&pr.keys, a, b, pr.spatial, fr, &to_comp, &to_layer, uid);
let n: Vec<Tick> = pr.keys.iter().filter(|k| k.time >= t0 && k.time <= t1).map(|k| k.time).collect();
after += n.len();
sel.extend(n.into_iter().map(|time| KeyRef { layer: lid, prop: uid, time }));
}
st.selected_keys = sel;
Ok(())
})?;
Ok(json!({"before": before, "after": after}))
}
fn wiggle(s: &mut Session, p: &Value) -> Result<Value> {
let o = parse_wiggle(p)?;
run_on_selection(s, "Wiggler", "keys.wiggle", 2, &|keys, a, b, spatial, fr, tc, tl, uid| wiggle_keys(keys, a, b, spatial, fr, tc, tl, &o, uid))
}
fn smooth(s: &mut Session, p: &Value) -> Result<Value> {
let tol = f_p(p, "tolerance").unwrap_or(1.0).max(0.0);
run_on_selection(s, "Smoother", "keys.smooth", 3, &|keys, a, b, spatial, fr, tc, tl, _| {
let samples = sample_curve(keys, keys[a].time, keys[b].time, spatial, fr, tc, tl);
smooth_keys(keys, a, b, &samples, tol, spatial)
})
}
/// Motion Sketch: `points` are `[t, x, y]` (t = capture seconds from the start) or `[x, y]`
/// (one per frame); recorded from `start` (default the current time).
fn sketch(s: &mut Session, p: &Value) -> Result<Value> {
let c = "motion.sketch";
let (cid, lid) = layer_p(s, p, c)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let fr = comp.frame_rate;
let fd = fr.frame_duration().seconds();
let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(c, "missing `points`: [[t, x, y]…] or [[x, y]…] in comp pixels"))?;
let mut rec: Vec<(f64, [f64; 2])> = vec![];
for (i, v) in pts.iter().enumerate() {
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
match a.len() {
2 => rec.push((i as f64 * fd, [a[0], a[1]])),
3.. => rec.push((a[0], [a[1], a[2]])),
_ => return Err(bad(c, format!("point {i}: expected [t, x, y] or [x, y]"))),
}
}
if rec.len() < 2 {
return Err(bad(c, "need at least two points"));
}
rec.sort_by(|a, b| a.0.total_cmp(&b.0));
// Capture speed: 100 % plays back at the recorded speed; 200 % plays back twice as slow.
let speed = f_p(p, "captureSpeed").unwrap_or(100.0).max(1.0) / 100.0;
let smoothing = f_p(p, "smoothing").unwrap_or(1.0).max(0.0);
let start = p.get("start").and_then(Value::as_f64).map(Tick::from_seconds_f64).unwrap_or_else(|| s.time());
let f0 = fr.frame_at(start);
let t_rec0 = rec[0].0;
let span = (rec.last().map(|r| r.0).unwrap_or(0.0) - t_rec0) * speed;
let nframes = (span / fd).round() as i64;
let end_frame = fr.frame_at(comp.duration - comp.frame_duration());
let at = |secs: f64| {
// Linear interpolation of the recorded path at recording time `secs`.
let i = rec.partition_point(|r| r.0 <= secs).clamp(1, rec.len() - 1);
let (a, b) = (rec[i - 1], rec[i]);
let f = if b.0 > a.0 { ((secs - a.0) / (b.0 - a.0)).clamp(0.0, 1.0) } else { 1.0 };
[a.1[0] + (b.1[0] - a.1[0]) * f, a.1[1] + (b.1[1] - a.1[1]) * f]
};
let n = s.edit("Motion Sketch", None, |proj, st| {
let l = layer_mut(proj, cid, lid)?;
let lc = l.clone();
let pos = l.props.prop_mut("transform/position").ok_or_else(|| bad(c, "the layer has no Position"))?;
let z = pos.value.as_vec3()[2];
let three = matches!(pos.value, KValue::Vec3(_));
let mut new: Vec<Keyframe> = vec![];
for k in 0..=nframes {
let f = f0 + k;
if f > end_frame {
break;
}
let q = at(t_rec0 + k as f64 * fd / speed);
let v = if three { KValue::Vec3([q[0], q[1], z]) } else { KValue::Vec2(q) };
new.push(Keyframe::new(lc.layer_time(fr.tick_of(f)), v));
}
let (Some(t0), Some(t1)) = (new.first().map(|k| k.time), new.last().map(|k| k.time)) else { return Ok(0) };
let mut keys: Vec<Keyframe> = pos.keys.iter().filter(|k| k.time < t0 || k.time > t1).cloned().collect();
keys.extend(new);
keys.sort_by_key(|k| k.time);
let (a, b) = (keys.iter().position(|k| k.time == t0).unwrap_or(0), keys.iter().position(|k| k.time == t1).unwrap_or(0));
if smoothing > 0.0 && b > a + 1 {
let samples: Vec<(Tick, Vec<f64>)> = keys[a..=b].iter().map(|k| (k.time, k.value.components())).collect();
keys = smooth_keys(&keys, a, b, &samples, smoothing, true);
}
pos.keys = keys;
let uid = pos.uid;
st.selected_keys = pos.keys.iter().filter(|k| k.time >= t0 && k.time <= t1).map(|k| KeyRef { layer: lid, prop: uid, time: k.time }).collect();
Ok(st.selected_keys.len())
})?;
Ok(json!({"keys": n}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"keys.wiggle",
"Wiggler",
[],
None,
"{apply?: spatial|temporal, noise?: smooth|jagged, dimensions?: one|same|independent, dimension? (index for `one`), frequency? (keys/s, 5), magnitude?, seed?}",
has_keys,
wiggle
),
cmd!("keys.smooth", "Smoother", [], None, "{tolerance? (property units, 1)}", has_keys, smooth),
cmd!(
"motion.sketch",
"Motion Sketch",
[],
None,
"{layer?, points: [[t, x, y]…] (t = capture seconds) | [[x, y]…] (one per frame), start? (s, default current time), captureSpeed? (%, 100), smoothing? (px, 1)}",
has_comp,
sketch
),
]
}