diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index b52113532..d017ea920 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -25,6 +25,7 @@ import { } from "./browserManager.js"; import { beginFrameCapture, + ensureRenderFrameSiblings, getCdpSession, pageScreenshotCapture, initTransparentBackground, @@ -1518,6 +1519,7 @@ export async function initializeSession(session: CaptureSession): Promise await initDrawElementOrTransparentBackground(session, page, logInitPhase); await armStaticDedup(session, session.page, logInitPhase); + await ensureRenderFrameSiblings(session.page); session.isInitialized = true; return; } @@ -1648,13 +1650,16 @@ export async function initializeSession(session: CaptureSession): Promise await recordSessionInitTelemetry(session, initStart); - // Stop warmup. Unlocked mode exits on this flag; locked mode keeps ticking - // until LOCKED_WARMUP_TICKS, so we await its promise to ensure the count is - // exact before deriving the baseline. + // Stop warmup, then drain the loop in BOTH modes before any further + // BeginFrame on this session (drawElement init, the render-frame commit tick + // at the end of init, and frame 0). Clearing the flag only stops new + // iterations — a warmup `HeadlessExperimental.beginFrame` may still be in + // flight, and a second beginFrame on the same session while one is pending + // fails with "Another frame is pending". Locked mode additionally needs the + // await to reach the exact LOCKED_WARMUP_TICKS count before deriving the + // baseline below. warmupState.running = false; - if (lockWarmupTicks) { - await warmupLoopPromise.catch(() => {}); - } + await warmupLoopPromise.catch(() => {}); // Set base frame time ticks past warmup range. Locked mode pins to the // constant so chunk workers on different hosts compute the same baseline. @@ -1672,6 +1677,37 @@ export async function initializeSession(session: CaptureSession): Promise await initDrawElementOrTransparentBackground(session, page, logInitPhase); await armStaticDedup(session, session.page, logInitPhase); + + // Pre-create the hidden `__render_frame__` siblings so the first per-session + // `injectVideoFramesBatch` takes the `hasImg = true` (src-update) path instead + // of inserting a fresh `` mid-capture. Then drive ONE non-capture, + // *visual* BeginFrame (`noDisplayUpdates: false`) to actually composite the + // new layers before the first real capture. The warmup ticks above are + // `noDisplayUpdates: true` (they advance the clock but don't paint), and the + // seek in `prepareFrameForCapture` doesn't tick, so without this commit tick + // the first *display-producing* BeginFrame would be the capture itself — and + // the freshly-inserted layers would miss it (the per-session worker-boundary + // near-black flash). The tick time sits in the gap between the last warmup + // tick and frame 0 (`beginFrameTimeTicks` carries +10 intervals of headroom), + // so ticks stay monotonic and no render frame is consumed. + // + // It must land BELOW the producer's BeginFrame liveness probe, which fires + // from probeStage right after init at `beginFrameTimeTicks - 5·interval` + // (screenshotService.probeBeginFrameLiveness). This commit tick is sent during + // init — temporally before the probe — so if its tick were above the probe's, + // the probe would run backwards in BeginFrame time (non-monotonic) and + // chrome-headless-shell stalls it indefinitely (surfaced as a "SwiftShader + // heavy-layer" probe timeout, then routed to screenshot capture). At the + // original `-1·interval` that stalled every comp reaching the probe; at + // `-6·interval` the order stays warmup < commit < probe < capture. + await ensureRenderFrameSiblings(page); + const commitCdp = await getCdpSession(page); + await commitCdp.send("HeadlessExperimental.beginFrame", { + frameTimeTicks: session.beginFrameTimeTicks - 6 * session.beginFrameIntervalMs, + interval: session.beginFrameIntervalMs, + noDisplayUpdates: false, + }); + session.isInitialized = true; } diff --git a/packages/engine/src/services/screenshotService.test.ts b/packages/engine/src/services/screenshotService.test.ts index 9b7c78d20..c59b73669 100644 --- a/packages/engine/src/services/screenshotService.test.ts +++ b/packages/engine/src/services/screenshotService.test.ts @@ -6,6 +6,7 @@ import { type Page } from "puppeteer-core"; import { pageScreenshotCapture, cdpSessionCache, + ensureRenderFrameSiblings, applyDomLayerMask, removeDomLayerMask, injectVideoFramesBatch, @@ -744,3 +745,90 @@ describe("video-frame injection respects ancestor visibility", () => { } }); }); + +describe("ensureRenderFrameSiblings", () => { + function passthroughPage(): Page { + return { + evaluate: async (fn: (...args: unknown[]) => unknown, ...args: unknown[]) => + Promise.resolve((fn as (...a: unknown[]) => unknown)(...args)), + } as unknown as Page; + } + + function withGlobalDom(setup: { window: Window; document: Document }): { teardown: () => void } { + const globals = globalThis as unknown as { window?: Window; document?: Document }; + const previousWindow = globals.window; + const previousDocument = globals.document; + globals.window = setup.window; + globals.document = setup.document; + return { + teardown: () => { + globals.window = previousWindow; + globals.document = previousDocument; + }, + }; + } + + it("creates a hidden __render_frame__ sibling for every video[data-start]", async () => { + const { window, document } = parseHTML( + ` +
+ + +
+ `, + ); + const { teardown } = withGlobalDom({ window, document }); + try { + await ensureRenderFrameSiblings(passthroughPage()); + } finally { + teardown(); + } + + for (const id of ["v1", "v2"]) { + const video = document.getElementById(id) as HTMLVideoElement; + const sibling = video.nextElementSibling as HTMLElement | null; + expect(sibling).not.toBeNull(); + expect(sibling?.tagName.toLowerCase()).toBe("img"); + expect(sibling?.classList.contains("__render_frame__")).toBe(true); + expect(sibling?.id).toBe(`__render_frame_${id}__`); + expect(sibling?.style.visibility).toBe("hidden"); + } + }); + + it("skips videos that already have a __render_frame__ sibling", async () => { + const { window, document } = parseHTML( + ` +
+ + +
+ `, + ); + const preExisting = document.getElementById("__render_frame_clip__") as HTMLImageElement; + const { teardown } = withGlobalDom({ window, document }); + try { + await ensureRenderFrameSiblings(passthroughPage()); + } finally { + teardown(); + } + + const video = document.getElementById("clip") as HTMLVideoElement; + expect(video.nextElementSibling).toBe(preExisting); + expect(document.querySelectorAll(".__render_frame__").length).toBe(1); + }); + + it("does not touch