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
+6
View File
@@ -242,6 +242,12 @@
"types": "./dist/parsers/springEase.d.ts",
"environments": ["browser", "bun", "node"]
},
"./wiggle-ease": {
"source": "./src/runtime/wiggleEase.ts",
"runtime": "./dist/runtime/wiggleEase.js",
"types": "./dist/runtime/wiggleEase.d.ts",
"environments": ["browser", "bun", "node"]
},
"./fonts/aliases": {
"source": "./src/fonts/aliases.ts",
"runtime": "./dist/fonts/aliases.js",
+10
View File
@@ -251,6 +251,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",
@@ -429,6 +435,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"
+1 -1
View File
@@ -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" });
+1 -1
View File
@@ -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] {
@@ -1,6 +1,6 @@
// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit
const POSITION_EDITS_RENDER_IIFE: string =
'"use strict";(()=>{function A(){return globalThis}function P(e,t){if(typeof window>"u")return;let n=A(),r=n.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(i){}(n.__hfDebug||n.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var T=null;function R(e,t){if(T)try{T({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(n){P("runtime.analytics.site1",n)}}var _="data-hf-edit-base-x",S="data-hf-edit-base-y",m="data-hf-edit-original-translate",E=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},V=e=>{let t=[],n=0,r="";for(let i of e.trim())i==="("&&(n+=1),i===")"&&(n=Math.max(0,n-1)),/\\s/.test(i)&&n===0?(r&&t.push(r),r=""):r+=i;return r&&t.push(r),t},$=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,k=(e,t)=>$.test(e)&&$.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,I=(e,t,n)=>{if(!e||e==="none")return`${t} ${n}`;let[r,i,u]=V(e);if(r===void 0)return`${t} ${n}`;if(i===void 0)return`${k(r,t)} ${n}`;let d=u===void 0?"":` ${u}`;return`${k(r,t)} ${k(i,n)}${d}`},O=e=>{try{e.ownerDocument.defaultView?.gsap?.getProperty?.(e,"x")}catch{}},G=e=>{let t=e.style.getPropertyValue("translate").trim();if(t)return t==="none"?"":t;try{let n=e.ownerDocument.defaultView,r=n?n.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},h=new WeakMap;function H(e,t){let n=h.get(e);if(!t?.force&&n!==void 0&&e.style.getPropertyValue("translate")!==n){R("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=E(e.getAttribute("data-x"))-E(e.getAttribute(_)),i=E(e.getAttribute("data-y"))-E(e.getAttribute(S));e.hasAttribute(m)||e.setAttribute(m,G(e)),n===void 0&&O(e);let u=e.getAttribute(m)??"",d=I(u,`${r}px`,`${i}px`);e.style.setProperty("translate",d),h.set(e,e.style.getPropertyValue("translate"))}function v(e,t){let n=e.defaultView?.HTMLElement,r=e.defaultView?.SVGElement,i=a=>n||r?n!==void 0&&a instanceof n||r!==void 0&&a instanceof r:typeof a.style?.setProperty=="function",u=e.querySelectorAll(`[${m}]:not([${_}]):not([${S}])`);for(let a=0;a<u.length;a++){let l=u[a];if(l===void 0||!i(l))continue;let w=l.getAttribute(m)??"";w===""?l.style.removeProperty("translate"):l.style.setProperty("translate",w),l.removeAttribute(m),h.delete(l)}let d=e.querySelectorAll(`[${_}], [${S}]`),b=0;for(let a=0;a<d.length;a++){let l=d[a];l===void 0||!i(l)||(H(l,t),b+=1)}return b}var x="__hfPositionEditsSeekReapplyWrapped",D=new WeakSet,F=new WeakMap,W=new WeakMap;function L(e){let t=e,n=()=>{try{v(t.document)}catch{}},r=o=>typeof o=="function"&&(D.has(o)||!!o[x]),i=o=>{D.add(o);try{Object.defineProperty(o,x,{value:!0})}catch{}},u=o=>{if(typeof o!="function"||r(o))return o;let s=function(...c){let f=o.apply(this,c);return n(),f};return i(s),s},d=(o,s)=>{let c=F.get(o);if(c?.has(s))return!0;let f=Object.getOwnPropertyDescriptor(o,s);if(f?.configurable===!1){let p=o[s];return typeof p=="function"&&(o[s]=u(p),n()),!1}let g=o[s],y=f?.set;return Object.defineProperty(o,s,{configurable:!0,enumerable:f?.enumerable??!0,get:()=>g,set:p=>{g=u(p),y?.call(o,p)}}),g=u(g),c??(c=new Set),c.add(s),F.set(o,c),n(),!0},b=(o,s)=>{let c=W.get(t),f=Object.getOwnPropertyDescriptor(t,o);if(!c?.has(o)){if(f?.configurable===!1){let p=t[o];return p?d(p,s):!1}let y=t[o];Object.defineProperty(t,o,{configurable:!0,enumerable:f?.enumerable??!0,get:()=>y,set:p=>{y=p,y&&d(y,s)}}),c??(c=new Set),c.add(o),W.set(t,c)}let g=t[o];return g?d(g,s):!1},a=()=>{let o=b("__hf","seek"),s=b("__player","renderSeek");return o&&s};if(a())return;let l=120,w=t.setInterval(()=>{if(a()){t.clearInterval(w);return}l-=1,l<=0&&t.clearInterval(w)},50)}function M(){document.querySelector(`[${_}], [${S}]`)&&(v(document),L(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",M,{once:!0}):M();})();\n';
'"use strict";(()=>{function A(){return globalThis}function P(e,t){if(typeof window>"u")return;let n=A(),r=n.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(i){}(n.__hfDebug||n.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var T=null;function R(e,t){if(T)try{T({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(n){P("runtime.analytics.site1",n)}}var w="data-hf-edit-base-x",k="data-hf-edit-base-y",y="data-hf-edit-original-translate",S=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},V=e=>{let t=[],n=0,r="";for(let i of e.trim())i==="("&&(n+=1),i===")"&&(n=Math.max(0,n-1)),/\\s/.test(i)&&n===0?(r&&t.push(r),r=""):r+=i;return r&&t.push(r),t},$=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,E=(e,t)=>$.test(e)&&$.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,I=(e,t,n)=>{if(!e||e==="none")return`${t} ${n}`;let[r,i,u]=V(e);if(r===void 0)return`${t} ${n}`;if(i===void 0)return`${E(r,t)} ${n}`;let d=u===void 0?"":` ${u}`;return`${E(r,t)} ${E(i,n)}${d}`},O=e=>{try{e.ownerDocument.defaultView?.gsap?.getProperty?.(e,"x")}catch{}},G=e=>{let t=e.style.getPropertyValue("translate").trim();if(t)return t==="none"?"":t;try{let n=e.ownerDocument.defaultView,r=n?n.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},h=new WeakMap;function H(e,t){let n=h.get(e);if(!t?.force&&n!==void 0&&e.style.getPropertyValue("translate")!==n){R("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=S(e.getAttribute("data-x"))-S(e.getAttribute(w)),i=S(e.getAttribute("data-y"))-S(e.getAttribute(k));e.hasAttribute(y)||e.setAttribute(y,G(e)),n===void 0&&O(e);let u=e.getAttribute(y)??"",d=I(u,`${r}px`,`${i}px`);e.style.setProperty("translate",d),h.set(e,e.style.getPropertyValue("translate"))}function v(e,t){let n=e.defaultView?.HTMLElement,r=e.defaultView?.SVGElement,i=a=>n||r?n!==void 0&&a instanceof n||r!==void 0&&a instanceof r:typeof a.style?.setProperty=="function",u=e.querySelectorAll(`[${y}]:not([${w}]):not([${k}])`);for(let a=0;a<u.length;a++){let l=u[a];if(l===void 0||!i(l))continue;let b=l.getAttribute(y)??"";b===""?l.style.removeProperty("translate"):l.style.setProperty("translate",b),l.removeAttribute(y),h.delete(l)}let d=e.querySelectorAll(`[${w}], [${k}]`),m=0;for(let a=0;a<d.length;a++){let l=d[a];l===void 0||!i(l)||(H(l,t),m+=1)}return m}var x="__hfPositionEditsSeekReapplyWrapped",D=new WeakSet,F=new WeakMap,W=new WeakMap;function L(e){let t=e,n=()=>{try{v(t.document)}catch{}},r=o=>typeof o=="function"&&(D.has(o)||!!o[x]),i=o=>{D.add(o);try{Object.defineProperty(o,x,{value:!0})}catch{}},u=o=>{if(typeof o!="function"||r(o))return o;let s=function(...c){let f=o.apply(this,c);return n(),f};return i(s),s},d=(o,s)=>{let c=F.get(o);if(c?.has(s))return!0;let f=Object.getOwnPropertyDescriptor(o,s);if(f?.configurable===!1){let p=o[s];return typeof p=="function"&&(o[s]=u(p),n()),!1}let g=o[s],_=f?.set;return Object.defineProperty(o,s,{configurable:!0,enumerable:f?.enumerable??!0,get:()=>g,set:p=>{g=u(p),_?.call(o,p)}}),g=u(g),c??(c=new Set),c.add(s),F.set(o,c),n(),!0},m=(o,s)=>{let c=W.get(t),f=Object.getOwnPropertyDescriptor(t,o);if(!c?.has(o)){if(f?.configurable===!1){let p=t[o];return p?d(p,s):!1}let _=t[o];Object.defineProperty(t,o,{configurable:!0,enumerable:f?.enumerable??!0,get:()=>_,set:p=>{_=p,_&&d(_,s)}}),c??(c=new Set),c.add(o),W.set(t,c)}let g=t[o];return g?d(g,s):!1},a=()=>{let o=m("__hf","seek"),s=m("__player","renderSeek");return o&&s};if(a())return;let l=120,b=t.setInterval(()=>{if(a()){t.clearInterval(b);return}l-=1,l<=0&&t.clearInterval(b)},50)}function M(){document.querySelector(`[${w}], [${k}]`)&&(v(document),L(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",M,{once:!0}):M();})();\n';
/** Returns the pre-built position-edits render IIFE as a string constant. */
export function getPositionEditsRenderScript(): string {
-1
View File
@@ -95,7 +95,6 @@ export {
keyframesToGsapAnimations,
gsapAnimationsToKeyframes,
} from "@hyperframes/parsers";
export type { ParsedHtml, CompositionMetadata } from "@hyperframes/parsers";
export {
@@ -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;
}
+5 -1
View File
@@ -14,7 +14,11 @@ export type RuntimeAnalyticsEvent =
| "composition_ended"
| "element_picked"
| "position_edit_fold_skipped"
| "timeline_missing_pause";
| "timeline_missing_pause"
| "auto_marker_install_failed"
| "custom_ease_install_failed"
| "custom_ease_parse_failed"
| "keyframe_ease_repair_failed";
export type RuntimeAnalyticsProperties = Record<string, string | number | boolean | null>;
@@ -0,0 +1,100 @@
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(Number.isNaN(gsap.parseEase("custom(M0,0 C0.25,0.1 0.25,1 1,1)")(Number.NaN))).toBe(
true,
);
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);
});
it("installs once per GSAP instance and reinstalls on a replacement instance", () => {
const firstParser = vi.fn(() => (progress: number) => progress);
const first = { parseEase: firstParser };
expect(installStudioCustomEase(first)).toBe(true);
const installed = first.parseEase;
expect(installStudioCustomEase(first)).toBe(true);
expect(first.parseEase).toBe(installed);
const replacementParser = vi.fn(() => (progress: number) => progress);
const replacement = { parseEase: replacementParser };
expect(installStudioCustomEase(replacement)).toBe(true);
expect(replacement.parseEase).not.toBe(replacementParser);
});
it("uses the same GSAP fallback for malformed public and segment eases", () => {
const fallback = (progress: number) => progress * progress;
const easeMap = new Map<string, ConfigurableEase>();
type ConfigurableEase = ((progress: number) => number) & {
config?: (...params: unknown[]) => (progress: number) => number;
};
const gsap = {
parseEase: vi.fn(() => fallback),
registerEase: (name: string, ease: ConfigurableEase) => easeMap.set(name, ease),
};
expect(installStudioCustomEase(gsap)).toBe(true);
expect(gsap.parseEase("spring(nope)")).toBe(fallback);
expect(easeMap.get("spring")?.config?.("nope")).toBe(fallback);
});
});
+153
View File
@@ -0,0 +1,153 @@
import { evaluateSpringEase, parseSpringBounce } from "../parsers/springEase";
import { emitAnalyticsEvent } from "./analytics";
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;
__hfCustomEaseInstalled?: boolean;
};
const BISECTION_STEPS = 24;
const HOLD_EASE: RuntimeEase = (progress) => (progress >= 1 ? 1 : 0);
const IDENTITY_EASE: RuntimeEase = (progress) => progress;
const NUMBER_SOURCE = String.raw`([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)`;
// Studio authors one cubic-bezier segment with fixed (0,0) and (1,1)
// endpoints. Anticipation/overshoot is represented by Y controls outside
// [0,1]; X controls stay in [0,1] so progress remains a function of time.
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 (!Number.isFinite(progress)) return progress;
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 {
if (gsap.__hfCustomEaseInstalled) return true;
const originalParseEase = gsap.parseEase;
if (!originalParseEase) return false;
const customEaseCache = new Map<string, RuntimeEase>();
const springEaseCache = new Map<number, RuntimeEase>();
const wiggleEaseCache = new Map<string, 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, wiggleEaseCache) ??
resolveSpringEase(ease, springEaseCache) ??
resolveCustomEase(ease, customEaseCache)
);
};
const parseEaseWithFallback = (
ease: string | RuntimeEase,
context: GsapEaseApi,
args: unknown[] = [],
): RuntimeEase => {
const resolved = resolveHyperframesEase(ease);
if (resolved) return resolved;
if (typeof ease === "string" && /^(?:hold|spring|wiggle|custom)(?:\(|$)/.test(ease.trim())) {
emitAnalyticsEvent("custom_ease_parse_failed", { ease });
}
return originalParseEase.call(context, ease, ...args) ?? IDENTITY_EASE;
};
// 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 parseEaseWithFallback(ease, this, 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) => parseEaseWithFallback(`${name}(${params.join(",")})`, gsap);
registerEase(name, base);
};
registerConfigurable("spring");
registerConfigurable("wiggle");
registerConfigurable("custom");
}
gsap.__hfCustomEaseInstalled = true;
return true;
}
+216
View File
@@ -119,11 +119,227 @@ describe("initSandboxRuntimeModular", () => {
delete (window as { __HF_EXPORT_RENDER_SEEK_CONFIG?: unknown }).__HF_EXPORT_RENDER_SEEK_CONFIG;
delete window.__hfTimelinesBuilding;
delete (window as { THREE?: unknown }).THREE;
delete (window as { __hfAutoNoopRegistered?: boolean }).__hfAutoNoopRegistered;
delete window.gsap;
vi.restoreAllMocks();
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
});
it("resolves Studio hold as a deterministic step at the segment end", () => {
const defaultEase = (progress: number) => progress;
const originalParseEase = vi.fn(() => defaultEase);
window.gsap = {
timeline: () => createMockTimeline(1),
parseEase: originalParseEase,
registerPlugin: vi.fn(),
};
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(1) };
initSandboxRuntimeModular();
const first = window.gsap.parseEase?.("hold");
const second = window.gsap.parseEase?.("hold");
expect(first).toBeTypeOf("function");
expect(second).toBe(first);
if (typeof first !== "function") return;
expect([0, 0.25, 0.5, 0.99].map(first)).toEqual([0, 0, 0, 0]);
expect(first(1)).toBe(1);
expect(first(1.01)).toBe(1);
expect(originalParseEase).not.toHaveBeenCalledWith("hold");
});
it("repairs a keyframes tween's inner-timeline ease baked to undefined before custom-ease registration", () => {
// The composition inline script builds keyframes tweens BEFORE this runtime
// registers the custom eases, so a `{keyframes, ease:"hold"}` tween's inner
// timeline `_ease` bakes to undefined (GSAP resolves it once at build via the
// internal ease map). GSAP then throws "_ease is not a function" on the first
// render. The runtime must re-resolve that inner ease after registration.
window.gsap = {
timeline: () => createMockTimeline(20),
parseEase: vi.fn(() => (progress: number) => progress),
registerPlugin: vi.fn(),
registerEase: vi.fn(),
} as unknown as typeof window.gsap;
const innerTimeline: { _ease?: unknown } = { _ease: undefined };
const keyframesTween = {
vars: { ease: "hold", keyframes: { "0%": { x: 0 }, "100%": { x: 50 } } },
timeline: innerTimeline,
_ease: (progress: number) => progress,
targets: () => [document.createElement("div")],
};
const main = createMockTimeline(20);
main.getChildren = () => [keyframesTween as unknown as RuntimeTimelineLike];
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "20");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main };
expect(innerTimeline._ease).toBeUndefined();
initSandboxRuntimeModular();
// Bind re-resolves the inner ease to a real function (the installed hold ease),
// so a subsequent render can call `timeline._ease(...)` without throwing.
expect(innerTimeline._ease).toBeTypeOf("function");
});
it("isolates a failed keyframe ease repair and reports it without skipping siblings", () => {
const outbound: Array<{ type?: string; event?: string }> = [];
vi.spyOn(window.parent, "postMessage").mockImplementation((message: unknown) => {
if (typeof message === "object" && message !== null) {
outbound.push(message as { type?: string; event?: string });
}
});
window.gsap = {
timeline: () => createMockTimeline(20),
parseEase: vi.fn((ease: unknown) => {
if (ease === "bad-ease") throw new Error("bad ease");
return (progress: number) => progress;
}),
registerPlugin: vi.fn(),
registerEase: vi.fn(),
} as unknown as typeof window.gsap;
const failedInner: { _ease?: unknown } = { _ease: undefined };
const repairedInner: { _ease?: unknown } = { _ease: undefined };
const main = createMockTimeline(20);
main.getChildren = () =>
[
{ vars: { ease: "bad-ease", keyframes: {} }, timeline: failedInner },
{ vars: { ease: "hold", keyframes: {} }, timeline: repairedInner },
] as unknown as RuntimeTimelineLike[];
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-duration", "20");
document.body.appendChild(root);
window.__timelines = { main };
initSandboxRuntimeModular();
expect(failedInner._ease).toBeUndefined();
expect(repairedInner._ease).toBeTypeOf("function");
expect(outbound).toContainEqual(
expect.objectContaining({
type: "analytics",
event: "keyframe_ease_repair_failed",
}),
);
});
it("resolves Studio custom cubic-bezier eases on the composition GSAP instance", () => {
const defaultEase = (progress: number) => 1 - (1 - progress) ** 2;
const originalParseEase = vi.fn(() => defaultEase);
window.gsap = {
timeline: () => createMockTimeline(1),
parseEase: originalParseEase,
registerPlugin: vi.fn(),
};
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(1) };
initSandboxRuntimeModular();
const custom = "custom(M0,0 C0.42,0 0.58,1 1,1)";
const first = window.gsap.parseEase?.(custom);
const second = window.gsap.parseEase?.(custom);
expect(first).toBeTypeOf("function");
expect(second).toBe(first);
if (typeof first !== "function" || typeof second !== "function") return;
const progressSamples = [0, 0.25, 0.5, 0.75, 1];
expect(progressSamples.map(first)).toEqual(progressSamples.map(second));
expect(first(0.25)).toBeCloseTo(0.1292, 4);
expect(first(0.5)).toBeCloseTo(0.5, 6);
expect(first(0.5)).not.toBeCloseTo(defaultEase(0.5), 6);
expect(first(0.75)).toBeCloseTo(0.8708, 4);
expect(originalParseEase).not.toHaveBeenCalledWith(custom);
expect(window.gsap.parseEase?.("power1.out")).toBe(defaultEase);
expect(originalParseEase).toHaveBeenCalledWith("power1.out");
expect(window.gsap.parseEase?.("custom(not-a-path)")).toBe(defaultEase);
expect(originalParseEase).toHaveBeenCalledWith("custom(not-a-path)");
const installedParseEase = window.gsap.parseEase;
initSandboxRuntimeModular();
expect(window.gsap.parseEase).toBe(installedParseEase);
window.gsap = {
timeline: () => createMockTimeline(1),
parseEase: vi.fn(() => defaultEase),
registerPlugin: vi.fn(),
};
initSandboxRuntimeModular();
const freshResolution = window.gsap.parseEase?.(custom);
expect(freshResolution).toBeTypeOf("function");
if (typeof freshResolution === "function") {
expect(progressSamples.map(freshResolution)).toEqual(progressSamples.map(first));
}
});
it("resolves Studio spring eases as deterministic oscillations that settle exactly", () => {
const defaultEase = (progress: number) => progress;
const originalParseEase = vi.fn(() => defaultEase);
window.gsap = {
timeline: () => createMockTimeline(1),
parseEase: originalParseEase,
registerPlugin: vi.fn(),
};
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
root.setAttribute("data-root", "true");
root.setAttribute("data-start", "0");
root.setAttribute("data-duration", "1");
root.setAttribute("data-width", "1920");
root.setAttribute("data-height", "1080");
document.body.appendChild(root);
window.__timelines = { main: createMockTimeline(1) };
initSandboxRuntimeModular();
const first = window.gsap.parseEase?.("spring(0.5)");
const second = window.gsap.parseEase?.("spring(0.5)");
expect(first).toBeTypeOf("function");
expect(second).toBe(first);
if (typeof first !== "function" || typeof second !== "function") return;
const samples = Array.from({ length: 101 }, (_, index) => first(index / 100));
expect(first(0)).toBe(0);
expect(first(1)).toBe(1);
expect(Math.max(...samples)).toBeGreaterThan(1);
expect(samples).toEqual(Array.from({ length: 101 }, (_, index) => second(index / 100)));
expect(originalParseEase).not.toHaveBeenCalledWith("spring(0.5)");
expect(window.gsap.parseEase?.("power1.out")).toBe(defaultEase);
expect(originalParseEase).toHaveBeenCalledWith("power1.out");
});
it("keeps authored composition hosts visible when the live child timeline is shorter", () => {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "main");
+88 -3
View File
@@ -52,6 +52,7 @@ import type {
import type { PlayerAPI } from "../core.types";
import { swallow } from "./diagnostics";
import { shouldAttemptPeriodicTimelineBind } from "./timelineRebindPolicy";
import { installStudioCustomEase } from "./customEase";
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
const AUTHORED_END_ATTR = "data-hf-authored-end";
@@ -114,6 +115,9 @@ function resolveExportRenderFps(): ExportRenderFpsResolution {
export function initSandboxRuntimeModular(): void {
const state = createRuntimeState();
// Own the analytics bridge before any best-effort runtime installation so
// early failures are observable instead of disappearing before player setup.
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
// SDK moveElement edits must render even when no usable GSAP timeline ever
// binds (CSS/WAAPI-animated or fully static compositions) — apply at init.
// This runs at DOMContentLoaded, after inline composition scripts have
@@ -142,6 +146,16 @@ export function initSandboxRuntimeModular(): void {
const runtimeCleanupCallbacks: Array<() => void> = [];
const postedDiagnosticKeys = new Set<string>();
let rootStageDiagnosticRafId: number | null = null;
const reportedRuntimeIssues = new Set<string>();
const reportRuntimeIssueOnce = (
key: string,
event: "auto_marker_install_failed" | "custom_ease_install_failed",
properties: Record<string, string>,
): void => {
if (reportedRuntimeIssues.has(key)) return;
reportedRuntimeIssues.add(key);
emitAnalyticsEvent(event, properties);
};
if (typeof window.__hfRuntimeTeardown === "function") {
try {
window.__hfRuntimeTeardown();
@@ -172,11 +186,38 @@ export function initSandboxRuntimeModular(): void {
try {
g.registerPlugin({ name: "_auto", init: () => false });
w.__hfAutoNoopRegistered = true;
} catch {
} catch (err) {
reportRuntimeIssueOnce("auto_marker_install_failed", "auto_marker_install_failed", {
reason: "threw",
});
swallow("runtime.autoMarker.install", err);
// a stray warning is preferable to a broken runtime
}
};
const ensureStudioCustomEase = (): void => {
const g = window.gsap;
if (!g) {
reportRuntimeIssueOnce("custom_ease_missing_gsap", "custom_ease_install_failed", {
reason: "missing_gsap",
});
return;
}
try {
if (!installStudioCustomEase(g)) {
reportRuntimeIssueOnce("custom_ease_no_parse_ease", "custom_ease_install_failed", {
reason: "no_parseEase",
});
}
} catch (err) {
reportRuntimeIssueOnce("custom_ease_install_threw", "custom_ease_install_failed", {
reason: "threw",
});
swallow("runtime.customEase.install", err);
// falling back to GSAP's default ease is preferable to a broken runtime
}
};
ensureAutoMarkerNoop();
ensureStudioCustomEase();
// Normalize html/body so browser defaults (8px margin, white background) never
// bleed into renders as white bars. Runs in both preview and render contexts,
// eliminating the preview/render parity gap that existed when only the React
@@ -1207,8 +1248,52 @@ export function initSandboxRuntimeModular(): void {
// (setTimeout(0)). Scripts using requestAnimationFrame or longer delays may
// not be discovered.
let childrenBound = false;
// A GSAP keyframes tween (`{ keyframes: {...}, ease }`) builds an INNER timeline
// whose own `_ease` GSAP resolves ONCE, at build time, via the internal
// `_parseEase(vars.ease)` (gsap-core: `tl._ease = _parseEase(keyframes.ease ||
// vars.ease || "none")`). On render it calls that inner `timeline._ease(...)`.
// The composition's inline `<script>` runs and builds these tweens BEFORE this
// runtime finishes registering the custom eases (hold/spring/wiggle/custom) in
// GSAP's internal ease map — so for a custom container ease the inner `_ease`
// bakes to `undefined`, and the first render throws "_ease is not a function"
// (a masked cross-origin Script error). Registering the eases afterward can't
// retro-fix that already-baked value, so re-resolve every keyframes tween's
// inner `_ease` here, once the eases are registered.
const repairKeyframeInnerEase = (tlLike: unknown): void => {
const g = (window as unknown as { gsap?: { parseEase?: (e: unknown) => unknown } }).gsap;
const tl = tlLike as { getChildren?: (a: boolean, b: boolean, c: boolean) => unknown[] } | null;
if (!tl || typeof tl.getChildren !== "function" || !g || typeof g.parseEase !== "function")
return;
for (const child of tl.getChildren(true, true, true)) {
const k = child as {
timeline?: { _ease?: unknown };
vars?: { ease?: unknown; keyframes?: unknown };
};
const inner = k.timeline;
if (!inner || !("_ease" in inner) || typeof inner._ease === "function") continue;
const kf = k.vars?.keyframes;
const kfEase = kf && !Array.isArray(kf) ? (kf as { ease?: unknown }).ease : undefined;
const ease = kfEase ?? k.vars?.ease ?? "none";
try {
const resolved = g.parseEase(ease);
if (typeof resolved === "function") inner._ease = resolved;
} catch (err) {
emitAnalyticsEvent("keyframe_ease_repair_failed", {
ease: typeof ease === "string" ? ease : String(ease),
});
swallow("runtime.keyframeEase.repair", err);
}
}
};
// fallow-ignore-next-line complexity
const bindRootTimelineIfAvailable = (): boolean => {
// Custom eases (hold/spring/wiggle/custom) must be registered in GSAP's
// internal ease map BEFORE this function's prime render (progress/totalTime
// below), or a keyframe segment using one resolves to a non-function ease
// and GSAP throws "_ease is not a function" at render. The one-shot call in
// init runs early, but if GSAP wasn't ready then (load-order race) it's a
// no-op with no retry — so re-assert here, at the render site. Idempotent.
ensureStudioCustomEase();
if (!externalCompositionsReady) return false;
const currentTimeline = state.capturedTimeline;
const currentDuration = getTimelineDurationSeconds(currentTimeline);
@@ -1229,6 +1314,8 @@ export function initSandboxRuntimeModular(): void {
if (typeof state.capturedTimeline.timeScale === "function") {
state.capturedTimeline.timeScale(state.playbackRate);
}
// Repair keyframe inner-timeline eases before any prime render (see helper above).
repairKeyframeInnerEase(state.capturedTimeline);
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
if (boundDuration <= 0) {
// No resolvable duration (e.g. a set()-only timeline, or one whose
@@ -2371,8 +2458,6 @@ export function initSandboxRuntimeModular(): void {
window.__player = createPlayerApiCompat(player);
window.__playerReady = true;
// Wire analytics event emission through the bridge
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
emitAnalyticsEvent("composition_loaded", {
duration: player.getDuration(),
compositionId:
+2 -7
View File
@@ -1,4 +1,5 @@
import type { HfColorGradingTarget } from "../colorGrading";
import type { RuntimeAnalyticsEvent } from "./analytics";
export type RuntimeJson =
| string
@@ -177,13 +178,7 @@ export type RuntimeReadyMessage = {
export type RuntimeAnalyticsMessage = {
source: "hf-preview";
type: "analytics";
event:
| "composition_loaded"
| "composition_played"
| "composition_paused"
| "composition_seeked"
| "composition_ended"
| "element_picked";
event: RuntimeAnalyticsEvent;
properties: Record<string, string | number | boolean | null>;
};
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { evaluateWiggleEase, parseWiggleEase, resolveWiggleEase } from "./wiggleEase";
function countExtrema(samples: number[]): number {
let previousDirection = 0;
let extrema = 0;
for (let index = 1; index < samples.length; index += 1) {
const delta = samples[index]! - samples[index - 1]!;
const direction = Math.sign(delta);
if (direction !== 0 && previousDirection !== 0 && direction !== previousDirection) extrema += 1;
if (direction !== 0) previousDirection = direction;
}
return extrema;
}
function maximumDeviation(
type: "easeOut" | "easeInOut" | "uniform",
from: number,
to: number,
amplitude?: number,
) {
return Math.max(
...Array.from({ length: 101 }, (_, index) => {
const progress = from + ((to - from) * index) / 100;
return Math.abs(evaluateWiggleEase(progress, 6, type, amplitude) - progress);
}),
);
}
describe("wiggle ease", () => {
it("parses positive integer wiggle counts and supported types", () => {
expect(parseWiggleEase(" wiggle(6, easeInOut) ")).toEqual({
wiggles: 6,
type: "easeInOut",
});
expect(parseWiggleEase("wiggle(3,easeInOut,0.12)")).toEqual({
wiggles: 3,
type: "easeInOut",
amplitude: 0.12,
});
expect(parseWiggleEase("wiggle(3,easeInOut,0)")?.amplitude).toBe(0);
expect(parseWiggleEase("wiggle(3,easeInOut,1)")?.amplitude).toBe(1);
expect(parseWiggleEase("wiggle(0,easeOut)")).toBeNull();
expect(parseWiggleEase("wiggle(2.5,uniform)")).toBeNull();
expect(parseWiggleEase("wiggle(3,unknown)")).toBeNull();
expect(parseWiggleEase("wiggle(3,easeInOut,-0.1)")).toBeNull();
expect(parseWiggleEase("wiggle(3,easeInOut,1.1)")).toBeNull();
});
it("is deterministic, endpoint-normalized, and oscillatory", () => {
const progress = Array.from({ length: 401 }, (_, index) => index / 400);
const first = progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut"));
const second = progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut"));
expect(first).toEqual(second);
expect(first[0]).toBe(0);
expect(first.at(-1)).toBe(1);
expect(countExtrema(first)).toBeGreaterThanOrEqual(8);
const explicitFirst = progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut", 0.2));
const explicitSecond = progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut", 0.2));
expect(explicitFirst).toEqual(explicitSecond);
expect(explicitFirst[0]).toBe(0);
expect(explicitFirst.at(-1)).toBe(1);
expect(Number.isNaN(evaluateWiggleEase(Number.NaN, 6, "easeInOut"))).toBe(true);
});
it("uses an explicit amplitude as the peak envelope amplitude", () => {
expect(maximumDeviation("easeInOut", 0, 1, 0.2)).toBeGreaterThan(
maximumDeviation("easeInOut", 0, 1, 0.1),
);
});
it("preserves the per-type defaults when amplitude is omitted", () => {
const progress = [0.125, 0.25, 0.5, 0.75, 0.875];
expect(progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut"))).toEqual(
progress.map((value) => evaluateWiggleEase(value, 6, "easeInOut", 0.08)),
);
expect(progress.map((value) => evaluateWiggleEase(value, 6, "easeOut"))).toEqual(
progress.map((value) => evaluateWiggleEase(value, 6, "easeOut", 0.16)),
);
expect(progress.map((value) => evaluateWiggleEase(value, 6, "uniform"))).toEqual(
progress.map((value) => evaluateWiggleEase(value, 6, "uniform", 0.14)),
);
});
it("applies the CustomWiggle-style amplitude envelopes", () => {
expect(maximumDeviation("easeOut", 0, 0.25)).toBeGreaterThan(
maximumDeviation("easeOut", 0.75, 1),
);
expect(maximumDeviation("easeInOut", 0.375, 0.625)).toBeGreaterThan(
maximumDeviation("easeInOut", 0, 0.125),
);
expect(maximumDeviation("uniform", 0, 0.25)).toBeCloseTo(
maximumDeviation("uniform", 0.75, 1),
6,
);
expect(evaluateWiggleEase(0.025, 5, "anticipate")).toBeLessThan(0);
});
it("caches resolved functions by normalized parameters", () => {
const cache = new Map();
const first = resolveWiggleEase("wiggle(6,easeInOut,0.2)", cache);
const second = resolveWiggleEase(" wiggle(6, easeInOut, 0.20) ", cache);
const differentAmplitude = resolveWiggleEase("wiggle(6,easeInOut,0.1)", cache);
const legacyFirst = resolveWiggleEase("wiggle(6,easeInOut)", cache);
const legacySecond = resolveWiggleEase(" wiggle(6, easeInOut) ", cache);
expect(first).not.toBeNull();
expect(first).toBe(second);
expect(first).not.toBe(differentAmplitude);
expect(legacyFirst).toBe(legacySecond);
expect(resolveWiggleEase("wiggle(6,easeInOut,0.2)", new Map())).not.toBe(first);
});
});
+68
View File
@@ -0,0 +1,68 @@
export type WiggleType = "easeOut" | "easeInOut" | "anticipate" | "uniform";
export type WiggleEaseConfig = {
wiggles: number;
type: WiggleType;
amplitude?: number;
};
type WiggleEase = (progress: number) => number;
const WIGGLE_TOKEN =
/^\s*wiggle\(\s*(\d+)\s*,\s*(easeOut|easeInOut|anticipate|uniform)\s*(?:,\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*)?\)\s*$/;
const TAU = Math.PI * 2;
export function parseWiggleEase(ease: string): WiggleEaseConfig | null {
const match = WIGGLE_TOKEN.exec(ease);
if (!match) return null;
const wiggles = Number(match[1]);
if (!Number.isSafeInteger(wiggles) || wiggles < 1) return null;
const type = match[2];
if (type !== "easeOut" && type !== "easeInOut" && type !== "anticipate" && type !== "uniform") {
return null;
}
if (match[3] === undefined) return { wiggles, type };
const amplitude = Number(match[3]);
if (!Number.isFinite(amplitude) || amplitude < 0 || amplitude > 1) return null;
return { wiggles, type, amplitude };
}
export function evaluateWiggleEase(
progress: number,
wiggles: number,
type: WiggleType,
amplitude?: number,
): number {
if (!Number.isFinite(progress)) return progress;
if (progress <= 0) return 0;
if (progress >= 1) return 1;
const peakAmplitude =
amplitude ??
(type === "easeInOut" ? 0.08 : type === "uniform" ? 0.14 : type === "anticipate" ? 0.12 : 0.16);
const envelope =
type === "easeInOut"
? peakAmplitude * Math.sin(Math.PI * progress)
: type === "uniform"
? peakAmplitude
: peakAmplitude * (1 - progress);
const direction = type === "anticipate" ? -1 : 1;
// Overshoot outside [0, 1] is intentional: wiggle eases model spring-like
// motion while still pinning the exact segment endpoints above.
return progress + direction * envelope * Math.sin(TAU * wiggles * progress);
}
export function resolveWiggleEase(
ease: string,
cache?: Map<string, WiggleEase>,
): WiggleEase | null {
const config = parseWiggleEase(ease);
if (!config) return null;
const key = `${config.wiggles}:${config.type}:${config.amplitude ?? "default"}`;
const cached = cache?.get(key);
if (cached) return cached;
const wiggleEase: WiggleEase = (progress) =>
evaluateWiggleEase(progress, config.wiggles, config.type, config.amplitude);
cache?.set(key, wiggleEase);
return wiggleEase;
}
+5
View File
@@ -75,6 +75,11 @@ declare global {
__HF_PICKER_API?: HyperframePickerApi;
gsap?: {
timeline: (params?: { paused?: boolean }) => RuntimeTimelineLike;
parseEase?: (
ease: string | ((progress: number) => number),
...args: unknown[]
) => ((progress: number) => number) | null;
registerPlugin?: (plugin: unknown) => void;
ticker?: {
tick: () => void;
};
+2 -1
View File
@@ -18,7 +18,8 @@
"src/runtime/mediaVolumeEnvelope.ts",
"src/runtime/positionEdits.ts",
"src/runtime/protocol.ts",
"src/runtime/stackingContext.ts"
"src/runtime/stackingContext.ts",
"src/runtime/wiggleEase.ts"
],
"include": ["src/**/*"],
"exclude": [
+4
View File
@@ -112,13 +112,17 @@ export const SUPPORTED_EASES = [
"bounce.in",
"bounce.out",
"bounce.inOut",
"circ.inOut",
"expo.in",
"expo.out",
"expo.inOut",
"elastic.out(1,0.3)",
"elastic.inOut(1,0.3)",
"spring-gentle",
"spring-bouncy",
"spring-stiff",
"spring-wobbly",
"spring-heavy",
"hold",
"steps(1)",
];
+30
View File
@@ -657,6 +657,27 @@ describe("stagger/yoyo/repeat round-trip", () => {
});
});
describe("object keyframe per-step duration is not an animatable property", () => {
it("excludes `duration` from %-keyed keyframe properties (segment timing must not become a lane)", () => {
// A %-keyed object keyframe carrying a stray per-step `duration` — the shape
// a buggy array->object conversion produces. `duration` is GSAP segment
// timing, not a property; it must never surface as a keyframe lane or get
// round-tripped as an animatable value (which corrupts the tween on edit).
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#card", { keyframes: { "0%": { x: 0, duration: 0 }, "50%": { x: 100, duration: 6, ease: "power2.out" }, "100%": { x: 200, duration: 6 } } }, 0);
`;
const parsed = parseGsapScript(script);
const kfs = parsed.animations[0].keyframes?.keyframes ?? [];
expect(kfs.length).toBe(3);
for (const kf of kfs) {
expect(kf.properties).not.toHaveProperty("duration"); // the fix
expect(kf.properties).toHaveProperty("x"); // real animatable prop preserved
}
expect(kfs[1]?.ease).toBe("power2.out"); // per-keyframe ease still parsed
});
});
describe("unresolvable value round-trip", () => {
it("preserves unresolvable property values through serialize", () => {
const script = `
@@ -1920,6 +1941,15 @@ describe("keyframe mutations", () => {
expect([kf[2]!.properties.x, kf[2]!.properties.y]).toEqual([1040, 0]);
});
it("updateKeyframeInScript — array-form ease-only update preserves existing properties", () => {
const id = getAnimId(ARRAY_KF_SCRIPT);
const updated = updateKeyframeInScript(ARRAY_KF_SCRIPT, id, 33.3, {}, "power2.inOut");
const kf = parseGsapScript(updated).animations[0].keyframes!.keyframes;
expect(kf[1]!.properties.x).toBe(520);
expect(kf[1]!.properties.y).toBe(120);
expect(kf[1]!.ease).toBe("power2.inOut");
});
it("addKeyframeToScript — array-form: normalizes to object form + inserts 50%", () => {
const id = getAnimId(ARRAY_KF_SCRIPT);
const updated = addKeyframeToScript(ARRAY_KF_SCRIPT, id, 50, { x: 780, y: 60 });
+25 -13
View File
@@ -686,6 +686,12 @@ function parsePercentageKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyf
for (const [k, v] of Object.entries(record)) {
if (k === "ease" && typeof v === "string") {
kfEase = v;
} else if (k === "duration") {
// `duration` is array-keyframe segment timing, not an animatable
// property. In a %-keyed object keyframe the % key owns timing, so a
// per-step `duration` is neither timing nor a property — skip it, or
// it surfaces as a bogus keyframe lane and corrupts the round-trip.
continue;
} else if (typeof v === "number" || typeof v === "string") {
properties[k] = v;
}
@@ -2020,6 +2026,18 @@ function buildKeyframeValueNode(
return parseExpr(`{ ${entries.join(", ")} }`);
}
function setObjectExpressionEase(node: AstNode, ease: string): boolean {
if (node?.type !== "ObjectExpression") return false;
const props = (node.properties ?? []) as AstNode[];
const easeIdx = props.findIndex(
(property: AstNode) => isObjectProperty(property) && propKeyName(property) === "ease",
);
const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0];
if (easeIdx >= 0) props[easeIdx] = easeNode;
else props.push(easeNode);
return true;
}
/** Parse + locate a target animation, returning null on failure. */
function locateAnimation(
script: string,
@@ -2473,7 +2491,12 @@ export function updateKeyframeInScript(
}
}
if (matchIdx === -1) return script;
const realIdx = arrVal.elements.indexOf(elements[matchIdx]);
const matchEl = elements[matchIdx];
if (!matchEl) return script;
const realIdx = arrVal.elements.indexOf(matchEl);
if (Object.keys(properties).length === 0 && ease && setObjectExpressionEase(matchEl, ease)) {
return recast.print(arrLoc.parsed.ast).code;
}
arrVal.elements[realIdx] = buildKeyframeValueNode(properties, ease);
return recast.print(arrLoc.parsed.ast).code;
}
@@ -2487,18 +2510,7 @@ export function updateKeyframeInScript(
if (Object.keys(properties).length === 0 && ease) {
// Ease-only update: preserve existing properties, just add/replace ease
const existing = match.prop.value;
if (existing?.type === "ObjectExpression") {
const props = (existing.properties ?? []) as AstNode[];
const easeIdx = props.findIndex(
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === "ease",
);
const easeNode = parseExpr(`({ ease: ${JSON.stringify(ease)} })`).properties[0];
if (easeIdx >= 0) {
props[easeIdx] = easeNode;
} else {
props.push(easeNode);
}
if (setObjectExpressionEase(match.prop.value, ease)) {
return recast.print(loc.parsed.ast).code;
}
// Non-object keyframe value (primitive shorthand, e.g. "50%": "0.5"): there
@@ -84,6 +84,24 @@ const REAL_WORLD_SCRIPT = `(function () {
// ── parseGsapScript ───────────────────────────────────────────────────────────
describe("parseGsapScript", () => {
it("excludes per-step `duration` from %-keyed keyframe properties (segment timing is not a lane)", () => {
// A %-keyed object keyframe carrying a stray per-step `duration` — the shape
// a buggy array->object conversion produces. `duration` is GSAP segment
// timing, not an animatable property; it must not surface as a keyframe lane
// or round-trip as a property (which corrupts the tween on the next edit).
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#card", { keyframes: { "0%": { x: 0, duration: 0 }, "50%": { x: 100, duration: 6, ease: "power2.out" }, "100%": { x: 200, duration: 6 } } }, 0);
`;
const anim = parseGsapScript(script).animations[0]!;
const kfs = expectKeyframesFormat(anim, "percentage", 3);
for (const kf of kfs) {
expect(kf.properties).not.toHaveProperty("duration"); // the fix
expect(kf.properties).toHaveProperty("x"); // real animatable prop preserved
}
expect(kfs[1]!.ease).toBe("power2.out"); // per-keyframe ease still parsed
});
it("parses a basic timeline with .to()", () => {
const script = `
const tl = gsap.timeline({ paused: true });
+8
View File
@@ -991,6 +991,14 @@ function parsePercentageKeyframes(
for (const [k, v] of Object.entries(record)) {
if (k === "ease" && typeof v === "string") {
kfEase = v;
} else if (k === "duration") {
// `duration` is array-keyframe SEGMENT TIMING, not an animatable
// property. In a %-keyed object keyframe the % key owns timing, so a
// per-step `duration` is neither timing nor a property here. Skip it
// (parseObjectArrayKeyframes already does) — otherwise it surfaces as
// a bogus "duration" keyframe lane and gets round-tripped as a
// property, corrupting the tween on the next manual edit.
continue;
} else if (typeof v === "number" || typeof v === "string") {
properties[k] = v;
}
@@ -15,7 +15,9 @@ import {
updateAnimationInScript,
updateKeyframeInScript,
} from "./gsapWriterAcorn.js";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
import { parseGsapScript } from "./gsapParser.js";
import { validateCompositionGsap } from "./gsapSerialize.js";
// ---------------------------------------------------------------------------
// Fixture scripts
@@ -376,6 +378,25 @@ describe("T6c — keyframe write ops", () => {
expect(result.indexOf('"25%"')).toBeLessThan(result.indexOf('"50%"'));
});
it("addKeyframeToScript converts a flat tween and round-trips three sane points", () => {
const script = `\
var tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 420, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const animationId = parseGsapScriptAcorn(script).animations[0]!.id;
const result = addKeyframeToScript(script, animationId, 50, { x: 210 });
const reparsed = parseGsapScriptAcorn(result);
expect(reparsed.animations).toHaveLength(1);
expect(reparsed.animations[0]?.keyframes?.keyframes).toEqual([
{ percentage: 0, properties: { x: 0 } },
{ percentage: 50, properties: { x: 210 } },
{ percentage: 100, properties: { x: 420 } },
]);
expect(validateCompositionGsap(result)).toEqual({ valid: true, errors: [], warnings: [] });
});
it("addKeyframeToScript replaces value when percentage already exists", () => {
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { opacity: 0.99 });
expect(result).toContain("opacity: 0.99");