mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
* fix: batch GSAP timeline construction to prevent main-thread hang (#1231) Compositions with thousands of tl.to() calls (e.g. 8,562 in the reported case) block Chrome's main thread synchronously during HTML parsing, preventing DOMContentLoaded from firing before Puppeteer's navigation timeout. This caused render jobs to hang indefinitely at 'Initializing calibration session...' with no error message. Root cause: GSAP's timeline API is synchronous — each tl.to() call registers a tween immediately on the main thread. A script with 8k+ calls holds the thread for seconds, starving the browser event loop and delaying DCL past the navigation timeout window. Fix: install a property trap on window.gsap in HF_EARLY_STUB (injected at the top of <head>, before GSAP or user scripts load). When GSAP assigns itself to window.gsap, the setter intercepts the real gsap object and wraps gsap.timeline() to return a proxy that queues tween descriptors (to/from/fromTo/set) instead of calling them synchronously. A requestAnimationFrame-based flush loop drains 100 tweens per frame, yielding the main thread between batches so DCL can fire. When the queue is drained, the stub sets window.__hfTimelinesBuilding = false and dispatches a 'hf-timelines-built' CustomEvent. init.ts checks this flag at DOMContentLoaded time; if building is still in progress it defers bindRootTimelineIfAvailable() until the event fires, then sets window.__renderReady = true as normal. pollHfReady continues to gate on both __renderReady and window.__hf.duration > 0, so the render pipeline does not start until the full timeline is bound. - Batch size: 100 tweens/rAF tick (empirical; ~4ms/batch at 8k scale) - Yield mechanism: requestAnimationFrame (cooperative, no setTimeout(0)) - Determinism: 'hf-timelines-built' event guarantees sequencing - Proxy forwards: pause/seek/totalTime/time/duration/add/paused/ timeScale/play delegate to the real timeline immediately - No GSAP package changes; no navigation timeout increase Fixes #1231 * style: apply oxfmt formatting to producer stub files * fix(producer): unwrap proxy children in add(), gate setter return on args.length Addresses two latent correctness concerns from code review: 1. proxy.add() now unwraps __hfReal from any proxy child before passing it to the real timeline. GSAP's internal tween graph (_first/_next/_prev linkage) requires real timeline instances — proxy objects lack internal fields like _dp that GSAP's iteration paths expect. 2. totalTime/time/paused/timeScale now return proxy when called in setter form (args.length > 0). Previously these returned the real timeline, causing callers who chain .to(...) after a setter call to bypass batching. Also: build-hf-early-stub.ts now runs oxfmt on the generated output file so the format check passes in CI on every build. * fix(producer): gate __hf.duration=0 while GSAP timelines are batching The HF_BRIDGE_SCRIPT duration getter now returns 0 whenever window.__hfTimelinesBuilding is true (set by HF_EARLY_STUB while the rAF batch loop is draining queued tl.to() calls). pollHfReady in the engine polls until window.__hf.duration > 0, so returning 0 keeps the engine waiting until the hf-timelines-built event fires and all tweens are committed to the real GSAP timelines. Without this gate, normal compositions (style-6, style-13, vignelli) were being captured mid-batch — the real timelines were empty so GSAP could not seek them, producing frozen/blank frames in the output video. * fix(producer): flush GSAP batching under virtual time * fix(producer): gate render bridge on runtime readiness * fix(producer): preserve timeline child binding under batching
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { initSandboxRuntimeModular } from "./init";
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
@@ -92,6 +93,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
delete window.__player;
|
||||
delete window.__playerReady;
|
||||
delete window.__renderReady;
|
||||
delete window.__hfTimelinesBuilding;
|
||||
vi.restoreAllMocks();
|
||||
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||
@@ -676,6 +678,37 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(window.__player).toBeDefined();
|
||||
});
|
||||
|
||||
it("waits for GSAP batching to finish before publishing render readiness", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
let timelineDuration = 0;
|
||||
const timeline = createMockTimeline(0);
|
||||
timeline.duration = () => timelineDuration;
|
||||
window.__timelines = {
|
||||
main: timeline,
|
||||
};
|
||||
window.__hfTimelinesBuilding = true;
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
expect(window.__playerReady).toBe(true);
|
||||
expect(window.__renderReady).toBe(false);
|
||||
expect(window.__player?.getDuration()).toBe(0);
|
||||
|
||||
timelineDuration = 10;
|
||||
window.__hfTimelinesBuilding = false;
|
||||
window.dispatchEvent(new CustomEvent("hf-timelines-built"));
|
||||
|
||||
expect(window.__renderReady).toBe(true);
|
||||
expect(window.__player?.getDuration()).toBe(10);
|
||||
});
|
||||
|
||||
it("sets __renderReady even without a GSAP timeline (CSS/WAAPI compositions)", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication complexity
|
||||
import { installRuntimeControlBridge, postRuntimeMessage } from "./bridge";
|
||||
import { initRuntimeAnalytics, emitAnalyticsEvent } from "./analytics";
|
||||
import { createCssAdapter } from "./adapters/css";
|
||||
@@ -1515,6 +1516,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
};
|
||||
|
||||
let maybePublishRenderReady = () => {
|
||||
window.__renderReady = false;
|
||||
};
|
||||
|
||||
if (!externalCompositionsReady) {
|
||||
const compositionLoaderParams = {
|
||||
injectedStyles: state.injectedCompStyles,
|
||||
@@ -1539,14 +1544,10 @@ export function initSandboxRuntimeModular(): void {
|
||||
.then(() => loadInlineTemplateCompositions(compositionLoaderParams))
|
||||
.finally(() => {
|
||||
externalCompositionsReady = true;
|
||||
bindRootTimelineIfAvailable();
|
||||
window.__renderReady = true;
|
||||
bindMediaMetadataListeners();
|
||||
runAdapters("discover", state.currentTime);
|
||||
installAssetFailureDiagnostics();
|
||||
applyCaptionOverrides();
|
||||
postTimeline();
|
||||
postState(true);
|
||||
maybePublishRenderReady();
|
||||
});
|
||||
} else {
|
||||
// No external/inline compositions to load — apply caption overrides immediately
|
||||
@@ -1706,34 +1707,6 @@ export function initSandboxRuntimeModular(): void {
|
||||
onDisablePickMode: () => picker.disablePickMode(),
|
||||
});
|
||||
|
||||
bindRootTimelineIfAvailable();
|
||||
if (state.capturedTimeline) {
|
||||
player._timeline = state.capturedTimeline;
|
||||
}
|
||||
|
||||
// __renderReady = timeline binding attempted, safe for deterministic seeking.
|
||||
// Set unconditionally: renderSeek works with or without a GSAP timeline
|
||||
// (CSS/WAAPI/Lottie compositions use adapter-only seeking).
|
||||
// fileServer.ts sets this immediately (no timeline to bind in its runtime).
|
||||
window.__renderReady = true;
|
||||
|
||||
// When the bundler inlines compositions, data-composition-src is removed so
|
||||
// loadExternalCompositions() is skipped. But inline scripts registering child
|
||||
// timelines in __timelines haven't executed yet (they run in the browser's next
|
||||
// microtask). Defer a rebinding attempt to catch them.
|
||||
if (externalCompositionsReady) {
|
||||
setTimeout(() => {
|
||||
const prevTimeline = state.capturedTimeline;
|
||||
if (bindRootTimelineIfAvailable() && state.capturedTimeline !== prevTimeline) {
|
||||
player._timeline = state.capturedTimeline;
|
||||
}
|
||||
runAdapters("discover", state.currentTime);
|
||||
window.__renderReady = true;
|
||||
postTimeline();
|
||||
postState(true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
state.deterministicAdapters = [
|
||||
createWaapiAdapter(),
|
||||
createCssAdapter({
|
||||
@@ -1761,6 +1734,61 @@ export function initSandboxRuntimeModular(): void {
|
||||
void webAudio.init().then((ok) => {
|
||||
webAudioReady = ok;
|
||||
});
|
||||
|
||||
const publishRenderReadyAfterTimelineBinding = () => {
|
||||
const prevTimeline = state.capturedTimeline;
|
||||
const rebound = bindRootTimelineIfAvailable();
|
||||
if (
|
||||
state.capturedTimeline &&
|
||||
(rebound || state.capturedTimeline !== prevTimeline || !player._timeline)
|
||||
) {
|
||||
player._timeline = state.capturedTimeline;
|
||||
}
|
||||
const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0);
|
||||
if (boundDuration > 0) {
|
||||
clock.setDuration(boundDuration);
|
||||
}
|
||||
runAdapters("discover", state.currentTime);
|
||||
// __renderReady = timeline binding attempted, safe for deterministic seeking.
|
||||
// Set after any GSAP batching has completed. renderSeek works with or
|
||||
// without a GSAP timeline (CSS/WAAPI/Lottie compositions use adapters only).
|
||||
window.__renderReady = true;
|
||||
postTimeline();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
maybePublishRenderReady = () => {
|
||||
if (!externalCompositionsReady || window.__hfTimelinesBuilding) {
|
||||
window.__renderReady = false;
|
||||
return;
|
||||
}
|
||||
publishRenderReadyAfterTimelineBinding();
|
||||
};
|
||||
|
||||
// When the GSAP tween-batching interceptor (HF_EARLY_STUB, fileServer.ts) is
|
||||
// active, composition scripts queue tl.to() calls instead of executing them
|
||||
// synchronously. Wait for the "hf-timelines-built" event before the first
|
||||
// binding attempt so the transport clock receives the finished timeline
|
||||
// duration instead of permanently publishing duration=0.
|
||||
if (window.__hfTimelinesBuilding) {
|
||||
window.__renderReady = false;
|
||||
const onTimelinesBuilt = () => {
|
||||
window.removeEventListener("hf-timelines-built", onTimelinesBuilt);
|
||||
maybePublishRenderReady();
|
||||
};
|
||||
window.addEventListener("hf-timelines-built", onTimelinesBuilt);
|
||||
}
|
||||
maybePublishRenderReady();
|
||||
|
||||
// When the bundler inlines compositions, data-composition-src is removed so
|
||||
// loadExternalCompositions() is skipped. But inline scripts registering child
|
||||
// timelines in __timelines haven't executed yet (they run in the browser's next
|
||||
// microtask). Defer a rebinding attempt to catch them.
|
||||
if (externalCompositionsReady) {
|
||||
setTimeout(() => {
|
||||
maybePublishRenderReady();
|
||||
}, 0);
|
||||
}
|
||||
let transportTickCount = 0;
|
||||
let inTransportTick = false;
|
||||
|
||||
|
||||
+11
@@ -105,6 +105,17 @@ declare global {
|
||||
* resolved values for the instance currently executing.
|
||||
*/
|
||||
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
|
||||
/**
|
||||
* Set to `true` while the GSAP tween-batching interceptor (injected via
|
||||
* HF_EARLY_STUB in fileServer.ts) is still draining queued tween calls
|
||||
* through requestAnimationFrame batches. Cleared and the "hf-timelines-built"
|
||||
* CustomEvent is dispatched when all queues are empty.
|
||||
*
|
||||
* init.ts uses this to decide whether to defer `bindRootTimelineIfAvailable`:
|
||||
* if true at DOMContentLoaded time, it adds a one-shot event listener and
|
||||
* rebinds after the event fires.
|
||||
*/
|
||||
__hfTimelinesBuilding?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user