Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/cli/src/commands/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import snapshotCommand, {
computeSnapshotTimes,
formatSnapshotTimestamp,
parseZoomScale,
recaptureSnapshotComposite,
requireSnapshotFfmpeg,
resolveSnapshotVideoClipStart,
resolveSnapshotVideoFrameTime,
Expand Down Expand Up @@ -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<T>(callback: () => T): Promise<Awaited<T>> {
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);
});
});
39 changes: 39 additions & 0 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,43 @@ export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string {
);
}

interface SnapshotCompositePage {
evaluate<T>(callback: () => T): Promise<Awaited<T>>;
screenshot(options: {
type: "jpeg";
quality: number;
clip: { x: number; y: number; width: number; height: number };
}): Promise<Uint8Array>;
}

/** Recapture canvas composites once the decoded video overlays have been injected. */
export async function recaptureSnapshotComposite(page: SnapshotCompositePage): Promise<void> {
const hasPageComposite = await page.evaluate(async () => {
const runtimeWindow = window as Window & {
__hf_page_composite_prepare?: () => Promise<boolean>;
__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
Expand Down Expand Up @@ -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`;
Expand Down