mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
fix(engine): fail-fast the sub-composition timeline wait when a script 404s
pollSubCompositionTimelines waits for every [data-composition-id] host to
register window.__timelines[id]. When the script carrying that registration
fails to load (404 / request failure), the registration can never arrive —
but the poll still burned the full playerReadyTimeout (45s), then warned and
shipped a silently animation-less render. Wild scale: the capture-setup
histogram over 30 days of local renders decays smoothly (402/503/364/282/191
per 5s bucket) then spikes to 705 at the 45s bucket — ~1,000 renders/month
across 402 distinct users, ~15 user-hours of pure waiting.
- Sessions now record failed SCRIPT resources (requestfailed + HTTP>=400
response, listeners that already existed for diagnostics) in
session.scriptLoadFailures.
- pollSubCompositionTimelines takes a failure getter and cuts the wait to a
2s grace once any script failed, with a loud warning naming the URL(s).
Late-registering fetch-async comps are unaffected: no script failure means
the full timeout still applies, and a registration landing inside the
grace window still wins (tested).
- Outcome telemetry: session.subTimelineWaitOutcome ("ready" | "timeout" |
"script_failure") -> CapturePerfSummary -> RenderPerfSummary.subTimelineWait
(worst across sessions) -> render_complete sub_timeline_wait, so the wild
rate becomes directly trackable instead of setup-histogram forensics.
Validation: the discovery comp (0768f038, its animations.js unreachable)
drops from ~72s to 23.1s total — poll cut at 2.1s with the script named;
healthy comp reports "ready". Canary suite 7/7 (PSNRs identical). 4 new
poll unit tests; engine suite 907 passed (14 failures are PRE-EXISTING on
main at v0.7.42 — 18 fail on a clean checkout, stash A/B verified).
tsc/oxlint/oxfmt clean.
Corpus note: 258/1,762 corpus comps (14%) reference local scripts missing
from the corpus fetch — their historical eval INIT timings measured this
timeout, not the engine. Capture-stage ratios remain valid (both paths paid
it equally).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
230cc5bf7d
commit
54359f3d6a
@@ -95,6 +95,16 @@ export interface CaptureSession {
|
||||
pageReleased?: boolean;
|
||||
browserReleased?: boolean;
|
||||
browserConsoleBuffer: string[];
|
||||
/**
|
||||
* Script resources that failed to load (request failure or HTTP >= 400).
|
||||
* pollSubCompositionTimelines fail-fasts on these: a comp whose timeline
|
||||
* script 404'd can never register window.__timelines[id], so waiting the
|
||||
* full playerReadyTimeout (45s) buys nothing (~1% of wild local renders
|
||||
* were hitting that wall — a 705-render spike at the 45s setup bucket).
|
||||
*/
|
||||
scriptLoadFailures: string[];
|
||||
/** Outcome of the sub-composition timeline wait: ready | timeout | script_failure. */
|
||||
subTimelineWaitOutcome?: "ready" | "timeout" | "script_failure";
|
||||
initTelemetry?: {
|
||||
initDurationMs: number;
|
||||
tweenCount: number;
|
||||
@@ -929,6 +939,7 @@ export async function createCaptureSession(
|
||||
onBeforeCapture,
|
||||
isInitialized: false,
|
||||
browserConsoleBuffer: [],
|
||||
scriptLoadFailures: [],
|
||||
capturePerf: {
|
||||
frames: 0,
|
||||
seekMs: 0,
|
||||
@@ -1001,21 +1012,6 @@ export function formatConsoleDiagnostic(
|
||||
return { text: `${prefix} ${text}`, suppressHostLog: false };
|
||||
}
|
||||
|
||||
async function pollPageExpression(
|
||||
page: Page,
|
||||
expression: string,
|
||||
timeoutMs: number,
|
||||
intervalMs: number = 100,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const ready = Boolean(await page.evaluate(expression));
|
||||
if (ready) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
return Boolean(await page.evaluate(expression));
|
||||
}
|
||||
|
||||
const HF_READY_DIAGNOSTIC_EXPR = `(function() {
|
||||
var hf = window.__hf;
|
||||
var player = window.__player;
|
||||
@@ -1151,11 +1147,19 @@ async function pollHfReady(page: Page, timeoutMs: number, intervalMs: number = 1
|
||||
);
|
||||
}
|
||||
|
||||
async function pollSubCompositionTimelines(
|
||||
export async function pollSubCompositionTimelines(
|
||||
page: Page,
|
||||
timeoutMs: number,
|
||||
intervalMs: number = 150,
|
||||
): Promise<void> {
|
||||
// Fail-fast hook: when a SCRIPT resource failed to load (404 / request
|
||||
// failure), the timeline registration it carried can never arrive — the
|
||||
// full-timeout wait buys nothing (measured: a 705-render spike at the 45s
|
||||
// setup bucket in 30 days of wild local renders, ~1% of renders, each also
|
||||
// shipping silently-broken animations). Once failures are present the poll
|
||||
// is cut to `scriptFailureGraceMs` from its start.
|
||||
getScriptLoadFailures?: () => readonly string[],
|
||||
scriptFailureGraceMs: number = 2_000,
|
||||
): Promise<"ready" | "timeout" | "script_failure"> {
|
||||
// Hosts may opt out of the timeline wait with `data-no-timeline` —
|
||||
// compositions driven purely by CSS animations / rAF (the render-compat
|
||||
// contract) never register window.__timelines[id], and without the opt-out
|
||||
@@ -1172,7 +1176,28 @@ async function pollSubCompositionTimelines(
|
||||
}
|
||||
return true;
|
||||
})()`;
|
||||
const ready = await pollPageExpression(page, expression, timeoutMs, intervalMs);
|
||||
const start = Date.now();
|
||||
const deadline = start + timeoutMs;
|
||||
let ready = false;
|
||||
let scriptFailureBail = false;
|
||||
for (;;) {
|
||||
ready = Boolean(await page.evaluate(expression));
|
||||
if (ready) break;
|
||||
const now = Date.now();
|
||||
if (now >= deadline) break;
|
||||
const failures = getScriptLoadFailures?.() ?? [];
|
||||
if (failures.length > 0 && now - start >= scriptFailureGraceMs) {
|
||||
scriptFailureBail = true;
|
||||
console.warn(
|
||||
`[FrameCapture] Sub-composition timeline wait cut short after ${now - start}ms: ` +
|
||||
`script resource(s) failed to load (${failures.join(", ")}) — ` +
|
||||
`the timeline registration they carry can never arrive. ` +
|
||||
`Fix the script reference; the render proceeds without those animations.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
// Always force a timeline rebind once sub-composition timelines are
|
||||
// confirmed present. The previous implementation only called rebind
|
||||
// when the timeline count grew during the poll, which missed the case
|
||||
@@ -1186,8 +1211,9 @@ async function pollSubCompositionTimelines(
|
||||
window.__hfForceTimelineRebind();
|
||||
}
|
||||
})()`);
|
||||
return "ready";
|
||||
}
|
||||
if (!ready) {
|
||||
if (!scriptFailureBail) {
|
||||
const missing = await page.evaluate(`(function() {
|
||||
var hosts = document.querySelectorAll("[data-composition-id]");
|
||||
var timelines = window.__timelines || {};
|
||||
@@ -1205,6 +1231,7 @@ async function pollSubCompositionTimelines(
|
||||
`Compositions intentionally driven without GSAP timelines (CSS animations / rAF) can mark the host with data-no-timeline to skip this wait.`,
|
||||
);
|
||||
}
|
||||
return scriptFailureBail ? "script_failure" : "timeout";
|
||||
}
|
||||
|
||||
async function pollVideosReady(
|
||||
@@ -1411,6 +1438,9 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
});
|
||||
|
||||
page.on("requestfailed", (request) => {
|
||||
if (request.resourceType() === "script") {
|
||||
session.scriptLoadFailures.push(request.url());
|
||||
}
|
||||
appendBrowserDiagnostic(
|
||||
session,
|
||||
formatRequestFailureDiagnostic({
|
||||
@@ -1427,6 +1457,9 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
if (status < 400) return;
|
||||
|
||||
const request = response.request();
|
||||
if (request.resourceType() === "script") {
|
||||
session.scriptLoadFailures.push(response.url());
|
||||
}
|
||||
appendBrowserDiagnostic(
|
||||
session,
|
||||
formatHttpErrorDiagnostic({
|
||||
@@ -1491,8 +1524,13 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
await pollHfReady(page, pageReadyTimeout);
|
||||
logInitPhase("pollHfReady complete");
|
||||
|
||||
await pollSubCompositionTimelines(page, pageReadyTimeout);
|
||||
logInitPhase("pollSubCompositionTimelines complete");
|
||||
session.subTimelineWaitOutcome = await pollSubCompositionTimelines(
|
||||
page,
|
||||
pageReadyTimeout,
|
||||
undefined,
|
||||
() => session.scriptLoadFailures,
|
||||
);
|
||||
logInitPhase(`pollSubCompositionTimelines complete (${session.subTimelineWaitOutcome})`);
|
||||
|
||||
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
|
||||
logInitPhase("applyVideoMetadataHints complete");
|
||||
@@ -1626,8 +1664,13 @@ export async function initializeSession(session: CaptureSession): Promise<void>
|
||||
throw err;
|
||||
}
|
||||
|
||||
await pollSubCompositionTimelines(page, pageReadyTimeout);
|
||||
logInitPhase("pollSubCompositionTimelines complete");
|
||||
session.subTimelineWaitOutcome = await pollSubCompositionTimelines(
|
||||
page,
|
||||
pageReadyTimeout,
|
||||
undefined,
|
||||
() => session.scriptLoadFailures,
|
||||
);
|
||||
logInitPhase(`pollSubCompositionTimelines complete (${session.subTimelineWaitOutcome})`);
|
||||
|
||||
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
|
||||
logInitPhase("applyVideoMetadataHints complete");
|
||||
@@ -3086,6 +3129,7 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma
|
||||
avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
|
||||
avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
|
||||
p50TotalMs: medianOf(session.capturePerf.frameMs),
|
||||
subTimelineWaitOutcome: session.subTimelineWaitOutcome,
|
||||
staticDedupReused: session.staticDedupCount ?? 0,
|
||||
staticDedupEnabled: session.staticDedupEnabled ?? false,
|
||||
// armed ⟺ a non-empty static set survived verification; predicted === its size.
|
||||
|
||||
Reference in New Issue
Block a user