Skip to content

Commit bad0bc7

Browse files
authored
fix(cli): recapture snapshot composites after video injection (#4767)
* fix(cli): recapture snapshot composites after video injection * test(cli): guard snapshot composite recapture ordering
1 parent d7285e4 commit bad0bc7

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

‎packages/cli/src/commands/snapshot.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import snapshotCommand, {
3535
computeSnapshotTimes,
3636
formatSnapshotTimestamp,
3737
parseZoomScale,
38+
recaptureSnapshotComposite,
3839
requireSnapshotFfmpeg,
3940
resolveSnapshotVideoClipStart,
4041
resolveSnapshotVideoFrameTime,
@@ -517,3 +518,56 @@ describe("requireSnapshotFfmpeg", () => {
517518
expect(requireSnapshotFfmpeg("C:\\tools\\ffmpeg.exe")).toBe("C:\\tools\\ffmpeg.exe");
518519
});
519520
});
521+
522+
describe("snapshot composite recapture", () => {
523+
it.each([false, true])("recaptures only when a resolver exists: %s", async (hasResolver) => {
524+
const order: string[] = [];
525+
const runtimeWindow = {
526+
__hf_page_composite_prepare: vi.fn(async () => {
527+
order.push("prepare");
528+
return true;
529+
}),
530+
__hf_page_composite_resolve: hasResolver
531+
? vi.fn(() => {
532+
order.push("resolve");
533+
return true;
534+
})
535+
: undefined,
536+
};
537+
vi.stubGlobal("window", runtimeWindow);
538+
try {
539+
await recaptureSnapshotComposite({
540+
async evaluate<T>(callback: () => T): Promise<Awaited<T>> {
541+
return await callback();
542+
},
543+
async screenshot(options) {
544+
expect(options).toEqual({
545+
type: "jpeg",
546+
quality: 1,
547+
clip: { x: 0, y: 0, width: 1, height: 1 },
548+
});
549+
order.push("paint");
550+
return new Uint8Array();
551+
},
552+
});
553+
expect(order).toEqual(hasResolver ? ["prepare", "paint", "resolve"] : []);
554+
} finally {
555+
vi.unstubAllGlobals();
556+
}
557+
});
558+
559+
it("places recapture after frame injection and visibility sync, before final capture", () => {
560+
const source = readFileSync(new URL("./snapshot.ts", import.meta.url), "utf8");
561+
const injection = source.indexOf("await injectVideoFramesBatch(page, updates)");
562+
const visibility = source.indexOf("await syncVideoFrameVisibility(", injection);
563+
const recapture = source.indexOf("await recaptureSnapshotComposite(page)", visibility);
564+
const finalCapture = source.indexOf(
565+
'page.screenshot({ path: framePath, type: "png"',
566+
recapture,
567+
);
568+
expect(injection).toBeGreaterThan(-1);
569+
expect(visibility).toBeGreaterThan(injection);
570+
expect(recapture).toBeGreaterThan(visibility);
571+
expect(finalCapture).toBeGreaterThan(recapture);
572+
});
573+
});

‎packages/cli/src/commands/snapshot.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,43 @@ export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string {
139139
);
140140
}
141141

142+
interface SnapshotCompositePage {
143+
evaluate<T>(callback: () => T): Promise<Awaited<T>>;
144+
screenshot(options: {
145+
type: "jpeg";
146+
quality: number;
147+
clip: { x: number; y: number; width: number; height: number };
148+
}): Promise<Uint8Array>;
149+
}
150+
151+
/** Recapture canvas composites once the decoded video overlays have been injected. */
152+
export async function recaptureSnapshotComposite(page: SnapshotCompositePage): Promise<void> {
153+
const hasPageComposite = await page.evaluate(async () => {
154+
const runtimeWindow = window as Window & {
155+
__hf_page_composite_prepare?: () => Promise<boolean>;
156+
__hf_page_composite_resolve?: () => boolean;
157+
};
158+
if (typeof runtimeWindow.__hf_page_composite_resolve !== "function") return false;
159+
await runtimeWindow.__hf_page_composite_prepare?.();
160+
return true;
161+
});
162+
if (hasPageComposite) {
163+
// The 1×1 screenshot forces Chrome to publish updated subtree paint records
164+
// after injection, so resolve can consume them without a fixed delay.
165+
await page.screenshot({
166+
type: "jpeg",
167+
quality: 1,
168+
clip: { x: 0, y: 0, width: 1, height: 1 },
169+
});
170+
await page.evaluate(() => {
171+
const runtimeWindow = window as Window & {
172+
__hf_page_composite_resolve?: () => boolean;
173+
};
174+
runtimeWindow.__hf_page_composite_resolve?.();
175+
});
176+
}
177+
}
178+
142179
/**
143180
* Extract a single frame from a video file at `timeSeconds` via FFmpeg.
144181
* Used to work around Chrome-headless's inability to reliably seek
@@ -595,6 +632,8 @@ async function captureSnapshots(
595632
}
596633
}
597634

635+
await recaptureSnapshotComposite(page);
636+
598637
const timeLabel = formatSnapshotTimestamp(time);
599638
const index = String(i).padStart(2, "0");
600639
const filename = `frame-${index}-at-${timeLabel}.png`;

0 commit comments

Comments
 (0)