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
+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: