fix(engine): fast-fail on zero duration instead of 45s timeout (#1186)

* fix(engine): fast-fail on zero duration instead of 45s timeout

When a composition's runtime finishes initializing but reports zero
duration (no GSAP timeline and no data-duration attribute), the engine
previously polled for the full 45-second timeout before failing.

Now, after 10 seconds of polling, the engine checks whether the runtime
has finished (window.__renderReady === true) with a working seek
function but zero duration. If so, it fails immediately with a
diagnostic message explaining what's wrong and how to fix it.

This also improves the generic timeout error message to include runtime
state (whether __player exists, __hf.seek, GSAP timelines, declared
duration) so users can self-diagnose.

PostHog data: 555-1,234 occurrences/day, each wasting 45s of user time.

* fix(engine): throttle diagnostic polls and tighten zero-duration fast-fail

Two nit fixes in pollHfReady:

1. Throttle evaluateHfDiagnostic calls to once per ~1000ms after the 10s
   mark. Previously called on every 100ms loop tick, generating ~350 CDP
   round-trips per failed render. One check per second is sufficient to
   detect a permanently-zero composition.

2. Change fast-fail condition from 'duration === 0' to
   '!hasTimeline && declaredDuration <= 0'. A composition with a GSAP
   timeline but no data-duration attribute should not be fast-failed —
   GSAP sets duration synchronously before __renderReady via __timelines,
   so a non-empty __timelines is a reliable signal that duration will
   eventually be non-zero. Only compositions with NEITHER a GSAP timeline
   NOR a declared duration are permanently zero.
This commit is contained in:
Miguel Ángel
2026-06-03 23:06:09 -04:00
committed by GitHub
parent 8c6faa45b5
commit efb8d90f72
+128 -25
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file complexity
/** /**
* Frame Capture Service * Frame Capture Service
* *
@@ -172,6 +173,7 @@ async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boole
return !timedOut; return !timedOut;
} }
// fallow-ignore-next-line unit-size
export async function createCaptureSession( export async function createCaptureSession(
serverUrl: string, serverUrl: string,
outputDir: string, outputDir: string,
@@ -349,6 +351,124 @@ async function pollPageExpression(
return Boolean(await page.evaluate(expression)); return Boolean(await page.evaluate(expression));
} }
const HF_READY_DIAGNOSTIC_EXPR = `(function() {
var hf = window.__hf;
var player = window.__player;
var renderReady = !!window.__renderReady;
var hasSeek = !!(hf && typeof hf.seek === "function");
var duration = hf ? hf.duration : -1;
var hasTimeline = !!(window.__timelines && Object.keys(window.__timelines).length > 0);
var root = document.querySelector("[data-composition-id]");
var declaredDuration = root ? Number(root.getAttribute("data-duration")) : -1;
return {
renderReady: renderReady,
hasHf: !!hf,
hasSeek: hasSeek,
hasPlayer: !!player,
duration: duration,
hasTimeline: hasTimeline,
declaredDuration: declaredDuration,
};
})()`;
// fallow-ignore-next-line complexity
function buildZeroDurationDiagnostic(diag: {
renderReady: boolean;
hasHf: boolean;
hasSeek: boolean;
hasPlayer: boolean;
duration: number;
hasTimeline: boolean;
declaredDuration: number;
}): string {
const hints: string[] = [];
if (!diag.hasPlayer) {
hints.push("window.__player was never set — the HyperFrames runtime did not initialize.");
}
if (!diag.hasTimeline) {
hints.push(
"No GSAP timeline registered (window.__timelines is empty). " +
"If using CSS/WAAPI/Lottie/Three.js animations, add data-duration to the root element.",
);
}
if (diag.declaredDuration <= 0 && !diag.hasTimeline) {
hints.push(
'Fix: add data-duration="<seconds>" to your root <div data-composition-id="..."> element.',
);
}
if (diag.hasSeek && diag.duration === 0 && diag.renderReady) {
hints.push("The runtime finished initializing but reported zero duration — this is permanent.");
}
return (
`[FrameCapture] Composition has zero duration.\n` +
` Runtime ready: ${diag.renderReady}, __player: ${diag.hasPlayer}, ` +
`__hf.seek: ${diag.hasSeek}, GSAP timeline: ${diag.hasTimeline}, ` +
`data-duration: ${diag.declaredDuration > 0 ? diag.declaredDuration + "s" : "not set"}\n` +
(hints.length > 0 ? hints.map((h) => `${h}`).join("\n") : "")
);
}
interface HfDiagnostic {
renderReady: boolean;
hasHf: boolean;
hasSeek: boolean;
hasPlayer: boolean;
duration: number;
hasTimeline: boolean;
declaredDuration: number;
}
async function evaluateHfDiagnostic(page: Page): Promise<HfDiagnostic> {
return (await page.evaluate(HF_READY_DIAGNOSTIC_EXPR)) as HfDiagnostic;
}
async function pollHfReady(page: Page, timeoutMs: number, intervalMs: number = 100): Promise<void> {
const readyExpr = `!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`;
const FAST_FAIL_AFTER_MS = 10_000;
// Throttle diagnostic CDP calls to ~1000ms — running evaluateHfDiagnostic on
// every 100ms poll tick after the 10s mark generates ~350 unnecessary CDP
// round-trips per failed render. One diagnostic per second is enough.
const DIAGNOSTIC_INTERVAL_MS = 1_000;
const deadline = Date.now() + timeoutMs;
let lastDiagnosticAt = 0;
while (Date.now() < deadline) {
const ready = Boolean(await page.evaluate(readyExpr));
if (ready) return;
const elapsed = timeoutMs - (deadline - Date.now());
if (elapsed >= FAST_FAIL_AFTER_MS) {
const now = Date.now();
if (now - lastDiagnosticAt >= DIAGNOSTIC_INTERVAL_MS) {
lastDiagnosticAt = now;
const diag = await evaluateHfDiagnostic(page);
// Only fast-fail when BOTH signals are permanently zero:
// 1. No GSAP timeline registered (GSAP sets duration synchronously
// before __renderReady, so a missing timeline won't self-correct).
// 2. No data-duration declared on the root element.
// A composition with a GSAP timeline but no data-duration is still
// valid — GSAP drives duration via __timelines, not data-duration.
if (diag.renderReady && diag.hasSeek && !diag.hasTimeline && diag.declaredDuration <= 0) {
throw new Error(buildZeroDurationDiagnostic(diag));
}
}
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
const diag = await evaluateHfDiagnostic(page);
if (diag.hasSeek && diag.duration === 0) {
throw new Error(buildZeroDurationDiagnostic(diag));
}
throw new Error(
`[FrameCapture] window.__hf not ready after ${timeoutMs}ms. ` +
`Page must expose window.__hf = { duration, seek }.\n` +
` State: __hf=${diag.hasHf}, seek=${diag.hasSeek}, player=${diag.hasPlayer}, ` +
`renderReady=${diag.renderReady}, duration=${diag.duration}`,
);
}
async function pollSubCompositionTimelines( async function pollSubCompositionTimelines(
page: Page, page: Page,
timeoutMs: number, timeoutMs: number,
@@ -436,6 +556,7 @@ async function applyVideoMetadataHints(
): Promise<void> { ): Promise<void> {
if (!hints || hints.length === 0) return; if (!hints || hints.length === 0) return;
// fallow-ignore-next-line complexity
await page.evaluate( await page.evaluate(
(metadataHints: CaptureVideoMetadataHint[]) => { (metadataHints: CaptureVideoMetadataHint[]) => {
for (const hint of metadataHints) { for (const hint of metadataHints) {
@@ -488,10 +609,12 @@ async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Prom
} }
} }
// fallow-ignore-next-line unit-size
export async function initializeSession(session: CaptureSession): Promise<void> { export async function initializeSession(session: CaptureSession): Promise<void> {
const { page, serverUrl } = session; const { page, serverUrl } = session;
// Forward browser console to host with [Browser] prefix // Forward browser console to host with [Browser] prefix
// fallow-ignore-next-line complexity
page.on("console", (msg: ConsoleMessage) => { page.on("console", (msg: ConsoleMessage) => {
const type = msg.type(); const type = msg.type();
const text = msg.text(); const text = msg.text();
@@ -546,16 +669,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
const pageReadyTimeout = const pageReadyTimeout =
session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout; session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
const pageReady = await pollPageExpression( await pollHfReady(page, pageReadyTimeout);
page,
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
pageReadyTimeout,
);
if (!pageReady) {
throw new Error(
`[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`,
);
}
await pollSubCompositionTimelines(page, pageReadyTimeout); await pollSubCompositionTimelines(page, pageReadyTimeout);
@@ -666,22 +780,11 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// Poll for window.__hf readiness using manual evaluate loop (waitForFunction // Poll for window.__hf readiness using manual evaluate loop (waitForFunction
// uses rAF polling internally, which won't fire in beginFrame mode). // uses rAF polling internally, which won't fire in beginFrame mode).
const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout; const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
const pollDeadline = Date.now() + pageReadyTimeout; try {
while (Date.now() < pollDeadline) { await pollHfReady(page, pageReadyTimeout);
const ready = await page.evaluate( } catch (err) {
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
);
if (ready) break;
await new Promise((r) => setTimeout(r, 100));
}
const pageReady = await page.evaluate(
`!!(window.__hf && typeof window.__hf.seek === "function" && window.__hf.duration > 0)`,
);
if (!pageReady) {
warmupState.running = false; warmupState.running = false;
throw new Error( throw err;
`[FrameCapture] window.__hf not ready after ${pageReadyTimeout}ms. Page must expose window.__hf = { duration, seek }.`,
);
} }
await pollSubCompositionTimelines(page, pageReadyTimeout); await pollSubCompositionTimelines(page, pageReadyTimeout);