mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 00:47:03 -06:00
M0.1–M0.7: workspace, time/geom/color/keyframe/project/raster/path/text/effects/render crates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9f19158a59
commit
808cd490cd
@@ -0,0 +1,543 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Property values.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A Bezier shape in the After Effects / Lottie vertex model: vertices with tangents relative to
|
||||
/// their vertex.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ShapePath {
|
||||
pub vertices: Vec<[f64; 2]>,
|
||||
pub in_tangents: Vec<[f64; 2]>,
|
||||
pub out_tangents: Vec<[f64; 2]>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
impl ShapePath {
|
||||
pub fn polygon(points: &[[f64; 2]], closed: bool) -> ShapePath {
|
||||
ShapePath { vertices: points.to_vec(), in_tangents: vec![[0.0; 2]; points.len()], out_tangents: vec![[0.0; 2]; points.len()], closed }
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.vertices.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.vertices.is_empty()
|
||||
}
|
||||
/// Rectangle centred at `c` (clockwise from top-left, as AE builds mask rectangles).
|
||||
pub fn rect(c: [f64; 2], w: f64, h: f64) -> ShapePath {
|
||||
let (x0, y0, x1, y1) = (c[0] - w / 2.0, c[1] - h / 2.0, c[0] + w / 2.0, c[1] + h / 2.0);
|
||||
ShapePath::polygon(&[[x0, y0], [x1, y0], [x1, y1], [x0, y1]], true)
|
||||
}
|
||||
/// Ellipse with four Bezier arcs (kappa 0.5523).
|
||||
pub fn ellipse(c: [f64; 2], w: f64, h: f64) -> ShapePath {
|
||||
const K: f64 = 0.552_284_749_8;
|
||||
let (rx, ry) = (w / 2.0, h / 2.0);
|
||||
ShapePath {
|
||||
vertices: vec![[c[0], c[1] - ry], [c[0] + rx, c[1]], [c[0], c[1] + ry], [c[0] - rx, c[1]]],
|
||||
in_tangents: vec![[-rx * K, 0.0], [0.0, -ry * K], [rx * K, 0.0], [0.0, ry * K]],
|
||||
out_tangents: vec![[rx * K, 0.0], [0.0, ry * K], [-rx * K, 0.0], [0.0, -ry * K]],
|
||||
closed: true,
|
||||
}
|
||||
}
|
||||
fn lerp(&self, o: &ShapePath, t: f64) -> ShapePath {
|
||||
if self.len() != o.len() {
|
||||
return if t < 1.0 { self.clone() } else { o.clone() };
|
||||
}
|
||||
let l = |a: &[[f64; 2]], b: &[[f64; 2]]| a.iter().zip(b).map(|(p, q)| [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t]).collect();
|
||||
ShapePath {
|
||||
vertices: l(&self.vertices, &o.vertices),
|
||||
in_tangents: l(&self.in_tangents, &o.in_tangents),
|
||||
out_tangents: l(&self.out_tangents, &o.out_tangents),
|
||||
closed: self.closed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal paragraph alignment.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
JustifyLastLeft,
|
||||
JustifyLastCenter,
|
||||
JustifyLastRight,
|
||||
JustifyAll,
|
||||
}
|
||||
|
||||
/// The value of a text layer's Source Text (hold-interpolated).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextDoc {
|
||||
pub text: String,
|
||||
pub font: String,
|
||||
pub style: String,
|
||||
pub size: f64,
|
||||
pub fill: [f32; 4],
|
||||
pub stroke: [f32; 4],
|
||||
pub stroke_width: f64,
|
||||
pub apply_fill: bool,
|
||||
pub apply_stroke: bool,
|
||||
pub stroke_over_fill: bool,
|
||||
pub tracking: f64,
|
||||
/// None = auto (120% of size).
|
||||
pub leading: Option<f64>,
|
||||
pub justify: Justify,
|
||||
pub baseline_shift: f64,
|
||||
pub h_scale: f64,
|
||||
pub v_scale: f64,
|
||||
pub faux_bold: bool,
|
||||
pub faux_italic: bool,
|
||||
pub all_caps: bool,
|
||||
pub small_caps: bool,
|
||||
/// Paragraph text box (width, height) with top-left at `box_pos`; None = point text.
|
||||
pub box_size: Option<[f64; 2]>,
|
||||
pub box_pos: [f64; 2],
|
||||
}
|
||||
|
||||
impl Default for TextDoc {
|
||||
fn default() -> Self {
|
||||
TextDoc {
|
||||
text: String::new(),
|
||||
font: "Inter".into(),
|
||||
style: "Regular".into(),
|
||||
size: 72.0,
|
||||
fill: [1.0, 1.0, 1.0, 1.0],
|
||||
stroke: [0.0, 0.0, 0.0, 1.0],
|
||||
stroke_width: 0.0,
|
||||
apply_fill: true,
|
||||
apply_stroke: false,
|
||||
stroke_over_fill: true,
|
||||
tracking: 0.0,
|
||||
leading: None,
|
||||
justify: Justify::Left,
|
||||
baseline_shift: 0.0,
|
||||
h_scale: 100.0,
|
||||
v_scale: 100.0,
|
||||
faux_bold: false,
|
||||
faux_italic: false,
|
||||
all_caps: false,
|
||||
small_caps: false,
|
||||
box_size: None,
|
||||
box_pos: [0.0, 0.0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient: colour stops `(position 0..1, rgba)` and opacity stops `(position, alpha)`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Gradient {
|
||||
pub colors: Vec<(f64, [f32; 4])>,
|
||||
pub opacities: Vec<(f64, f32)>,
|
||||
}
|
||||
|
||||
impl Default for Gradient {
|
||||
fn default() -> Self {
|
||||
Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }
|
||||
}
|
||||
}
|
||||
|
||||
impl Gradient {
|
||||
pub fn sample(&self, t: f64) -> [f32; 4] {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let col = sample_stops(&self.colors, t, |a, b, f| {
|
||||
let mut o = [0.0; 4];
|
||||
for i in 0..4 {
|
||||
o[i] = a[i] + (b[i] - a[i]) * f as f32;
|
||||
}
|
||||
o
|
||||
})
|
||||
.unwrap_or([1.0; 4]);
|
||||
let a = sample_stops(&self.opacities, t, |a, b, f| a + (b - a) * f as f32).unwrap_or(1.0);
|
||||
[col[0], col[1], col[2], col[3] * a]
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_stops<T: Copy>(stops: &[(f64, T)], t: f64, lerp: impl Fn(T, T, f64) -> T) -> Option<T> {
|
||||
let first = stops.first()?;
|
||||
if t <= first.0 {
|
||||
return Some(first.1);
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
if t <= w[1].0 {
|
||||
let span = (w[1].0 - w[0].0).max(1e-12);
|
||||
return Some(lerp(w[0].1, w[1].1, (t - w[0].0) / span));
|
||||
}
|
||||
}
|
||||
stops.last().map(|s| s.1)
|
||||
}
|
||||
|
||||
/// The value of a property.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "t", content = "v")]
|
||||
pub enum Value {
|
||||
Scalar(f64),
|
||||
Vec2([f64; 2]),
|
||||
Vec3([f64; 3]),
|
||||
/// Straight RGBA 0..1.
|
||||
Color([f64; 4]),
|
||||
Bool(bool),
|
||||
/// Index into a popup's options (1-based like AE's dropdown values is a UI concern; this is 0-based).
|
||||
Enum(u32),
|
||||
Path(ShapePath),
|
||||
Text(Box<TextDoc>),
|
||||
/// Layer reference (layer id), e.g. Layer Control effects, track matte sources.
|
||||
Layer(Option<u64>),
|
||||
Gradient(Gradient),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
/// Numeric components (empty for non-numeric values).
|
||||
pub fn components(&self) -> Vec<f64> {
|
||||
match self {
|
||||
Value::Scalar(v) => vec![*v],
|
||||
Value::Vec2(v) => v.to_vec(),
|
||||
Value::Vec3(v) => v.to_vec(),
|
||||
Value::Color(v) => v.to_vec(),
|
||||
Value::Bool(b) => vec![if *b { 1.0 } else { 0.0 }],
|
||||
Value::Enum(i) => vec![*i as f64],
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
pub fn dims(&self) -> usize {
|
||||
match self {
|
||||
Value::Scalar(_) | Value::Bool(_) | Value::Enum(_) => 1,
|
||||
Value::Vec2(_) => 2,
|
||||
Value::Vec3(_) => 3,
|
||||
Value::Color(_) => 4,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
/// Rebuild a value of the same kind from components.
|
||||
pub fn with_components(&self, c: &[f64]) -> Value {
|
||||
let g = |i: usize| c.get(i).copied().unwrap_or(0.0);
|
||||
match self {
|
||||
Value::Scalar(_) => Value::Scalar(g(0)),
|
||||
Value::Vec2(_) => Value::Vec2([g(0), g(1)]),
|
||||
Value::Vec3(_) => Value::Vec3([g(0), g(1), g(2)]),
|
||||
Value::Color(_) => Value::Color([g(0), g(1), g(2), g(3)]),
|
||||
Value::Bool(_) => Value::Bool(g(0) >= 0.5),
|
||||
Value::Enum(_) => Value::Enum(g(0).round().max(0.0) as u32),
|
||||
v => v.clone(),
|
||||
}
|
||||
}
|
||||
/// Can be interpolated (otherwise keyframes behave as hold).
|
||||
pub fn interpolates(&self) -> bool {
|
||||
matches!(self, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_) | Value::Color(_) | Value::Path(_) | Value::Gradient(_))
|
||||
}
|
||||
pub fn lerp(&self, o: &Value, t: f64) -> Value {
|
||||
match (self, o) {
|
||||
(Value::Path(a), Value::Path(b)) => Value::Path(a.lerp(b, t)),
|
||||
(Value::Gradient(a), Value::Gradient(b)) if a.colors.len() == b.colors.len() && a.opacities.len() == b.opacities.len() => {
|
||||
let colors = a
|
||||
.colors
|
||||
.iter()
|
||||
.zip(&b.colors)
|
||||
.map(|(x, y)| {
|
||||
let mut c = [0.0; 4];
|
||||
for i in 0..4 {
|
||||
c[i] = x.1[i] + (y.1[i] - x.1[i]) * t as f32;
|
||||
}
|
||||
(x.0 + (y.0 - x.0) * t, c)
|
||||
})
|
||||
.collect();
|
||||
let opacities = a.opacities.iter().zip(&b.opacities).map(|(x, y)| (x.0 + (y.0 - x.0) * t, x.1 + (y.1 - x.1) * t as f32)).collect();
|
||||
Value::Gradient(Gradient { colors, opacities })
|
||||
}
|
||||
_ if self.interpolates() && self.dims() > 0 && self.dims() == o.dims() => {
|
||||
let a = self.components();
|
||||
let b = o.components();
|
||||
self.with_components(&a.iter().zip(&b).map(|(x, y)| x + (y - x) * t).collect::<Vec<_>>())
|
||||
}
|
||||
_ => {
|
||||
if t < 1.0 {
|
||||
self.clone()
|
||||
} else {
|
||||
o.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn as_f64(&self) -> f64 {
|
||||
self.components().first().copied().unwrap_or(0.0)
|
||||
}
|
||||
pub fn as_vec2(&self) -> [f64; 2] {
|
||||
let c = self.components();
|
||||
[c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0)]
|
||||
}
|
||||
pub fn as_vec3(&self) -> [f64; 3] {
|
||||
let c = self.components();
|
||||
[c.first().copied().unwrap_or(0.0), c.get(1).copied().unwrap_or(0.0), c.get(2).copied().unwrap_or(0.0)]
|
||||
}
|
||||
pub fn as_color(&self) -> [f32; 4] {
|
||||
let c = self.components();
|
||||
let g = |i: usize, d: f64| c.get(i).copied().unwrap_or(d) as f32;
|
||||
[g(0, 0.0), g(1, 0.0), g(2, 0.0), g(3, 1.0)]
|
||||
}
|
||||
pub fn as_bool(&self) -> bool {
|
||||
match self {
|
||||
Value::Bool(b) => *b,
|
||||
v => v.as_f64() != 0.0,
|
||||
}
|
||||
}
|
||||
pub fn as_enum(&self) -> u32 {
|
||||
match self {
|
||||
Value::Enum(i) => *i,
|
||||
v => v.as_f64().max(0.0) as u32,
|
||||
}
|
||||
}
|
||||
pub fn as_path(&self) -> Option<&ShapePath> {
|
||||
if let Value::Path(p) = self { Some(p) } else { None }
|
||||
}
|
||||
pub fn as_text(&self) -> Option<&TextDoc> {
|
||||
if let Value::Text(t) = self { Some(t) } else { None }
|
||||
}
|
||||
pub fn as_layer(&self) -> Option<u64> {
|
||||
if let Value::Layer(l) = self { *l } else { None }
|
||||
}
|
||||
pub fn kind_name(&self) -> &'static str {
|
||||
match self {
|
||||
Value::Scalar(_) => "scalar",
|
||||
Value::Vec2(_) => "vec2",
|
||||
Value::Vec3(_) => "vec3",
|
||||
Value::Color(_) => "color",
|
||||
Value::Bool(_) => "bool",
|
||||
Value::Enum(_) => "enum",
|
||||
Value::Path(_) => "path",
|
||||
Value::Text(_) => "text",
|
||||
Value::Layer(_) => "layer",
|
||||
Value::Gradient(_) => "gradient",
|
||||
Value::Str(_) => "string",
|
||||
}
|
||||
}
|
||||
/// Lenient conversion from JSON (numbers, arrays, booleans, strings) to a value shaped like `self`.
|
||||
pub fn coerce_json(&self, j: &serde_json::Value) -> Option<Value> {
|
||||
use serde_json::Value as J;
|
||||
let nums = |j: &J| -> Option<Vec<f64>> {
|
||||
match j {
|
||||
J::Number(n) => n.as_f64().map(|v| vec![v]),
|
||||
J::Array(a) => a.iter().map(J::as_f64).collect(),
|
||||
J::Bool(b) => Some(vec![if *b { 1.0 } else { 0.0 }]),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
match self {
|
||||
Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_) | Value::Enum(_) => {
|
||||
let mut c = nums(j)?;
|
||||
if c.len() == 1 && self.dims() > 1 {
|
||||
c = vec![c[0]; self.dims()];
|
||||
}
|
||||
Some(self.with_components(&c))
|
||||
}
|
||||
Value::Color(_) => {
|
||||
if let Some(s) = j.as_str() {
|
||||
let c = effectcraft_color::Rgba::from_hex(s)?;
|
||||
return Some(Value::Color([c.r as f64, c.g as f64, c.b as f64, c.a as f64]));
|
||||
}
|
||||
let mut c = nums(j)?;
|
||||
if c.len() == 3 {
|
||||
c.push(1.0);
|
||||
}
|
||||
Some(self.with_components(&c))
|
||||
}
|
||||
Value::Bool(_) => j.as_bool().or_else(|| j.as_f64().map(|v| v != 0.0)).map(Value::Bool),
|
||||
Value::Str(_) => j.as_str().map(|s| Value::Str(s.to_string())),
|
||||
Value::Text(t) => {
|
||||
if let Some(s) = j.as_str() {
|
||||
let mut t = (**t).clone();
|
||||
t.text = s.to_string();
|
||||
Some(Value::Text(Box::new(t)))
|
||||
} else {
|
||||
serde_json::from_value::<TextDoc>(j.clone()).ok().map(|t| Value::Text(Box::new(t)))
|
||||
}
|
||||
}
|
||||
Value::Layer(_) => Some(Value::Layer(j.as_u64())),
|
||||
_ => serde_json::from_value::<Value>(j.clone()).ok(),
|
||||
}
|
||||
}
|
||||
/// JSON for display/automation (numbers/arrays for numeric kinds).
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
use serde_json::json;
|
||||
match self {
|
||||
Value::Scalar(v) => json!(v),
|
||||
Value::Vec2(v) => json!(v),
|
||||
Value::Vec3(v) => json!(v),
|
||||
Value::Color(v) => json!(v),
|
||||
Value::Bool(b) => json!(b),
|
||||
Value::Enum(i) => json!(i),
|
||||
Value::Str(s) => json!(s),
|
||||
Value::Layer(l) => json!(l),
|
||||
Value::Text(t) => json!(t.text),
|
||||
v => serde_json::to_value(v).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user