Files
effectcraft/crates/keyframe/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 951ed1fa3d 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>
2026-10-01 21:47:29 -04:00

687 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Animated values with After Effects keyframe semantics.
//!
//! * **Temporal** interpolation between two keys is linear, Bezier or hold. Bezier eases are given
//! per dimension as *speed* (value units per second) and *influence* (fraction of the segment's
//! duration, 0.1 %–100 %), exactly the numbers the Keyframe Velocity dialog shows.
//! * **Spatial** properties (Position, Anchor Point, Point effect params) move along a Bezier motion
//! path defined by spatial tangents; their temporal ease is a single speed along the path
//! (pixels/second), evaluated through an arc-length table.
//! * Auto-Bezier and continuous-Bezier keys derive their tangents from their neighbours.
pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use value::{Gradient, Justify, ShapePath, TextDoc, Value};
/// Temporal interpolation on one side of a keyframe.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Interp {
#[default]
Linear,
Bezier,
Hold,
}
impl Interp {
pub fn label(self) -> &'static str {
match self {
Interp::Linear => "Linear",
Interp::Bezier => "Bezier",
Interp::Hold => "Hold",
}
}
}
/// Temporal ease on one side of a key: speed in units/s and influence 0..1 (fraction of the
/// adjacent segment duration).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Ease {
pub speed: f64,
pub influence: f64,
}
impl Default for Ease {
fn default() -> Self {
Ease { speed: 0.0, influence: 1.0 / 6.0 }
}
}
impl Ease {
/// Easy Ease: zero speed, 33.33 % influence.
pub const EASY: Ease = Ease { speed: 0.0, influence: 1.0 / 3.0 };
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Keyframe {
pub time: Tick,
pub value: Value,
#[serde(default)]
pub in_interp: Interp,
#[serde(default)]
pub out_interp: Interp,
/// Per-dimension (or one entry for spatial / single-dimension values).
#[serde(default)]
pub in_ease: Vec<Ease>,
#[serde(default)]
pub out_ease: Vec<Ease>,
/// Temporal continuous / auto Bezier flags.
#[serde(default)]
pub continuous: bool,
#[serde(default)]
pub auto_bezier: bool,
/// Spatial tangents relative to the key value (spatial properties only).
#[serde(default)]
pub spatial_in: [f64; 3],
#[serde(default)]
pub spatial_out: [f64; 3],
/// Spatial auto-Bezier: tangents computed from neighbours.
#[serde(default = "yes")]
pub spatial_auto: bool,
#[serde(default)]
pub spatial_continuous: bool,
#[serde(default)]
pub roving: bool,
}
fn yes() -> bool {
true
}
impl Keyframe {
pub fn new(time: Tick, value: Value) -> Keyframe {
Keyframe {
time,
value,
in_interp: Interp::Linear,
out_interp: Interp::Linear,
in_ease: vec![],
out_ease: vec![],
continuous: false,
auto_bezier: false,
spatial_in: [0.0; 3],
spatial_out: [0.0; 3],
spatial_auto: true,
spatial_continuous: false,
roving: false,
}
}
pub fn hold(mut self) -> Keyframe {
self.in_interp = Interp::Hold;
self.out_interp = Interp::Hold;
self
}
/// Easy Ease on both sides.
pub fn eased(mut self) -> Keyframe {
self.in_interp = Interp::Bezier;
self.out_interp = Interp::Bezier;
let n = self.value.dims().max(1);
self.in_ease = vec![Ease::EASY; n];
self.out_ease = vec![Ease::EASY; n];
self
}
/// Shape of the keyframe icon in the timeline.
pub fn icon(&self) -> KeyIcon {
let side = |i: Interp, auto: bool| match i {
Interp::Hold => KeyHalf::Hold,
Interp::Linear => KeyHalf::Linear,
Interp::Bezier if auto => KeyHalf::Auto,
Interp::Bezier => KeyHalf::Bezier,
};
KeyIcon { left: side(self.in_interp, self.auto_bezier), right: side(self.out_interp, self.auto_bezier) }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyHalf {
Linear,
Bezier,
Auto,
Hold,
}
/// Keyframe icon halves (diamond = linear, hourglass = bezier/ease, circle = auto, square = hold).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KeyIcon {
pub left: KeyHalf,
pub right: KeyHalf,
}
fn secs(t: Tick) -> f64 {
t.0 as f64 / TICKS_PER_SECOND as f64
}
/// Cubic Bezier in 1D.
fn bez(p0: f64, p1: f64, p2: f64, p3: f64, u: f64) -> f64 {
let v = 1.0 - u;
v * v * v * p0 + 3.0 * v * v * u * p1 + 3.0 * v * u * u * p2 + u * u * u * p3
}
fn bez_d(p0: f64, p1: f64, p2: f64, p3: f64, u: f64) -> f64 {
let v = 1.0 - u;
3.0 * v * v * (p1 - p0) + 6.0 * v * u * (p2 - p1) + 3.0 * u * u * (p3 - p2)
}
/// Solve `bez(x0..x3, u) = x` for u in 0..1 (x curve monotone).
fn solve_u(x0: f64, x1: f64, x2: f64, x3: f64, x: f64) -> f64 {
if x <= x0 {
return 0.0;
}
if x >= x3 {
return 1.0;
}
let mut u = (x - x0) / (x3 - x0);
for _ in 0..8 {
let f = bez(x0, x1, x2, x3, u) - x;
if f.abs() < 1e-12 {
return u;
}
let d = bez_d(x0, x1, x2, x3, u);
if d.abs() < 1e-12 {
break;
}
let n = u - f / d;
if !(0.0..=1.0).contains(&n) {
break;
}
u = n;
}
let (mut lo, mut hi) = (0.0, 1.0);
for _ in 0..60 {
let mid = (lo + hi) * 0.5;
if bez(x0, x1, x2, x3, mid) < x {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) * 0.5
}
/// Fraction of progress 0..1 through a 1D Bezier temporal segment whose value goes from 0 to 1,
/// with normalised out/in speeds (speed × duration / Δvalue) and influences.
pub fn ease_progress(out_speed_n: f64, out_inf: f64, in_speed_n: f64, in_inf: f64, x: f64) -> f64 {
let (mut i0, mut i1) = (out_inf.clamp(0.0, 1.0), in_inf.clamp(0.0, 1.0));
if i0 + i1 > 1.0 {
let k = 1.0 / (i0 + i1);
i0 *= k;
i1 *= k;
}
let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x);
bez(0.0, out_speed_n * i0, 1.0 - in_speed_n * i1, 1.0, u)
}
/// Spatial cubic segment with an arc-length table.
struct SpatialSeg {
p: [[f64; 3]; 4],
/// Cumulative length at u = i / N.
table: Vec<f64>,
}
const ARC_N: usize = 96;
impl SpatialSeg {
fn new(p: [[f64; 3]; 4]) -> SpatialSeg {
let mut table = Vec::with_capacity(ARC_N + 1);
table.push(0.0);
let mut prev = Self::point(&p, 0.0);
let mut acc = 0.0;
for i in 1..=ARC_N {
let q = Self::point(&p, i as f64 / ARC_N as f64);
acc += ((q[0] - prev[0]).powi(2) + (q[1] - prev[1]).powi(2) + (q[2] - prev[2]).powi(2)).sqrt();
table.push(acc);
prev = q;
}
SpatialSeg { p, table }
}
fn point(p: &[[f64; 3]; 4], u: f64) -> [f64; 3] {
[bez(p[0][0], p[1][0], p[2][0], p[3][0], u), bez(p[0][1], p[1][1], p[2][1], p[3][1], u), bez(p[0][2], p[1][2], p[2][2], p[3][2], u)]
}
fn length(&self) -> f64 {
*self.table.last().unwrap_or(&0.0)
}
fn at_length(&self, s: f64) -> [f64; 3] {
let len = self.length();
if len <= 0.0 {
return self.p[0];
}
let s = s.clamp(0.0, len);
let i = self.table.partition_point(|&v| v < s).clamp(1, ARC_N);
let (a, b) = (self.table[i - 1], self.table[i]);
let f = if b > a { (s - a) / (b - a) } else { 0.0 };
Self::point(&self.p, (i as f64 - 1.0 + f) / ARC_N as f64)
}
}
fn v3(v: &Value) -> [f64; 3] {
v.as_vec3()
}
/// Auto-Bezier spatial tangents (Catmull-Rom style) for key `i`.
fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
if i == 0 || i + 1 >= keys.len() {
return ([0.0; 3], [0.0; 3]);
}
let a = v3(&keys[i - 1].value);
let b = v3(&keys[i].value);
let c = v3(&keys[i + 1].value);
let d = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if dl < 1e-12 {
return ([0.0; 3], [0.0; 3]);
}
let lin = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt();
let lout = ((c[0] - b[0]).powi(2) + (c[1] - b[1]).powi(2) + (c[2] - b[2]).powi(2)).sqrt();
let n = [d[0] / dl, d[1] / dl, d[2] / dl];
let tin = [-n[0] * lin / 3.0, -n[1] * lin / 3.0, -n[2] * lin / 3.0];
let tout = [n[0] * lout / 3.0, n[1] * lout / 3.0, n[2] * lout / 3.0];
(tin, tout)
}
/// Effective spatial tangents of key `i` (auto or stored).
pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
if keys[i].spatial_auto { auto_spatial(keys, i) } else { (keys[i].spatial_in, keys[i].spatial_out) }
}
/// Effective temporal ease of key `i` for dimension `d` (auto-Bezier keys get the neighbour slope).
fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Ease {
let k = &keys[i];
let list = if out { &k.out_ease } else { &k.in_ease };
if k.auto_bezier && i > 0 && i + 1 < keys.len() {
let dt = secs(keys[i + 1].time) - secs(keys[i - 1].time);
let slope = if spatial {
let a = v3(&keys[i - 1].value);
let c = v3(&keys[i + 1].value);
((c[0] - a[0]).powi(2) + (c[1] - a[1]).powi(2) + (c[2] - a[2]).powi(2)).sqrt() / dt.max(1e-12)
} else {
let a = keys[i - 1].value.components();
let c = keys[i + 1].value.components();
(c.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0)) / dt.max(1e-12)
};
return Ease { speed: slope, influence: 1.0 / 6.0 };
}
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()?;
if keys.len() == 1 || t <= first.time {
return Some(first.value.clone());
}
let last = keys.last()?;
if t >= last.time {
return Some(last.value.clone());
}
let i = keys.partition_point(|k| k.time <= t) - 1;
Some(segment_value(keys, i, t, spatial))
}
/// Value inside segment `i` → `i+1` at time `t`.
pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value {
let a = &keys[i];
let b = &keys[i + 1];
if a.out_interp == Interp::Hold || !a.value.interpolates() {
return a.value.clone();
}
let (t0, t1) = (secs(a.time), secs(b.time));
let dur = (t1 - t0).max(1e-12);
let x = ((secs(t) - t0) / dur).clamp(0.0, 1.0);
let lin_out = a.out_interp == Interp::Linear;
let lin_in = b.in_interp == Interp::Linear;
let is_spatial = spatial && matches!(a.value, Value::Vec2(_) | Value::Vec3(_));
if is_spatial {
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);
let seg = 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]);
let len = seg.length();
let f = if lin_out && lin_in || len <= 1e-12 {
x
} else {
let eo = if lin_out { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, true, true) };
let ei = if lin_in { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, true, false) };
ease_progress(eo.speed * dur / len, eo.influence, ei.speed * dur / len, ei.influence, x)
};
let p = seg.at_length(f * len);
return a.value.with_components(&p);
}
if lin_out && lin_in {
return a.value.lerp(&b.value, x);
}
let ca = a.value.components();
let cb = b.value.components();
if ca.is_empty() || ca.len() != cb.len() {
// Paths, gradients: ease on dimension 0 as overall progress.
let eo = if lin_out { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, false, true) };
let ei = if lin_in { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, false, false) };
let f = ease_progress(eo.speed * dur, eo.influence, ei.speed * dur, ei.influence, x);
return a.value.lerp(&b.value, f);
}
let mut out = Vec::with_capacity(ca.len());
for d in 0..ca.len() {
let dv = cb[d] - ca[d];
let eo = if lin_out { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, d, false, true) };
let ei = if lin_in { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, d, false, false) };
// Bezier in (time, value) space directly: P1 = (i0, v0 + s0*i0*dur), P2 = (1-i1, v1 - s1*i1*dur).
let (mut i0, mut i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
if i0 + i1 > 1.0 {
let k = 1.0 / (i0 + i1);
i0 *= k;
i1 *= k;
}
let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x);
let v = bez(ca[d], ca[d] + eo.speed * i0 * dur, cb[d] - ei.speed * i1 * dur, cb[d], u);
out.push(v);
}
a.value.with_components(&out)
}
/// Numeric velocity (units/s per dimension) at `t` by central difference.
pub fn velocity(keys: &[Keyframe], t: Tick, spatial: bool) -> Vec<f64> {
let h = Tick(TICKS_PER_SECOND / 1000);
let (Some(a), Some(b)) = (evaluate(keys, t - h, spatial), evaluate(keys, t + h, spatial)) else { return vec![] };
let dt = 2.0 * secs(h);
a.components().iter().zip(b.components()).map(|(x, y)| (y - x) / dt).collect()
}
/// Speed (magnitude of velocity).
pub fn speed(keys: &[Keyframe], t: Tick, spatial: bool) -> f64 {
velocity(keys, t, spatial).iter().map(|v| v * v).sum::<f64>().sqrt()
}
/// Insert or replace (same time) a key; keeps keys sorted. Returns its index.
pub fn set_key(keys: &mut Vec<Keyframe>, key: Keyframe) -> usize {
match keys.binary_search_by(|k| k.time.cmp(&key.time)) {
Ok(i) => {
let mut k = key;
// Keep the existing interpolation when only the value changes.
let old = &keys[i];
k.in_interp = old.in_interp;
k.out_interp = old.out_interp;
k.in_ease = old.in_ease.clone();
k.out_ease = old.out_ease.clone();
k.auto_bezier = old.auto_bezier;
k.continuous = old.continuous;
k.spatial_in = old.spatial_in;
k.spatial_out = old.spatial_out;
k.spatial_auto = old.spatial_auto;
keys[i] = k;
i
}
Err(i) => {
keys.insert(i, key);
i
}
}
}
/// Index of the key exactly at `t`.
pub fn key_at(keys: &[Keyframe], t: Tick) -> Option<usize> {
keys.binary_search_by(|k| k.time.cmp(&t)).ok()
}
/// Apply Easy Ease (in, out or both) to key `i`.
pub fn easy_ease(k: &mut Keyframe, ease_in: bool, ease_out: bool) {
let n = k.value.dims().max(1);
if ease_in {
k.in_interp = Interp::Bezier;
k.in_ease = vec![Ease::EASY; n];
}
if ease_out {
k.out_interp = Interp::Bezier;
k.out_ease = vec![Ease::EASY; n];
}
k.auto_bezier = false;
}
#[cfg(test)]
mod tests {
use super::*;
fn s(x: f64) -> Tick {
Tick::from_seconds_f64(x)
}
#[test]
fn linear_and_hold() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0))];
assert_eq!(evaluate(&keys, s(1.0), false), Some(Value::Scalar(50.0)));
assert_eq!(evaluate(&keys, s(-1.0), false), Some(Value::Scalar(0.0)));
assert_eq!(evaluate(&keys, s(3.0), false), Some(Value::Scalar(100.0)));
let held = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).hold(), Keyframe::new(s(2.0), Value::Scalar(100.0))];
assert_eq!(evaluate(&held, s(1.999), false), Some(Value::Scalar(0.0)));
}
#[test]
fn easy_ease_is_symmetric_and_slow_at_ends() {
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(1.0), Value::Scalar(100.0)).eased()];
let mid = evaluate(&keys, s(0.5), false).unwrap().as_f64();
assert!((mid - 50.0).abs() < 1e-6, "{mid}");
let early = evaluate(&keys, s(0.1), false).unwrap().as_f64();
assert!(early < 10.0 && early > 0.0, "{early}");
let v0 = velocity(&keys, s(0.01), false)[0];
let vm = velocity(&keys, s(0.5), false)[0];
assert!(v0 < vm);
}
#[test]
fn bezier_with_linear_speed_matches_linear() {
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0));
let mut b = Keyframe::new(s(2.0), Value::Scalar(100.0));
a.out_interp = Interp::Bezier;
b.in_interp = Interp::Bezier;
a.out_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }];
b.in_ease = vec![Ease { speed: 50.0, influence: 1.0 / 3.0 }];
let keys = vec![a, b];
for i in 0..=20 {
let t = i as f64 * 0.1;
let v = evaluate(&keys, s(t), false).unwrap().as_f64();
assert!((v - 50.0 * t).abs() < 1e-6, "{t} {v}");
}
}
#[test]
fn spatial_linear_constant_speed() {
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))];
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
assert!((p[0] - 150.0).abs() < 0.5 && (p[1] - 200.0).abs() < 0.5, "{p:?}");
}
#[test]
fn spatial_auto_bezier_passes_through_keys() {
let keys = vec![
Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])),
Keyframe::new(s(1.0), Value::Vec2([100.0, 100.0])),
Keyframe::new(s(2.0), Value::Vec2([200.0, 0.0])),
];
let p = evaluate(&keys, s(1.0), true).unwrap().as_vec2();
assert_eq!(p, [100.0, 100.0]);
// Curved: the midpoint of the first segment lies off the straight line.
let m = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
assert!((m[0] - m[1]).abs() > 1.0, "{m:?}");
}
#[test]
fn set_key_replaces_same_time() {
let mut keys = vec![];
set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(1.0)).eased());
set_key(&mut keys, Keyframe::new(s(0.0), Value::Scalar(0.0)));
set_key(&mut keys, Keyframe::new(s(1.0), Value::Scalar(5.0)));
assert_eq!(keys.len(), 2);
assert_eq!(keys[1].value, Value::Scalar(5.0));
assert_eq!(keys[1].in_interp, Interp::Bezier);
}
#[test]
fn colors_and_paths_interpolate() {
let keys = vec![Keyframe::new(s(0.0), Value::Color([0.0, 0.0, 0.0, 1.0])), Keyframe::new(s(1.0), Value::Color([1.0, 0.5, 0.0, 1.0]))];
assert_eq!(evaluate(&keys, s(0.5), false).unwrap().as_color(), [0.5, 0.25, 0.0, 1.0]);
let a = ShapePath::rect([0.0, 0.0], 10.0, 10.0);
let b = ShapePath::rect([0.0, 0.0], 20.0, 20.0);
let keys = vec![Keyframe::new(s(0.0), Value::Path(a)), Keyframe::new(s(1.0), Value::Path(b))];
let v = evaluate(&keys, s(0.5), false).unwrap();
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) {
let mut a = Keyframe::new(s(0.0), Value::Scalar(0.0)).eased();
let mut b = Keyframe::new(s(1.0), Value::Scalar(10.0)).eased();
a.out_ease = vec![Ease { speed: 0.0, influence: i0 }];
b.in_ease = vec![Ease { speed: 0.0, influence: i1 }];
let v = evaluate(&[a, b], s(t), false).unwrap().as_f64();
proptest::prop_assert!((-1e-9..=10.0 + 1e-9).contains(&v));
}
}
}