diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 7fd4db398c..6f013784c1 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -31,6 +31,7 @@ import snapshotCommand, { computeSnapshotTimes, formatSnapshotTimestamp, parseZoomScale, + recaptureSnapshotComposite, requireSnapshotFfmpeg, resolveSnapshotVideoClipStart, resolveSnapshotVideoFrameTime, @@ -475,3 +476,56 @@ describe("requireSnapshotFfmpeg", () => { expect(requireSnapshotFfmpeg("C:\\tools\\ffmpeg.exe")).toBe("C:\\tools\\ffmpeg.exe"); }); }); + +describe("snapshot composite recapture", () => { + it.each([false, true])("recaptures only when a resolver exists: %s", async (hasResolver) => { + const order: string[] = []; + const runtimeWindow = { + __hf_page_composite_prepare: vi.fn(async () => { + order.push("prepare"); + return true; + }), + __hf_page_composite_resolve: hasResolver + ? vi.fn(() => { + order.push("resolve"); + return true; + }) + : undefined, + }; + vi.stubGlobal("window", runtimeWindow); + try { + await recaptureSnapshotComposite({ + async evaluate(callback: () => T): Promise> { + return await callback(); + }, + async screenshot(options) { + expect(options).toEqual({ + type: "jpeg", + quality: 1, + clip: { x: 0, y: 0, width: 1, height: 1 }, + }); + order.push("paint"); + return new Uint8Array(); + }, + }); + expect(order).toEqual(hasResolver ? ["prepare", "paint", "resolve"] : []); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("places recapture after frame injection and visibility sync, before final capture", () => { + const source = readFileSync(new URL("./snapshot.ts", import.meta.url), "utf8"); + const injection = source.indexOf("await injectVideoFramesBatch(page, updates)"); + const visibility = source.indexOf("await syncVideoFrameVisibility(", injection); + const recapture = source.indexOf("await recaptureSnapshotComposite(page)", visibility); + const finalCapture = source.indexOf( + 'page.screenshot({ path: framePath, type: "png"', + recapture, + ); + expect(injection).toBeGreaterThan(-1); + expect(visibility).toBeGreaterThan(injection); + expect(recapture).toBeGreaterThan(visibility); + expect(finalCapture).toBeGreaterThan(recapture); + }); +}); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index b091b3c6c1..931c13b45b 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -139,6 +139,43 @@ export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string { ); } +interface SnapshotCompositePage { + evaluate(callback: () => T): Promise>; + screenshot(options: { + type: "jpeg"; + quality: number; + clip: { x: number; y: number; width: number; height: number }; + }): Promise; +} + +/** Recapture canvas composites once the decoded video overlays have been injected. */ +export async function recaptureSnapshotComposite(page: SnapshotCompositePage): Promise { + const hasPageComposite = await page.evaluate(async () => { + const runtimeWindow = window as Window & { + __hf_page_composite_prepare?: () => Promise; + __hf_page_composite_resolve?: () => boolean; + }; + if (typeof runtimeWindow.__hf_page_composite_resolve !== "function") return false; + await runtimeWindow.__hf_page_composite_prepare?.(); + return true; + }); + if (hasPageComposite) { + // The 1×1 screenshot forces Chrome to publish updated subtree paint records + // after injection, so resolve can consume them without a fixed delay. + await page.screenshot({ + type: "jpeg", + quality: 1, + clip: { x: 0, y: 0, width: 1, height: 1 }, + }); + await page.evaluate(() => { + const runtimeWindow = window as Window & { + __hf_page_composite_resolve?: () => boolean; + }; + runtimeWindow.__hf_page_composite_resolve?.(); + }); + } +} + /** * Extract a single frame from a video file at `timeSeconds` via FFmpeg. * Used to work around Chrome-headless's inability to reliably seek @@ -595,6 +632,8 @@ async function captureSnapshots( } } + await recaptureSnapshotComposite(page); + const timeLabel = formatSnapshotTimestamp(time); const index = String(i).padStart(2, "0"); const filename = `frame-${index}-at-${timeLabel}.png`;