Expressions: cubic-Bezier easing helper (bezier(...)) (#1884)

Add bezier(t, tMin, tMax, v1, v2, [x1, y1, x2, y2]) and the short bezier(t, v1, v2, curve) form: a CSS cubic-bezier easing curve solved with Newton-Raphson and a bisection fallback.

Scalars and arrays work like linear/ease; x1/x2 clamp to 0..1, y1/y2 may overshoot, t clamps to tMin..tMax, and a curve that is not four finite numbers falls back to linear instead of throwing.

Tests cover known curves (ease is [1/3, 0, 2/3, 1]), clamps, arrays, the short form, invalid curves and overshoot, plus two fuzz seeds.

Closes #1884.
This commit is contained in:
Camille Moine
2026-10-29 11:58:34 +01:00
parent 990fa668cd
commit 53399c2831
4 changed files with 89 additions and 1 deletions
+2 -1
View File
@@ -17,7 +17,8 @@
//! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`,
//! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the
//! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/
//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`).
//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`),
//! plus `bezier`, a CSS-style cubic-Bezier easing helper that After Effects does not have.
//! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time /
//! `seedRandom`), so frames render identically on every thread and platform.
//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read
+50
View File
@@ -207,6 +207,56 @@ function ease() { return __interp(__ease, arguments); }
function easeIn() { return __interp(__easeIn, arguments); }
function easeOut() { return __interp(__easeOut, arguments); }
// Cubic-Bezier easing (the CSS cubic-bezier() shape): [x1, y1, x2, y2], with x1 / x2 in 0..1
// and y1 / y2 free (overshoot included). Solves x(u) = x by Newton–Raphson, then by bisection
// when Newton leaves 0..1 or the derivative vanishes, and returns y(u). A curve that is not four
// finite numbers is linear, never an error.
function __cubicBezier(curve) {
var x1, y1, x2, y2;
if (Array.isArray(curve) && curve.length >= 4) {
x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3];
}
if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' ||
!isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) {
return __lin;
}
x1 = Math.max(0, Math.min(1, x1));
x2 = Math.max(0, Math.min(1, x2));
function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; }
function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); }
function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; }
return function (x) {
if (x <= 0) return 0;
if (x >= 1) return 1;
var u = x;
for (var i = 0; i < 8; i++) {
var f = bx(u) - x;
if (Math.abs(f) < 1e-9) return by(u);
var d = dbx(u);
if (Math.abs(d) < 1e-12) break;
var n = u - f / d;
if (!(n >= 0 && n <= 1)) break;
u = n;
}
var lo = 0, hi = 1;
for (var j = 0; j < 60; j++) {
var mid = (lo + hi) / 2;
if (bx(mid) < x) lo = mid; else hi = mid;
}
return by((lo + hi) / 2);
};
}
// bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is
// the last argument; everything before it follows linear / ease.
function bezier() {
var curve = arguments[arguments.length - 1];
var shape = __cubicBezier(curve);
var args = [];
for (var i = 0; i < arguments.length - 1; i++) args.push(arguments[i]);
return __interp(shape, args);
}
// ---------------------------------------------------------------------------------------------
// Random numbers and noise.
+35
View File
@@ -313,6 +313,41 @@ fn interpolation_with_arrays() {
assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]);
}
#[test]
fn cubic_bezier_curves() {
let f = fx();
// ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³.
assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0));
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0);
// Symmetric ease-in-out: slower at both ends, faster in the middle.
let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0);
assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}");
// Control points on the diagonal are linear.
assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0);
// t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers.
assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0);
assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0);
}
#[test]
fn cubic_bezier_short_form_arrays_and_fallbacks() {
let f = fx();
// Short form: no tMin / tMax.
assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5);
assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]);
// y outside 0..1 overshoots, as CSS allows.
let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0);
assert!(over > 100.0, "overshoot: {over}");
// A curve that is not four finite numbers falls back to linear instead of throwing.
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0);
assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0);
// x control points outside 0..1 are clamped, as CSS requires.
let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0);
assert!(v.is_finite(), "clamped curve: {v}");
}
// ---- object model -----------------------------------------------------------------------------
#[test]
+2
View File
@@ -93,6 +93,8 @@ const SEEDS: &[&str] = &[
"linear(time, 0, 1, 0, 100)",
"ease(time, 0, 2, [0, 0], [100, 50])",
"easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)",
"bezier(time, 0, 1, 0, 100, [0.42, 0, 0.58, 1])",
"bezier(time, [0, 0], [100, 50], [0.25, 0.1, 0.25, 1])",
"clamp(value * 2, 0, 100)",
"length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]",
"normalize([3, 4])[0] * degreesToRadians(180)",