Files
effectcraft/crates/keyframe/src/lib.rs
T

539 lines
20 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()
}
/// 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]);
}
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));
}
}
}