Skip to content

Commit df4c638

Browse files
committed
fix(engine): pre-create __render_frame__ siblings in initializeSession
Under chrome-headless-shell + HeadlessExperimental.beginFrame deterministic mode, injectVideoFramesBatch's `isNewImage` branch creates the replacement <img> on the fly (createElement + insertBefore). The immediately-next beginFrame captures before the new layer lands in the compositor's tree, so that frame paints only body background + already-composed overlays. Pre-create the __render_frame__ sibling once at the end of initializeSession. By the time the first capture begins, warmup + GSAP flush + readiness polls have driven several BeginFrame ticks, so the layers are committed and every subsequent inject takes the `hasImg=true` path (just an img.src update).
1 parent 145c71e commit df4c638

3 files changed

Lines changed: 130 additions & 0 deletions

File tree

packages/engine/src/services/frameCapture.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from "./browserManager.js";
2626
import {
2727
beginFrameCapture,
28+
ensureRenderFrameSiblings,
2829
getCdpSession,
2930
pageScreenshotCapture,
3031
initTransparentBackground,
@@ -1085,6 +1086,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
10851086
}
10861087

10871088
await armStaticDedup(session, session.page, logInitPhase);
1089+
await ensureRenderFrameSiblings(session.page);
10881090
session.isInitialized = true;
10891091
return;
10901092
}
@@ -1239,6 +1241,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
12391241
}
12401242

12411243
await armStaticDedup(session, session.page, logInitPhase);
1244+
await ensureRenderFrameSiblings(session.page);
12421245
session.isInitialized = true;
12431246
}
12441247

packages/engine/src/services/screenshotService.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type Page } from "puppeteer-core";
55
import {
66
pageScreenshotCapture,
77
cdpSessionCache,
8+
ensureRenderFrameSiblings,
89
injectVideoFramesBatch,
910
syncVideoFrameVisibility,
1011
shouldDefaultCaptureBeyondViewport,
@@ -547,3 +548,90 @@ describe("video-frame injection respects ancestor visibility", () => {
547548
expect(setPropertySpy).toHaveBeenCalledWith("visibility", "hidden", "important");
548549
});
549550
});
551+
552+
describe("ensureRenderFrameSiblings", () => {
553+
function passthroughPage(): Page {
554+
return {
555+
evaluate: async (fn: (...args: unknown[]) => unknown, ...args: unknown[]) =>
556+
Promise.resolve((fn as (...a: unknown[]) => unknown)(...args)),
557+
} as unknown as Page;
558+
}
559+
560+
function withGlobalDom(setup: { window: Window; document: Document }): { teardown: () => void } {
561+
const globals = globalThis as unknown as { window?: Window; document?: Document };
562+
const previousWindow = globals.window;
563+
const previousDocument = globals.document;
564+
globals.window = setup.window;
565+
globals.document = setup.document;
566+
return {
567+
teardown: () => {
568+
globals.window = previousWindow;
569+
globals.document = previousDocument;
570+
},
571+
};
572+
}
573+
574+
it("creates a hidden __render_frame__ sibling for every video[data-start]", async () => {
575+
const { window, document } = parseHTML(
576+
`<html><body>
577+
<div id="root">
578+
<video id="v1" data-start="0" data-duration="2"></video>
579+
<video id="v2" data-start="2" data-duration="2"></video>
580+
</div>
581+
</body></html>`,
582+
);
583+
const { teardown } = withGlobalDom({ window, document });
584+
try {
585+
await ensureRenderFrameSiblings(passthroughPage());
586+
} finally {
587+
teardown();
588+
}
589+
590+
for (const id of ["v1", "v2"]) {
591+
const video = document.getElementById(id) as HTMLVideoElement;
592+
const sibling = video.nextElementSibling as HTMLElement | null;
593+
expect(sibling).not.toBeNull();
594+
expect(sibling?.tagName.toLowerCase()).toBe("img");
595+
expect(sibling?.classList.contains("__render_frame__")).toBe(true);
596+
expect(sibling?.id).toBe(`__render_frame_${id}__`);
597+
expect(sibling?.style.visibility).toBe("hidden");
598+
}
599+
});
600+
601+
it("skips videos that already have a __render_frame__ sibling", async () => {
602+
const { window, document } = parseHTML(
603+
`<html><body>
604+
<div id="root">
605+
<video id="clip" data-start="0" data-duration="2"></video>
606+
<img id="__render_frame_clip__" class="__render_frame__">
607+
</div>
608+
</body></html>`,
609+
);
610+
const preExisting = document.getElementById("__render_frame_clip__") as HTMLImageElement;
611+
const { teardown } = withGlobalDom({ window, document });
612+
try {
613+
await ensureRenderFrameSiblings(passthroughPage());
614+
} finally {
615+
teardown();
616+
}
617+
618+
const video = document.getElementById("clip") as HTMLVideoElement;
619+
expect(video.nextElementSibling).toBe(preExisting);
620+
expect(document.querySelectorAll(".__render_frame__").length).toBe(1);
621+
});
622+
623+
it("does not touch <video> elements without data-start", async () => {
624+
const { window, document } = parseHTML(
625+
`<html><body><div id="root"><video id="raw"></video></div></body></html>`,
626+
);
627+
const { teardown } = withGlobalDom({ window, document });
628+
try {
629+
await ensureRenderFrameSiblings(passthroughPage());
630+
} finally {
631+
teardown();
632+
}
633+
634+
const video = document.getElementById("raw") as HTMLVideoElement;
635+
expect(video.nextElementSibling).toBeNull();
636+
});
637+
});

packages/engine/src/services/screenshotService.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,45 @@ export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Pr
385385
);
386386
}
387387

388+
/**
389+
* Pre-create hidden `__render_frame__` sibling `<img>`s for every
390+
* `video[data-start]` in the page. Idempotent — videos that already
391+
* have a sibling are skipped.
392+
*
393+
* `injectVideoFramesBatch` creates the sibling on the fly the first time
394+
* it paints a given videoId (the `isNewImage = !hasImg` branch below).
395+
* Under chrome-headless-shell's deterministic + `HeadlessExperimental.
396+
* BeginFrame` mode, the immediately-next BeginFrame captures before the
397+
* freshly-inserted `<img>` layer lands in the compositor's layer tree;
398+
* the layer arrives a frame later. That single frame paints only the
399+
* body background + previously-composed overlays.
400+
*
401+
* Called once at the end of `initializeSession` — by the time the first
402+
* capture BeginFrame fires, several BeginFrame ticks have already elapsed
403+
* through warmup / GSAP flush / readiness polls, so the pre-created
404+
* layers are committed and every subsequent `injectVideoFramesBatch`
405+
* takes the `hasImg = true` path (just an `img.src` update). The
406+
* `isNewImage` branch stays as a fallback for callers that don't run
407+
* through `initializeSession`.
408+
*/
409+
export async function ensureRenderFrameSiblings(page: Page): Promise<void> {
410+
await page.evaluate(() => {
411+
for (const video of Array.from(
412+
document.querySelectorAll<HTMLVideoElement>("video[data-start]"),
413+
)) {
414+
const next = video.nextElementSibling;
415+
if (next !== null && next.classList.contains("__render_frame__")) continue;
416+
const img = document.createElement("img");
417+
img.classList.add("__render_frame__");
418+
img.id = `__render_frame_${video.id}__`;
419+
img.style.pointerEvents = "none";
420+
img.style.position = "absolute";
421+
img.style.visibility = "hidden";
422+
video.parentNode?.insertBefore(img, video.nextSibling);
423+
}
424+
});
425+
}
426+
388427
/**
389428
* Returns the subset of `updates.videoId`s that were actually painted in
390429
* this call. Videos skipped because of a hidden visual ancestor are NOT

0 commit comments

Comments
 (0)