diff --git a/packages/core/package.json b/packages/core/package.json index 263c5b551d..300f6eab14 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -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" diff --git a/packages/core/src/figma/motionEase.test.ts b/packages/core/src/figma/motionEase.test.ts index 0ca0d75eae..727984a34c 100644 --- a/packages/core/src/figma/motionEase.test.ts +++ b/packages/core/src/figma/motionEase.test.ts @@ -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" }); diff --git a/packages/core/src/figma/motionEase.ts b/packages/core/src/figma/motionEase.ts index 5f52d6d1bd..1079487a16 100644 --- a/packages/core/src/figma/motionEase.ts +++ b/packages/core/src/figma/motionEase.ts @@ -28,7 +28,7 @@ const NAMED_EASE: Record = { elasticinout: "elastic.inOut", anticipate: "back.in", spring: "elastic.out", - hold: "steps(1)", + hold: "hold", }; function isBezier4(ease: unknown[]): ease is [number, number, number, number] { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c39d474fe..94efee91a7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -95,7 +95,6 @@ export { keyframesToGsapAnimations, gsapAnimationsToKeyframes, } from "@hyperframes/parsers"; - export type { ParsedHtml, CompositionMetadata } from "@hyperframes/parsers"; export { diff --git a/packages/core/src/parsers/springEase.test.ts b/packages/core/src/parsers/springEase.test.ts new file mode 100644 index 0000000000..909f74425f --- /dev/null +++ b/packages/core/src/parsers/springEase.test.ts @@ -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)); + }); +}); diff --git a/packages/core/src/parsers/springEase.ts b/packages/core/src/parsers/springEase.ts index b7ae233b85..137d3a3ec7 100644 --- a/packages/core/src/parsers/springEase.ts +++ b/packages/core/src/parsers/springEase.ts @@ -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; +} diff --git a/packages/core/src/runtime/customEase.test.ts b/packages/core/src/runtime/customEase.test.ts new file mode 100644 index 0000000000..705d32502d --- /dev/null +++ b/packages/core/src/runtime/customEase.test.ts @@ -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 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); + }); +}); diff --git a/packages/core/src/runtime/customEase.ts b/packages/core/src/runtime/customEase.ts new file mode 100644 index 0000000000..25ae0816ff --- /dev/null +++ b/packages/core/src/runtime/customEase.ts @@ -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, +): 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, +): 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(); + const springEaseCache = new Map(); + + // 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; +} diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index c83fa4d30b..99e235b985 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -114,11 +114,184 @@ 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 as { __hfCustomEaseRegistered?: boolean }).__hfCustomEaseRegistered; + 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("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); + + delete (window as { __hfCustomEaseRegistered?: boolean }).__hfCustomEaseRegistered; + 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"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c6f6060124..da4e18c7e2 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -49,6 +49,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"; @@ -173,7 +174,18 @@ export function initSandboxRuntimeModular(): void { // a stray warning is preferable to a broken runtime } }; + const ensureStudioCustomEase = (): void => { + const g = window.gsap; + const w = window as Window & { __hfCustomEaseRegistered?: boolean }; + if (!g || w.__hfCustomEaseRegistered) return; + try { + if (installStudioCustomEase(g)) w.__hfCustomEaseRegistered = true; + } catch { + // 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 @@ -1204,8 +1216,44 @@ 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 `