screenshot-size-guard.ts
106 lines · browse/src/screenshot-size-guard.ts at 3f79d81
1/**
2 * Screenshot size guard — keep full-page screenshots ≤ 2000px max-dim.
3 *
4 * The Anthropic vision API rejects images whose longest dimension exceeds
5 * 2000 image-pixels (post deviceScaleFactor). Full-page screenshots of long
6 * pages routinely exceed that, silently bricking the session: the agent
7 * burns turns on a base64 blob that errors model-side with no useful
8 * stderr surfacing on the browse side.
9 *
10 * This module centralizes the "after page.screenshot, check dimensions and
11 * downscale if too big" path so every full-page caller in browse/src can
12 * share the same enforcement. The cap is image-pixels, not CSS pixels,
13 * matching the Anthropic API's own threshold.
14 *
15 * Used by: snapshot.ts (annotated, heatmap), meta-commands.ts (screenshot),
16 * write-commands.ts (prettyscreenshot). See test/snapshot-meta-write-guard.test.ts.
17 *
18 * Closes #1214.
19 */
21import { writeFileSync, readFileSync } from "fs";
23const MAX_DIMENSION_PX = 2000;
25export interface SizeGuardResult {
26 /** True if the input image exceeded MAX_DIMENSION_PX and was downscaled. */
27 resized: boolean;
28 /** Final width and height (pixels) of the image as written/returned. */
29 width: number;
30 height: number;
31 /** Original dimensions before any downscale. */
32 originalWidth: number;
33 originalHeight: number;
34}
36/**
37 * Inspect an image buffer and downscale if its longest side exceeds the
38 * 2000px Anthropic vision API cap. Preserves aspect ratio. Encodes back
39 * to PNG. Returns the resulting buffer plus a diagnostic shape.
40 *
41 * Imports sharp lazily so the module load cost only hits screenshot paths
42 * (sharp's native binding is non-trivial to initialize).
43 */
44export async function guardScreenshotBuffer(input: Buffer): Promise<{ buffer: Buffer; result: SizeGuardResult }> {
45 const sharpModule = await import("sharp");
46 const sharp = sharpModule.default ?? sharpModule;
47 const image = sharp(input);
48 const metadata = await image.metadata();
49 const width = metadata.width ?? 0;
50 const height = metadata.height ?? 0;
52 const longest = Math.max(width, height);
53 if (longest <= MAX_DIMENSION_PX) {
54 return {
55 buffer: input,
56 result: {
57 resized: false,
58 width,
59 height,
60 originalWidth: width,
61 originalHeight: height,
62 },
63 };
64 }
66 const scale = MAX_DIMENSION_PX / longest;
67 const newWidth = Math.round(width * scale);
68 const newHeight = Math.round(height * scale);
70 const resized = await image
71 .resize(newWidth, newHeight, { fit: "inside" })
72 .png()
73 .toBuffer();
75 process.stderr.write(
76 `[screenshot-size-guard] image ${width}x${height} exceeded ${MAX_DIMENSION_PX}px max-dim; ` +
77 `downscaled to ${newWidth}x${newHeight} to fit Anthropic vision API\n`,
78 );
80 return {
81 buffer: resized,
82 result: {
83 resized: true,
84 width: newWidth,
85 height: newHeight,
86 originalWidth: width,
87 originalHeight: height,
88 },
89 };
90}
92/**
93 * File-mode variant: read the image at the given path, downscale if
94 * needed, and write the result back to the same path. Returns the
95 * diagnostic shape. Use this after `await page.screenshot({ path, ... })`.
96 */
97export async function guardScreenshotPath(filePath: string): Promise<SizeGuardResult> {
98 const input = readFileSync(filePath);
99 const { buffer, result } = await guardScreenshotBuffer(input);
100 if (result.resized) {
101 writeFileSync(filePath, buffer);
102 }
103 return result;
104}
106export const SCREENSHOT_MAX_DIMENSION_PX = MAX_DIMENSION_PX;