Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a749295
feat(studio): keyframe timeline (lanes, ease editor) reconciled onto …
miguel-heygen Jul 14, 2026
7e41582
fix(core): hoist runtime transport helpers to avoid load-time TDZ
miguel-heygen Jul 14, 2026
e8be020
feat(studio): timeline clip thumbnails off by default with a toolbar …
miguel-heygen Jul 14, 2026
9f96c21
refactor(core): wire runtime control bridge after all transport helpers
miguel-heygen Jul 14, 2026
43dd256
fix(parsers): exclude per-keyframe duration from object-keyframe prop…
miguel-heygen Jul 14, 2026
434ee9b
fix(studio): register custom eases in GSAP internal map so keyframe s…
miguel-heygen Jul 14, 2026
784cc29
fix(studio): re-assert custom-ease registration at the timeline rende…
miguel-heygen Jul 14, 2026
a46cea5
fix(studio): repair keyframes inner-ease baked before custom-ease reg…
miguel-heygen Jul 15, 2026
5730959
fix(studio): resolve Chrome for dev thumbnails via env + puppeteer cache
miguel-heygen Jul 15, 2026
e850f38
fix(studio): address element targets by a valid selector for non-iden…
miguel-heygen Jul 15, 2026
0b264c1
fix(studio): keep ease-curve draft until the commit round-trips (no f…
miguel-heygen Jul 15, 2026
c0f7834
fix(studio): show the segment ease button on inline clip-row keyframes
miguel-heygen Jul 15, 2026
f9cdc60
fix(studio): inline ease button opens the ease editor in the flat ins…
miguel-heygen Jul 15, 2026
ffa71e6
fix(studio): address code-review findings (cache identity, spring-eas…
miguel-heygen Jul 15, 2026
17e1435
fix(studio): reserve label column for flat-tween lanes; clamp bezier …
miguel-heygen Jul 15, 2026
05cf380
fix(studio): scope inline ease focus to its element; clear drag previ…
miguel-heygen Jul 15, 2026
33eb91a
test(studio): assert elementId on focusedEaseSegment in the segment-f…
miguel-heygen Jul 15, 2026
1c67e2d
test(studio): enable thumbnails in the empty-label test (fixes pre-ex…
miguel-heygen Jul 15, 2026
3824459
fix(studio): resolve keyframe removal from the clicked element, not t…
miguel-heygen Jul 15, 2026
4e1a537
fix(studio): reselect a retimed keyframe at its post-move tween perce…
miguel-heygen Jul 15, 2026
629ea7e
fix(studio): compose a rapid second retime from the pending position
miguel-heygen Jul 15, 2026
9027a57
fix(studio): suppress inline ease button on ambiguous collapsed-row s…
miguel-heygen Jul 15, 2026
bc2d918
fix(studio): harden diamond retime (post-move identity, boundary clam…
miguel-heygen Jul 15, 2026
861ab9e
fix(studio): commit non-selected keyframe deletion through the clicke…
miguel-heygen Jul 15, 2026
ecbe482
fix(studio): scope ease ambiguity + focus by identity, not raw ease/s…
miguel-heygen Jul 15, 2026
9056eda
feat(studio): record colliding animation ids on merged keyframes
miguel-heygen Jul 15, 2026
6e63eb5
feat(studio): thread colliding animation ids into focusedEaseSegment
miguel-heygen Jul 15, 2026
3b77b01
feat(studio): always show the inline ease button on merged segments
miguel-heygen Jul 15, 2026
dc2d897
feat(studio): bulk ease commit fanning out to all colliding animation…
miguel-heygen Jul 15, 2026
f24e7f3
feat(studio): collapsed-row ease editor bulk-edits all colliding anim…
miguel-heygen Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,12 @@
"import": "./src/parsers/springEase.ts",
"types": "./src/parsers/springEase.ts"
},
"./wiggle-ease": {
"bun": "./src/runtime/wiggleEase.ts",
"node": "./dist/runtime/wiggleEase.js",
"import": "./src/runtime/wiggleEase.ts",
"types": "./src/runtime/wiggleEase.ts"
},
"./fonts/aliases": {
"bun": "./src/fonts/aliases.ts",
"node": "./dist/fonts/aliases.js",
Expand Down Expand Up @@ -413,6 +419,10 @@
"import": "./dist/parsers/springEase.js",
"types": "./dist/parsers/springEase.d.ts"
},
"./wiggle-ease": {
"import": "./dist/runtime/wiggleEase.js",
"types": "./dist/runtime/wiggleEase.d.ts"
},
"./fonts/aliases": {
"import": "./dist/fonts/aliases.js",
"types": "./dist/fonts/aliases.d.ts"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/figma/motionEase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe("mapEase", () => {
ease: "power2.inOut",
});
expect(mapEase("backOut")).toEqual({ kind: "named", ease: "back.out" });
expect(mapEase("HOLD")).toEqual({ kind: "named", ease: "steps(1)" });
expect(mapEase("HOLD")).toEqual({ kind: "named", ease: "hold" });
});
it("falls back to none for unknown named eases", () => {
expect(mapEase("wobble")).toEqual({ kind: "named", ease: "none" });
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/figma/motionEase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const NAMED_EASE: Record<string, string> = {
elasticinout: "elastic.inOut",
anticipate: "back.in",
spring: "elastic.out",
hold: "steps(1)",
hold: "hold",
};

function isBezier4(ease: unknown[]): ease is [number, number, number, number] {
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ export {
keyframesToGsapAnimations,
gsapAnimationsToKeyframes,
} from "@hyperframes/parsers";

export type { ParsedHtml, CompositionMetadata } from "@hyperframes/parsers";

export {
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/parsers/springEase.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 1 deletion packages/core/src/parsers/springEase.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,30 @@
/** @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 (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;
}
67 changes: 67 additions & 0 deletions packages/core/src/runtime/customEase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { installStudioCustomEase } from "./customEase";

describe("installStudioCustomEase", () => {
it("resolves wiggle while preserving hold, spring, custom, and named eases", () => {
const namedEase = (progress: number) => progress * progress;
const originalParseEase = vi.fn(() => namedEase);
const gsap = { parseEase: originalParseEase };

expect(installStudioCustomEase(gsap)).toBe(true);

const wiggleEase = gsap.parseEase("wiggle(6,easeInOut)");
expect(wiggleEase).toBeTypeOf("function");
const samples = Array.from({ length: 401 }, (_, index) => wiggleEase(index / 400));
expect(samples[0]).toBe(0);
expect(samples.at(-1)).toBe(1);
expect(samples).toEqual(Array.from({ length: 401 }, (_, index) => wiggleEase(index / 400)));
const directions = samples
.slice(1)
.map((value, index) => Math.sign(value - samples[index]!))
.filter((direction) => direction !== 0);
expect(
directions.filter((direction, index) => index > 0 && direction !== directions[index - 1])
.length,
).toBeGreaterThanOrEqual(8);

expect(gsap.parseEase("hold")(0.5)).toBe(0);
expect(gsap.parseEase("spring(0.5)")(0)).toBe(0);
expect(gsap.parseEase("custom(M0,0 C0.25,0.1 0.25,1 1,1)")(1)).toBe(1);
expect(gsap.parseEase("power2.out")).toBe(namedEase);
expect(originalParseEase).toHaveBeenCalledTimes(1);
});

it("registers custom eases in GSAP's internal ease map for keyframe-segment resolution", () => {
// GSAP resolves keyframe SEGMENT eases via its internal _parseEase/_easeMap,
// not the public parseEase — so the eases must be registered there too, else
// a custom ease inside `keyframes:{...}` resolves to undefined and throws
// "_ease is not a function" on first render.
const easeMap = new Map<string, ((progress: number) => number) & { config?: unknown }>();
const gsap = {
parseEase: vi.fn((ease: unknown) => (typeof ease === "function" ? ease : null)),
registerEase: (name: string, ease: (progress: number) => number) => easeMap.set(name, ease),
};

expect(installStudioCustomEase(gsap)).toBe(true);

// Bare hold registers directly and holds at 0 until the end.
expect(easeMap.get("hold")?.(0.5)).toBe(0);
expect(easeMap.get("hold")?.(1)).toBe(1);

// GSAP calls a configurable ease's `.config` with the parenthesized params
// comma-split (custom's bezier path splits into several parts). The config
// must rejoin them and resolve a real ease.
const springConfig = easeMap.get("spring")?.config as (
...p: unknown[]
) => (n: number) => number;
expect(springConfig(0.5)(0)).toBe(0);

const customConfig = easeMap.get("custom")?.config as (
...p: unknown[]
) => (n: number) => number;
// "custom(M0,0 C0.25,0.1 0.25,1 1,1)" → params split on "," by GSAP:
const customEase = customConfig("M0", "0 C0.25", "0.1 0.25", "1 1", 1);
expect(customEase(0)).toBe(0);
expect(customEase(1)).toBe(1);
});
});
131 changes: 131 additions & 0 deletions packages/core/src/runtime/customEase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { evaluateSpringEase, parseSpringBounce } from "../parsers/springEase";
import { resolveWiggleEase } from "./wiggleEase";

type RuntimeEase = (progress: number) => number;

type ConfigurableEase = RuntimeEase & { config?: (...params: unknown[]) => RuntimeEase };

type GsapEaseApi = {
parseEase?: (ease: string | RuntimeEase, ...args: unknown[]) => RuntimeEase | null;
registerEase?: (name: string, ease: RuntimeEase) => void;
};

const BISECTION_STEPS = 24;
const HOLD_EASE: RuntimeEase = (progress) => (progress >= 1 ? 1 : 0);
const NUMBER_SOURCE = String.raw`([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)`;
const CUSTOM_CUBIC_PATH = new RegExp(
String.raw`^\s*M\s*0\s*,\s*0\s+C\s*${NUMBER_SOURCE}\s*,\s*${NUMBER_SOURCE}\s+${NUMBER_SOURCE}\s*,\s*${NUMBER_SOURCE}\s+1\s*,\s*1\s*$`,
"i",
);

function cubicCoordinate(t: number, point1: number, point2: number): number {
const inverse = 1 - t;
return 3 * inverse * inverse * t * point1 + 3 * inverse * t * t * point2 + t * t * t;
}

function evaluateCubicBezier(
progress: number,
x1: number,
y1: number,
x2: number,
y2: number,
): number {
if (progress <= 0) return 0;
if (progress >= 1) return 1;

let low = 0;
let high = 1;
for (let step = 0; step < BISECTION_STEPS; step += 1) {
const t = (low + high) / 2;
if (cubicCoordinate(t, x1, x2) < progress) low = t;
else high = t;
}
return cubicCoordinate((low + high) / 2, y1, y2);
}

function createCubicBezierEase(path: string): RuntimeEase | null {
const match = CUSTOM_CUBIC_PATH.exec(path);
if (!match) return null;
const x1 = Number(match[1]);
const y1 = Number(match[2]);
const x2 = Number(match[3]);
const y2 = Number(match[4]);
if (Math.min(x1, x2) < 0 || Math.max(x1, x2) > 1) return null;
return (progress) => evaluateCubicBezier(progress, x1, y1, x2, y2);
}

function resolveSpringEase(
ease: string,
springEaseCache: Map<number, RuntimeEase>,
): RuntimeEase | null {
if (!ease.startsWith("spring(")) return null;
const bounce = parseSpringBounce(ease);
if (bounce === null) return null;
const cached = springEaseCache.get(bounce);
if (cached) return cached;
const springEase: RuntimeEase = (progress) => evaluateSpringEase(progress, bounce);
springEaseCache.set(bounce, springEase);
return springEase;
}

function resolveCustomEase(
ease: string,
customEaseCache: Map<string, RuntimeEase>,
): RuntimeEase | null {
if (!ease.startsWith("custom(") || !ease.endsWith(")")) return null;
const path = ease.slice(7, -1);
const cached = customEaseCache.get(path);
if (cached) return cached;
const customEase = createCubicBezierEase(path);
if (customEase) customEaseCache.set(path, customEase);
return customEase;
}

export function installStudioCustomEase(gsap: GsapEaseApi): boolean {
const originalParseEase = gsap.parseEase;
if (!originalParseEase) return false;

const customEaseCache = new Map<string, RuntimeEase>();
const springEaseCache = new Map<number, RuntimeEase>();

// Single source of truth for "hyperframes ease string -> function". Both the
// public parseEase override and the internal registerEase configs below route
// through this, so the resolution rules live in exactly one place.
const resolveHyperframesEase = (ease: string | RuntimeEase): RuntimeEase | null => {
if (typeof ease !== "string") return null;
if (ease === "hold") return HOLD_EASE;
return (
resolveWiggleEase(ease) ??
resolveSpringEase(ease, springEaseCache) ??
resolveCustomEase(ease, customEaseCache)
);
};

// Public parseEase: direct callers (studio ease UI, adapters) resolve
// synchronously; anything else falls through to GSAP's own parser.
gsap.parseEase = function parseHyperframesEase(ease, ...args) {
return resolveHyperframesEase(ease) ?? originalParseEase.call(this, ease, ...args);
};

// GSAP resolves a keyframe SEGMENT's ease through its INTERNAL _parseEase /
// _easeMap, never the public parseEase above — so a custom ease used inside
// `keyframes:{...}` resolves to undefined and throws "_ease is not a function"
// on the first render. Register the eases in the internal map too. GSAP calls
// a configurable ease's `.config(...params)` with the parenthesized string
// comma-split, so `params.join(",")` losslessly reconstructs the original
// (including custom() bezier paths, whose commas survive the round-trip).
const registerEase = gsap.registerEase;
if (typeof registerEase === "function") {
registerEase("hold", HOLD_EASE);
const registerConfigurable = (name: string): void => {
const base: ConfigurableEase = (progress) => progress;
base.config = (...params) =>
resolveHyperframesEase(`${name}(${params.join(",")})`) ?? ((progress) => progress);
registerEase(name, base);
};
registerConfigurable("spring");
registerConfigurable("wiggle");
registerConfigurable("custom");
}
return true;
}
Loading
Loading