fix(engine): poll for sub-composition timeline readiness before capture

The renderer now waits for all sub-composition timelines to be
registered in window.__timelines before starting frame capture.
Previously only window.__hf root readiness was checked, causing
blank frames when sub-compositions use async data loading (fetch)
or when the headless renderer starts capturing before scripts
complete.

Adds pollSubCompositionTimelines() to both screenshot and
beginFrame render paths, with a diagnostic warning listing which
composition IDs are missing if the timeout expires.
This commit is contained in:
Miguel Ángel
2026-05-18 00:01:54 -04:00
parent ae1716d4c1
commit 3482d163b2
@@ -349,6 +349,41 @@ async function pollPageExpression(
return Boolean(await page.evaluate(expression));
}
async function pollSubCompositionTimelines(
page: Page,
timeoutMs: number,
intervalMs: number = 150,
): Promise<void> {
const expression = `(function() {
var hosts = document.querySelectorAll("[data-composition-id]");
if (hosts.length <= 1) return true;
var timelines = window.__timelines || {};
for (var i = 0; i < hosts.length; i++) {
var id = hosts[i].getAttribute("data-composition-id");
if (!id) continue;
if (!timelines[id]) return false;
}
return true;
})()`;
const ready = await pollPageExpression(page, expression, timeoutMs, intervalMs);
if (!ready) {
const missing = await page.evaluate(`(function() {
var hosts = document.querySelectorAll("[data-composition-id]");
var timelines = window.__timelines || {};
var m = [];
for (var i = 0; i < hosts.length; i++) {
var id = hosts[i].getAttribute("data-composition-id");
if (id && !timelines[id]) m.push(id);
}
return m.join(", ");
})()`);
console.warn(
`[FrameCapture] Sub-composition timelines not registered after ${timeoutMs}ms: ${missing}. ` +
`Compositions that load data asynchronously (e.g. fetch) must register window.__timelines[id] after setup completes.`,
);
}
}
async function pollVideosReady(
page: Page,
skipIds: readonly string[],
@@ -499,6 +534,8 @@ export async function initializeSession(session: CaptureSession): Promise<void>
);
}
await pollSubCompositionTimelines(page, pageReadyTimeout);
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
// Wait for all video elements to have decoded their CURRENT frame, not
@@ -615,6 +652,8 @@ export async function initializeSession(session: CaptureSession): Promise<void>
);
}
await pollSubCompositionTimelines(page, pageReadyTimeout);
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
// Same readyState contract as the screenshot path above (>= 2 / HAVE_CURRENT_DATA).