From 3482d163b2d76ff779de62084b5a89a28a0b6bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 18 May 2026 00:01:54 -0400 Subject: [PATCH] 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. --- packages/engine/src/services/frameCapture.ts | 39 ++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index e945063a2..313e097a9 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -349,6 +349,41 @@ async function pollPageExpression( return Boolean(await page.evaluate(expression)); } +async function pollSubCompositionTimelines( + page: Page, + timeoutMs: number, + intervalMs: number = 150, +): Promise { + 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 ); } + 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 ); } + await pollSubCompositionTimelines(page, pageReadyTimeout); + await applyVideoMetadataHints(page, session.options.videoMetadataHints); // Same readyState contract as the screenshot path above (>= 2 / HAVE_CURRENT_DATA).