feat(core): add deterministic keyframe ease runtime

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-25 14:12:16 +02:00
parent 1f9a8f0985
commit 5acbf240cb
24 changed files with 938 additions and 30 deletions
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { evaluateSpringEase, parseSpringBounce } from "./springEase";
describe("single-parameter spring ease", () => {
it("parses and clamps the bounce parameter", () => {
expect(parseSpringBounce("spring(0.5)")).toBe(0.5);
expect(parseSpringBounce(" spring(2) ")).toBe(1);
expect(parseSpringBounce("spring(-1)")).toBe(0);
expect(parseSpringBounce("spring(nope)")).toBeNull();
});
it("starts at zero, overshoots, and settles exactly at one", () => {
const samples = Array.from({ length: 101 }, (_, index) => evaluateSpringEase(index / 100, 0.5));
expect(samples[0]).toBe(0);
expect(samples.at(-1)).toBe(1);
expect(Math.max(...samples)).toBeGreaterThan(1);
});
it("is deterministic and makes higher bounce values more oscillatory", () => {
const progress = Array.from({ length: 41 }, (_, index) => index / 40);
expect(progress.map((value) => evaluateSpringEase(value, 0.75))).toEqual(
progress.map((value) => evaluateSpringEase(value, 0.75)),
);
const lowBounce = progress.map((value) => evaluateSpringEase(value, 0.25));
const highBounce = progress.map((value) => evaluateSpringEase(value, 0.75));
expect(Math.max(...highBounce)).toBeGreaterThan(Math.max(...lowBounce));
});
});
+30 -1
View File
@@ -1,2 +1,31 @@
/** @deprecated Import from @hyperframes/parsers/spring-ease */
// Preserve the legacy symbols (SpringPreset, SPRING_PRESETS, generateSpringEaseData)
// on the still-published ./spring-ease subpath. Dropping them is a breaking change
// for external importers; the canonical source stays @hyperframes/parsers/spring-ease.
export * from "@hyperframes/parsers/spring-ease";
const SPRING_TOKEN = /^\s*spring\(\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*\)\s*$/;
function clampBounce(bounce: number): number {
return Math.max(0, Math.min(1, bounce));
}
/** Parse Studio's single-parameter spring token into a normalized bounce value. */
export function parseSpringBounce(ease: string): number | null {
const match = SPRING_TOKEN.exec(ease);
if (!match) return null;
const bounce = Number(match[1]);
return Number.isFinite(bounce) ? clampBounce(bounce) : null;
}
/** Evaluate Studio's deterministic, endpoint-normalized damped-cosine spring. */
export function evaluateSpringEase(progress: number, bounce: number): number {
if (!Number.isFinite(progress)) return progress;
if (progress <= 0) return 0;
if (progress >= 1) return 1;
const normalizedBounce = clampBounce(bounce);
const decay = 12 - normalizedBounce * 6;
const angularFrequency = Math.PI * 2 * (1 + normalizedBounce * 1.5);
const endpoint = 1 - Math.exp(-decay) * Math.cos(angularFrequency);
return (1 - Math.exp(-decay * progress) * Math.cos(angularFrequency * progress)) / endpoint;
}