mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 18:18:17 -06:00
The Timeline's "Edit Gradient…" on a gradient property (Gradient Fill / Gradient Stroke ▸ Colors, Gradient Overlay) only painted its label. It now opens a Gradient Editor (prop.editGradient): opacity stops above a preview bar and colour stops below. Click to add a stop (it takes that track's own value there), drag or type a Location to move it, set its opacity or colour (egui's colour picker), Delete removes it (never the last of a track, never while typing). The tracks stay in order while editing and the selection follows its stop. The editor works on a draft. OK sets the property once with prop.set at the composition time it opened, snapped to the frame, with timeBase: "comp" (offset and stretched layers get the key where it was opened): one undo step, and nothing at all when the draft is unchanged, so OK adds no keyframe. Cancel and Escape change nothing; Escape with the picker open closes only the picker. On an animated gradient only the keyframe at that time changes; when its stop counts no longer match a neighbouring keyframe's (gradients only blend with equal counts) the editor says so. The draft is saved in the typed form, so one colour stop and each stop's RGBA survive. prop.editGradient checks its target is a gradient property before the UI opens anything, so agents get an error for a wrong one. New Gradient color_at / opacity_at sample one track each (sample() multiplies opacity in, which would apply it twice to a new stop). Tests: ui_gradient_value (fails on main: the value had no automation id), ui_gradient_editor (11), gradient_editor and keyframe unit tests.
998 lines
43 KiB
Rust
998 lines
43 KiB
Rust
//! 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.
|
||
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
pub mod text_doc;
|
||
pub mod value;
|
||
|
||
use effectcraft_time::{TICKS_PER_SECOND, Tick};
|
||
use serde::{Deserialize, Serialize};
|
||
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun};
|
||
pub use value::{FeatherPoint, 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,
|
||
/// Keyframe color label: index into the label list (0 = None).
|
||
#[serde(default, skip_serializing_if = "is_zero")]
|
||
pub label: u8,
|
||
}
|
||
|
||
fn is_zero(v: &u8) -> bool {
|
||
*v == 0
|
||
}
|
||
|
||
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,
|
||
label: 0,
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
if x1 == x3 && x2 == x0 {
|
||
// Full influence on both sides has a flat time derivative at the midpoint.
|
||
// Its exact inverse avoids cancellation and residual-only Newton convergence there.
|
||
let progress = (x - x0) / (x3 - x0);
|
||
return 0.5 + ((progress - 0.5) * 0.25).cbrt();
|
||
}
|
||
let mut u = (x - x0) / (x3 - x0);
|
||
for _ in 0..8 {
|
||
let f = bez(x0, x1, x2, x3, u) - x;
|
||
let d = bez_d(x0, x1, x2, x3, u);
|
||
if d.abs() < 1e-12 {
|
||
break;
|
||
}
|
||
if (f / d).abs() < 1e-12 {
|
||
return u;
|
||
}
|
||
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 {
|
||
// The time handles may cross. AE keeps both influences; rescaling them changes the ease.
|
||
let (i0, i1) = (out_inf.clamp(0.0, 1.0), in_inf.clamp(0.0, 1.0));
|
||
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)
|
||
}
|
||
|
||
/// Whether control point `c` lies on the segment `a`–`b` (within rounding), so a cubic with
|
||
/// such handles traces the straight chord.
|
||
fn on_chord(a: [f64; 3], c: [f64; 3], b: [f64; 3]) -> bool {
|
||
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
|
||
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
|
||
let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||
if dd <= 1e-24 {
|
||
return false;
|
||
}
|
||
let t = (v[0] * d[0] + v[1] * d[1] + v[2] * d[2]) / dd;
|
||
let off = [v[0] - d[0] * t, v[1] - d[1] * t, v[2] - d[2] * t];
|
||
(0.0..=1.0).contains(&t) && off[0] * off[0] + off[1] * off[1] + off[2] * off[2] <= dd * 1e-18
|
||
}
|
||
|
||
/// 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;
|
||
|
||
/// 5-point Gauss-Legendre nodes and weights on -1..1.
|
||
const GAUSS: [(f64, f64); 5] = [
|
||
(0.0, 0.568_888_888_888_888_9),
|
||
(-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
|
||
(0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
|
||
(-0.906_179_845_938_664, 0.236_926_885_056_189_1),
|
||
(0.906_179_845_938_664, 0.236_926_885_056_189_1),
|
||
];
|
||
|
||
impl SpatialSeg {
|
||
fn new(p: [[f64; 3]; 4]) -> SpatialSeg {
|
||
let mut table = Vec::with_capacity(ARC_N + 1);
|
||
table.push(0.0);
|
||
let mut acc = 0.0;
|
||
for i in 1..=ARC_N {
|
||
acc += Self::arc(&p, (i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64);
|
||
table.push(acc);
|
||
}
|
||
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)]
|
||
}
|
||
/// |dB/du|.
|
||
fn tangent_len(p: &[[f64; 3]; 4], u: f64) -> f64 {
|
||
(0..3).map(|k| bez_d(p[0][k], p[1][k], p[2][k], p[3][k], u).powi(2)).sum::<f64>().sqrt()
|
||
}
|
||
/// Arc length between `a` and `b` (Gauss-Legendre; exact enough on one table cell).
|
||
fn arc(p: &[[f64; 3]; 4], a: f64, b: f64) -> f64 {
|
||
let (m, h) = ((a + b) * 0.5, (b - a) * 0.5);
|
||
GAUSS.iter().map(|(x, w)| w * Self::tangent_len(p, m + h * x)).sum::<f64>() * h
|
||
}
|
||
fn length(&self) -> f64 {
|
||
*self.table.last().unwrap_or(&0.0)
|
||
}
|
||
/// The point at arc length `s`. The table cell's linear guess is refined with Newton steps
|
||
/// on the true arc length, so the position (and the speed graph, its derivative) is smooth
|
||
/// along curved paths instead of piecewise linear per cell (#456).
|
||
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 (Some(&a), Some(&b)) = (self.table.get(i - 1), self.table.get(i)) else { return self.p[0] };
|
||
let (u0, u1) = ((i - 1) as f64 / ARC_N as f64, i as f64 / ARC_N as f64);
|
||
let mut u = u0 + if b > a { (s - a) / (b - a) } else { 0.0 } * (u1 - u0);
|
||
for _ in 0..4 {
|
||
let d = Self::tangent_len(&self.p, u);
|
||
if d <= 1e-12 {
|
||
break;
|
||
}
|
||
let e = a + Self::arc(&self.p, u0, u) - s;
|
||
u = (u - e / d).clamp(u0, u1);
|
||
if e.abs() <= 1e-12 * len.max(1.0) {
|
||
break;
|
||
}
|
||
}
|
||
Self::point(&self.p, u)
|
||
}
|
||
}
|
||
|
||
fn v3(v: &Value) -> [f64; 3] {
|
||
v.as_vec3()
|
||
}
|
||
|
||
/// Auto-Bezier spatial tangents for key `i`, as After Effects computes them (Catmull-Rom): the out
|
||
/// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and
|
||
/// key times play no part. An end key uses its one neighbour, so the path leaves the first key
|
||
/// and enters the last one toward the neighbouring key instead of with no tangent.
|
||
fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||
if i >= keys.len() || keys.len() < 2 {
|
||
return ([0.0; 3], [0.0; 3]);
|
||
}
|
||
let a = v3(&keys[i.saturating_sub(1)].value);
|
||
let c = v3(&keys[(i + 1).min(keys.len() - 1)].value);
|
||
let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0];
|
||
([-tout[0], -tout[1], -tout[2]], tout)
|
||
}
|
||
|
||
/// Effective spatial tangents of key `i` (auto or stored).
|
||
pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||
let Some(key) = keys.get(i) else { return ([0.0; 3], [0.0; 3]) };
|
||
if key.spatial_auto { auto_spatial(keys, i) } else { (key.spatial_in, key.spatial_out) }
|
||
}
|
||
|
||
/// Spatial tangents of key `i` as motion-path handles: [`spatial_tangents`], with a side that
|
||
/// has none but a neighbour (a key whose stored tangents are zero, as on a straight path) pointing
|
||
/// where the path leaves the key, toward the segment's next control point, a third of the way to
|
||
/// the neighbouring key. After Effects shows Bezier handles there to drag the path into a curve.
|
||
pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) {
|
||
let (mut tin, mut tout) = spatial_tangents(keys, i);
|
||
let Some(p) = keys.get(i).map(|k| v3(&k.value)) else { return (tin, tout) };
|
||
// Toward key `j`'s control point on this segment (its in side when `j` follows `i`).
|
||
let toward = |j: usize, after: bool| -> Option<[f64; 3]> {
|
||
let q = v3(&keys.get(j)?.value);
|
||
let (jin, jout) = spatial_tangents(keys, j);
|
||
let t = if after { jin } else { jout };
|
||
let d = [q[0] + t[0] - p[0], q[1] + t[1] - p[1], q[2] + t[2] - p[2]];
|
||
let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||
let len = ((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + (q[2] - p[2]).powi(2)).sqrt() / 3.0;
|
||
(dl > 1e-9).then(|| d.map(|v| v * len / dl))
|
||
};
|
||
let none = |t: [f64; 3]| t == [0.0; 3];
|
||
if none(tout)
|
||
&& let Some(v) = toward(i + 1, true)
|
||
{
|
||
tout = v;
|
||
}
|
||
if none(tin)
|
||
&& let Some(v) = i.checked_sub(1).and_then(|j| toward(j, false))
|
||
{
|
||
tin = v;
|
||
}
|
||
(tin, tout)
|
||
}
|
||
|
||
/// 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(next) = i.checked_add(1) else { return 0.0 };
|
||
let (Some(a), Some(b)) = (keys.get(i), keys.get(next)) 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, next);
|
||
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 span = i128::from(t1.0) - i128::from(t0.0);
|
||
let mut retimed = i128::from(t0.0) + (span as f64 * f).round() as i128;
|
||
// Keep every roving key strictly between its neighbours: a zero-length segment would otherwise
|
||
// put it on a fixed key's time, and two keys can't share a time.
|
||
let (lo, hi) = (i128::from(keys[i - 1].time.0) + 1, i128::from(t1.0) - (b - i) as i128);
|
||
if lo <= hi {
|
||
retimed = retimed.clamp(lo, hi);
|
||
}
|
||
let nt = Tick(retimed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) 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() {
|
||
// Imported Tick values may span i64's full range; keep the intermediate exact.
|
||
let reversed = i128::from(first.0) + i128::from(last.0) - i128::from(k.time.0);
|
||
k.time = Tick(reversed.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64);
|
||
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;
|
||
// A Hold in-side only matters for the segment it ends, which the out side above decides:
|
||
// it shapes the incoming segment like a linear side, as in the Graph Editor and Lottie export.
|
||
let lin_in = b.in_interp != Interp::Bezier;
|
||
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)
|
||
};
|
||
// A straight path (both handles on the chord, as auto-Bezier keys on a line have) is the
|
||
// chord itself: interpolate it exactly instead of through the arc-length table.
|
||
let p = if on_chord(p0, seg.p[1], p3) && on_chord(p0, seg.p[2], p3) {
|
||
[p0[0] + (p3[0] - p0[0]) * f, p0[1] + (p3[1] - p0[1]) * f, p0[2] + (p3[2] - p0[2]) * f]
|
||
} else {
|
||
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 (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0));
|
||
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;
|
||
k.spatial_continuous = old.spatial_continuous;
|
||
k.roving = old.roving;
|
||
k.label = old.label;
|
||
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 missing_spatial_keys_have_no_tangents_or_length() {
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]))];
|
||
for i in [1, usize::MAX] {
|
||
assert_eq!(spatial_tangents(&keys, i), ([0.0; 3], [0.0; 3]));
|
||
assert_eq!(spatial_segment_length(&keys, i), 0.0);
|
||
}
|
||
assert_eq!(spatial_tangents(&[], 0), ([0.0; 3], [0.0; 3]));
|
||
assert_eq!(spatial_segment_length(&[], usize::MAX), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn time_reverse_handles_deserialized_tick_extremes() {
|
||
// Tick's public serde representation can contain values beyond from_seconds_f64's cap.
|
||
for (first, last) in [(i64::MIN, i64::MIN + 10), (i64::MAX - 10, i64::MAX), (i64::MIN, i64::MAX)] {
|
||
let mut keys = vec![Keyframe::new(Tick(first), Value::Scalar(1.0)), Keyframe::new(Tick(last), Value::Scalar(2.0))];
|
||
time_reverse(&mut keys);
|
||
assert_eq!(keys[0].time, Tick(first));
|
||
assert_eq!(keys[0].value, Value::Scalar(2.0));
|
||
assert_eq!(keys[1].time, Tick(last));
|
||
assert_eq!(keys[1].value, Value::Scalar(1.0));
|
||
time_reverse(&mut keys);
|
||
assert_eq!(keys[0].value, Value::Scalar(1.0));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn roving_handles_deserialized_tick_extremes() {
|
||
let mut keys = vec![
|
||
Keyframe::new(Tick(i64::MIN), Value::Vec2([0.0, 0.0])),
|
||
Keyframe::new(Tick(10), Value::Vec2([50.0, 0.0])),
|
||
Keyframe::new(Tick(i64::MAX), Value::Vec2([100.0, 0.0])),
|
||
];
|
||
for key in &mut keys {
|
||
key.spatial_auto = false;
|
||
}
|
||
keys[1].roving = true;
|
||
assert!(retime_roving(&mut keys, true));
|
||
assert_eq!(keys[1].time, Tick::ZERO);
|
||
}
|
||
|
||
#[test]
|
||
fn replacing_key_preserves_spatial_metadata() {
|
||
let mut key = Keyframe::new(s(1.0), Value::Vec2([20.0, 30.0]));
|
||
key.roving = true;
|
||
key.spatial_continuous = true;
|
||
let mut keys = vec![key];
|
||
set_key(&mut keys, Keyframe::new(s(1.0), Value::Vec2([40.0, 50.0])));
|
||
assert!(keys[0].roving);
|
||
assert!(keys[0].spatial_continuous);
|
||
assert_eq!(keys[0].value, Value::Vec2([40.0, 50.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn temporal_ease_matches_live_ae_with_overlapping_influences() {
|
||
// Original Rotation probe via AEsync 2.0.4, AE 26.3x87, 2026-10-05.
|
||
// AE retains each handle's requested influence even when their sum exceeds 100%.
|
||
for (from, to, out_speed, out_inf, in_speed, in_inf, samples) in [
|
||
(0.0, 100.0, 0.0, 0.8, 0.0, 0.8, [(0.1, 0.59243939036327), (0.25, 4.76438030608764), (0.4, 18.4284394271562), (0.75, 95.2356196939123)]),
|
||
(0.0, 100.0, 20.0, 0.8, 80.0, 0.6, [(0.1, 2.25754942621577), (0.25, 6.99841889412931), (0.4, 14.9334636959052), (0.75, 73.8196785378389)]),
|
||
(100.0, 0.0, -20.0, 0.6, -80.0, 0.8, [(0.1, 97.6395466334594), (0.25, 91.597048571292), (0.4, 74.5367957474338), (0.75, 22.3789127753126)]),
|
||
] {
|
||
let mut a = Keyframe::new(s(0.0), Value::Scalar(from));
|
||
let mut b = Keyframe::new(s(1.0), Value::Scalar(to));
|
||
a.out_interp = Interp::Bezier;
|
||
b.in_interp = Interp::Bezier;
|
||
a.out_ease = vec![Ease { speed: out_speed, influence: out_inf }];
|
||
b.in_ease = vec![Ease { speed: in_speed, influence: in_inf }];
|
||
for (time, expected) in samples {
|
||
let value = evaluate(&[a.clone(), b.clone()], s(time), false).unwrap().as_f64();
|
||
assert!((value - expected).abs() < 1e-7, "{time}: {value} != {expected}");
|
||
let normalized = ease_progress(out_speed / (to - from), out_inf, in_speed / (to - from), in_inf, time);
|
||
assert!((from + normalized * (to - from) - expected).abs() < 1e-7);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn maximum_influence_resolves_flat_midpoint_time_curve() {
|
||
for time in [0.5 - 1e-9, 0.5 - 1e-12, 0.5, 0.5 + 1e-12, 0.5 + 1e-9] {
|
||
// At 100%/100%, x = 0.5 + 4*(u - 0.5)^3 exactly.
|
||
let u = 0.5 + ((time - 0.5) / 4.0_f64).cbrt();
|
||
let expected = bez(0.0, 0.0, 1.0, 1.0, u);
|
||
let actual = ease_progress(0.0, 1.0, 0.0, 1.0, time);
|
||
assert!((actual - expected).abs() < 1e-10, "{time}: {actual} != {expected}");
|
||
}
|
||
}
|
||
|
||
/// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns.
|
||
#[test]
|
||
fn gradients_coerce_from_plain_json() {
|
||
let g = Value::Gradient(Gradient::default());
|
||
let plain = serde_json::json!({"colors": [[0, [0, 0.5, 1, 1]], [1, "#ffff00"]], "opacities": [[0, 1], [1, 0.5]]});
|
||
let Some(Value::Gradient(a)) = g.coerce_json(&plain) else { panic!("object form") };
|
||
assert_eq!(a.colors, vec![(0.0, [0.0, 0.5, 1.0, 1.0]), (1.0, [1.0, 1.0, 0.0, 1.0])]);
|
||
assert_eq!(a.opacities, vec![(0.0, 1.0), (1.0, 0.5)]);
|
||
let Some(Value::Gradient(b)) = g.coerce_json(&serde_json::json!(["#ff0000", "#00ff00", "#0000ff"])) else { panic!("list form") };
|
||
assert_eq!(b.colors.iter().map(|c| c.0).collect::<Vec<_>>(), vec![0.0, 0.5, 1.0]);
|
||
assert_eq!(b.opacities.len(), 3);
|
||
// The tagged form (what prop.get returns) still round-trips.
|
||
assert_eq!(g.coerce_json(&g.to_json()), Some(g.clone()));
|
||
assert_eq!(g.coerce_json(&serde_json::json!(5)), None);
|
||
}
|
||
|
||
/// #612: the gradient editor samples a new stop from its own track only. `sample` multiplies
|
||
/// in the opacity stops, so feeding its alpha into a colour stop would apply 50% twice (25%).
|
||
#[test]
|
||
fn gradient_tracks_sample_separately() {
|
||
let g = Gradient { colors: vec![(0.0, [1.0, 0.0, 0.0, 0.8]), (1.0, [0.0, 0.0, 1.0, 0.4])], opacities: vec![(0.0, 0.5), (1.0, 0.5)] };
|
||
let c = g.color_at(0.5);
|
||
assert!((c[0] - 0.5).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6, "{c:?}");
|
||
assert!((c[3] - 0.6).abs() < 1e-6, "the colour track's own alpha, without opacity: {c:?}");
|
||
assert!((g.opacity_at(0.5) - 0.5).abs() < 1e-6);
|
||
assert!((g.sample(0.5)[3] - 0.3).abs() < 1e-6, "sample still applies opacity once");
|
||
// A stop added at 50% from the opacity track keeps 50%: sampled again, it is still 50%.
|
||
let mut with_stop = g.clone();
|
||
with_stop.opacities.insert(1, (0.5, g.opacity_at(0.5)));
|
||
assert!((with_stop.opacity_at(0.5) - 0.5).abs() < 1e-6);
|
||
assert!((with_stop.sample(0.5)[3] - 0.3).abs() < 1e-6, "adding a stop doesn't change the look");
|
||
// Outside the stops, and with none: the ends hold, and an empty track is opaque white.
|
||
assert_eq!(g.color_at(-1.0), [1.0, 0.0, 0.0, 0.8]);
|
||
assert_eq!(g.color_at(2.0), [0.0, 0.0, 1.0, 0.4]);
|
||
let empty = Gradient { colors: vec![], opacities: vec![] };
|
||
assert_eq!((empty.color_at(0.3), empty.opacity_at(0.3)), ([1.0; 4], 1.0));
|
||
}
|
||
|
||
/// #612: the editor saves the typed form, so one colour stop (which the plain JSON parser
|
||
/// rejects) and each stop's RGBA come back exactly.
|
||
#[test]
|
||
fn typed_gradient_round_trips_one_stop_and_rgba() {
|
||
let one = Value::Gradient(Gradient { colors: vec![(0.25, [0.1, 0.2, 0.3, 0.7])], opacities: vec![(0.0, 1.0), (0.6, 0.35)] });
|
||
let cur = Value::Gradient(Gradient::default());
|
||
assert_eq!(cur.coerce_json(&one.to_json()), Some(one.clone()));
|
||
assert_eq!(cur.coerce_json(&serde_json::json!({"colors": [[0.25, [0.1, 0.2, 0.3, 0.7]]]})), None, "plain JSON needs two colours");
|
||
}
|
||
|
||
#[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 hold_key_keeps_its_incoming_segment_linear() {
|
||
// Hold on the second key (in and out, as KeyframeInterpolationType.HOLD sets it) holds
|
||
// what comes after it, not the linear motion into it.
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).hold()];
|
||
assert_eq!(evaluate(&keys, s(0.5), false), Some(Value::Scalar(25.0)));
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(2.0), Value::Vec2([100.0, 0.0])).hold()];
|
||
let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2();
|
||
assert!((p[0] - 25.0).abs() < 0.01, "{p:?}");
|
||
}
|
||
|
||
#[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:?}");
|
||
}
|
||
|
||
/// #456: along a curved spatial path with linear (constant-speed) keys the speed graph is
|
||
/// flat, not a sawtooth from a piecewise-linear arc-length table.
|
||
#[test]
|
||
fn spatial_curved_path_has_smooth_constant_speed() {
|
||
let mut a = Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0]));
|
||
let mut b = Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0]));
|
||
a.spatial_auto = false;
|
||
b.spatial_auto = false;
|
||
a.spatial_out = [50.0, -300.0, 0.0];
|
||
b.spatial_in = [80.0, 250.0, 0.0];
|
||
let keys = vec![a, b];
|
||
let len = spatial_segment_length(&keys, 0);
|
||
let speeds: Vec<f64> = (1..400).map(|i| speed(&keys, s(i as f64 * 0.005), true)).collect();
|
||
for v in &speeds {
|
||
assert!((v - len / 2.0).abs() < 1e-3 * len, "speed {v} vs {}", len / 2.0);
|
||
}
|
||
}
|
||
|
||
#[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 straight_auto_bezier_path_is_exact() {
|
||
// Auto-Bezier ends now have handles along the line; the chord is still hit exactly.
|
||
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([40.0, 50.0])), Keyframe::new(s(2.0), Value::Vec2([120.0, 50.0]))];
|
||
assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn spatial_auto_bezier_tangents_follow_after_effects() {
|
||
// Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each
|
||
// auto-Bezier key's out tangent is (next - previous) / 6 and its in tangent the mirror,
|
||
// whatever the key times. End keys use their one neighbour: (neighbour - key) / 6.
|
||
let p = [[40.0, 300.0, 0.0], [260.0, 80.0, 20.0], [700.0, 420.0, -40.0], [1100.0, 150.0, 0.0]];
|
||
let mut keys: Vec<Keyframe> = [0.0, 0.2, 1.5, 1.8].iter().zip(p).map(|(&t, v)| Keyframe::new(s(t), Value::Vec3(v))).collect();
|
||
let sixth = |a: [f64; 3], b: [f64; 3]| [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0, (b[2] - a[2]) / 6.0];
|
||
let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]];
|
||
let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9);
|
||
let want = [sixth(p[0], p[1]), sixth(p[0], p[2]), sixth(p[1], p[3]), sixth(p[2], p[3])];
|
||
for (i, out) in want.into_iter().enumerate() {
|
||
let (tin, tout) = spatial_tangents(&keys, i);
|
||
assert!(close(tout, out) && close(tin, neg(out)), "key {i}: {tin:?} {tout:?}");
|
||
}
|
||
// Moving a key in time leaves every tangent as it was.
|
||
keys[1].time = s(1.2);
|
||
assert!(close(spatial_tangents(&keys, 1).1, sixth(p[0], p[2])));
|
||
// A key between two coincident neighbours, and a lone key, have none.
|
||
let still = [[5.0, 5.0], [9.0, 1.0], [5.0, 5.0]].iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64), Value::Vec2(v))).collect::<Vec<_>>();
|
||
assert_eq!(spatial_tangents(&still, 1), ([0.0; 3], [0.0; 3]));
|
||
assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3]));
|
||
}
|
||
|
||
#[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 roving_keys_never_share_a_time_with_a_fixed_key() {
|
||
// Zero-length last segment (issue #532) and zero-length first segment.
|
||
for xs in [[0.0, 40.0, 120.0, 120.0], [0.0, 0.0, 40.0, 120.0]] {
|
||
let mut keys: Vec<Keyframe> = xs.iter().enumerate().map(|(i, x)| Keyframe::new(s(i as f64), Value::Vec2([*x, 64.0]))).collect();
|
||
for k in &mut keys {
|
||
k.spatial_auto = false;
|
||
}
|
||
keys[1].roving = true;
|
||
keys[2].roving = true;
|
||
retime_roving(&mut keys, true);
|
||
assert!(keys.windows(2).all(|w| w[0].time < w[1].time), "{xs:?}: {:?}", keys.iter().map(|k| k.time).collect::<Vec<_>>());
|
||
}
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
}
|
||
}
|