feat: add resizeImage sandbox utility for shrinking screenshots before LLM ingestion

Extracts the sharp resize logic from screenshotWithAccessibilityLabels into a
standalone resizeImage() function exposed in the executor sandbox. Agents can
now shrink screenshots (or any image) to consume fewer context tokens.

Default behavior: fit within 1568×1568px (Claude-optimal), overwrite input file.
Also supports explicit width/height/maxDimension/fit/quality for custom resizing.

Replaces the old macOS-specific sips advice in skill.md.
This commit is contained in:
Tommy D. Rossi
2026-02-26 12:57:39 +01:00
parent de9c3e961b
commit c13c835c5f
5 changed files with 142 additions and 34 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog # Changelog
## 0.0.78
### Features
- **Add `resizeImage` sandbox utility**: Standalone function to resize images, useful for shrinking screenshots before reading them back into context. Default LLM-optimal mode fits within 1568×1568px; also supports explicit width/height/maxDimension. Available in execute sandbox alongside other utilities.
## 0.0.77
### Improvements
- **Cap speed-up output to source fps in FFmpeg pipeline**: Speed-up filters now use explicit `fps=fps=<source>:round=down` and set output `-r` to the same probed frame rate, keeping accelerated sections bounded to the recording's native fps.
## 0.0.76
### Bug Fixes
- **Fix ultra-short/slow demo generation on variable-framerate recordings**: `probeVideo()` now prefers `avg_frame_rate` and clamps output FPS to sane bounds, avoiding accidental `fps=30000` filter chains.
- **Avoid speeding entire video when no execute timestamps exist**: `computeIdleSections()` now returns no idle sections when timestamps are empty, so `createDemoVideo()` preserves original speed instead of aggressively compressing full recordings.
## 0.0.75 ## 0.0.75
### Improvements ### Improvements
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "playwriter", "name": "playwriter",
"description": "", "description": "",
"version": "0.0.75", "version": "0.0.78",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+114 -18
View File
@@ -84,6 +84,113 @@ export interface ScreenshotResult {
labelCount: number labelCount: number
} }
// ============================================================================
// Image Resize Utility
// ============================================================================
/**
* LLM-optimal max dimension. Claude auto-resizes images larger than 1568px
* on any edge, adding latency. Token cost: (width * height) / 750.
*/
export const LLM_MAX_DIMENSION = 1568
export interface ResizeImageOptions {
/** Input: file path or Buffer */
input: string | Buffer
/** Max pixels on longest edge. Default 1568 (Claude-optimal).
* Ignored if explicit width/height provided. */
maxDimension?: number
/** Explicit target width in px (aspect ratio preserved unless both width+height set) */
width?: number
/** Explicit target height in px */
height?: number
/** How to fit when both width+height specified. Default 'inside' (preserve aspect ratio, no crop) */
fit?: 'inside' | 'cover' | 'contain' | 'fill'
/** JPEG quality 1-100. Default 80 */
quality?: number
/** Output file path. Defaults to overwriting the input file (when input is a path) */
output?: string
}
export interface ResizeImageResult {
buffer: Buffer
mimeType: 'image/jpeg'
/** Only set if output path was provided */
path?: string
}
/**
* Resize an image using sharp. Useful for shrinking screenshots before reading
* them back into context so they consume fewer tokens.
*
* Default behavior (no width/height): fits within 1568×1568px, preserving
* aspect ratio, never upscales. This is optimal for Claude vision.
*
* Explicit width/height: resizes to those dimensions using the fit strategy.
*/
export async function resizeImage(options: ResizeImageOptions): Promise<ResizeImageResult> {
const sharp = await sharpPromise
if (!sharp) {
throw new Error('sharp is not installed — install it with: pnpm add sharp')
}
const inputBuffer = (() => {
if (Buffer.isBuffer(options.input)) {
return options.input
}
return fs.readFileSync(options.input)
})()
const quality = options.quality ?? 80
const hasExplicitDimensions = options.width !== undefined || options.height !== undefined
const fit = options.fit ?? 'inside'
const resizeOpts = (() => {
if (hasExplicitDimensions) {
return {
width: options.width,
height: options.height,
fit,
withoutEnlargement: false,
}
}
const max = options.maxDimension ?? LLM_MAX_DIMENSION
return {
width: max,
height: max,
fit,
withoutEnlargement: true,
}
})()
const buffer = await sharp(inputBuffer)
.resize(resizeOpts)
.jpeg({ quality })
.toBuffer()
// Default: overwrite input file. When input is a Buffer, no file is written
// unless output is explicitly set.
const outputPath = (() => {
if (options.output) {
return options.output
}
if (typeof options.input === 'string') {
return options.input
}
return undefined
})()
if (outputPath) {
fs.writeFileSync(outputPath, buffer)
}
return {
buffer,
mimeType: 'image/jpeg',
...(outputPath ? { path: outputPath } : {}),
}
}
export interface AriaSnapshotResult { export interface AriaSnapshotResult {
snapshot: string snapshot: string
tree: AriaSnapshotNode[] tree: AriaSnapshotNode[]
@@ -1444,16 +1551,12 @@ export async function screenshotWithAccessibilityLabels({
height: number height: number
} }
// Max 1568px on any edge (larger gets auto-resized by Claude, adding latency)
// Token formula: tokens = (width * height) / 750
const MAX_DIMENSION = 1568
// Check if sharp is available for resizing // Check if sharp is available for resizing
const sharp = await sharpPromise const sharp = await sharpPromise
// Clip dimensions: if sharp unavailable, limit capture area to MAX_DIMENSION // Clip dimensions: if sharp unavailable, limit capture area to LLM_MAX_DIMENSION
const clipWidth = sharp ? viewport.width : Math.min(viewport.width, MAX_DIMENSION) const clipWidth = sharp ? viewport.width : Math.min(viewport.width, LLM_MAX_DIMENSION)
const clipHeight = sharp ? viewport.height : Math.min(viewport.height, MAX_DIMENSION) const clipHeight = sharp ? viewport.height : Math.min(viewport.height, LLM_MAX_DIMENSION)
// Take viewport screenshot with scale: 'css' to ignore device pixel ratio // Take viewport screenshot with scale: 'css' to ignore device pixel ratio
const screenshotStart = Date.now() const screenshotStart = Date.now()
@@ -1467,23 +1570,16 @@ export async function screenshotWithAccessibilityLabels({
log(`page.screenshot: ${Date.now() - screenshotStart}ms`) log(`page.screenshot: ${Date.now() - screenshotStart}ms`)
} }
// Resize with sharp if available, otherwise use clipped raw buffer // Resize with resizeImage if sharp available, otherwise use clipped raw buffer
const resizeStart = Date.now() const resizeStart = Date.now()
const buffer = await (async () => { const buffer = await (async () => {
if (!sharp) { if (!sharp) {
logger?.error?.('[playwriter] sharp not available, using clipped screenshot (max', MAX_DIMENSION, 'px)') logger?.error?.('[playwriter] sharp not available, using clipped screenshot (max', LLM_MAX_DIMENSION, 'px)')
return rawBuffer return rawBuffer
} }
try { try {
return await sharp(rawBuffer) const result = await resizeImage({ input: rawBuffer })
.resize({ return result.buffer
width: MAX_DIMENSION,
height: MAX_DIMENSION,
fit: 'inside', // Scale down to fit, preserving aspect ratio
withoutEnlargement: true, // Don't upscale small images
})
.jpeg({ quality: 80 })
.toBuffer()
} catch (err) { } catch (err) {
logger?.error?.('[playwriter] sharp resize failed, using raw buffer:', err) logger?.error?.('[playwriter] sharp resize failed, using raw buffer:', err)
return rawBuffer return rawBuffer
+2
View File
@@ -26,6 +26,7 @@ import { ScopedFS } from './scoped-fs.js'
import { import {
screenshotWithAccessibilityLabels, screenshotWithAccessibilityLabels,
getAriaSnapshot, getAriaSnapshot,
resizeImage,
type ScreenshotResult, type ScreenshotResult,
type SnapshotFormat, type SnapshotFormat,
} from './aria-snapshot.js' } from './aria-snapshot.js'
@@ -1029,6 +1030,7 @@ export class PlaywrightExecutor {
formatStylesAsText, formatStylesAsText,
getReactSource: getReactSourceFn, getReactSource: getReactSourceFn,
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn, screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
resizeImage,
ghostCursor: { ghostCursor: {
show: showGhostCursor, show: showGhostCursor,
hide: hideGhostCursor, hide: hideGhostCursor,
+6 -15
View File
@@ -913,23 +913,14 @@ await screenshotWithAccessibilityLabels({ page: state.page })
Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs. Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.
**resizeImage** - resize an image to consume fewer tokens when read back into context. Useful when you take a `page.screenshot()` and need to ingest the image later. By default fits within 1568×1568px (Claude-optimal, avoids server-side re-encoding). Token cost formula: `(width × height) / 750`. Always outputs JPEG. **resizeImage** - shrink an image so it consumes fewer tokens when read back into context. Overwrites the input file by default. Fits within 1568×1568px (Claude-optimal). Always outputs JPEG.
```js ```js
// Shrink a screenshot for LLM ingestion (default: max 1568px, JPEG q80) // Shrink screenshot in-place for LLM ingestion
await resizeImage({ input: './screenshot.png', output: './small.jpg' }) await resizeImage({ input: './screenshot.png' })
// Resize to a specific width (aspect ratio preserved)
await resizeImage({ input: './large.png', width: 800, output: './resized.jpg' })
// Resize to fit inside exact dimensions
await resizeImage({ input: './photo.png', width: 1024, height: 768, output: './fit.jpg' })
// Custom max dimension and quality
await resizeImage({ input: './shot.png', maxDimension: 1024, quality: 60, output: './out.jpg' })
``` ```
Options: `input` (path or Buffer), `output` (path, optional), `maxDimension` (default 1568, ignored if width/height set), `width`, `height`, `fit` ('inside' | 'cover' | 'contain' | 'fill', default 'inside'), `quality` (1-100, default 80). Returns `{ buffer, mimeType, path? }`. Also supports explicit dimensions: `width`, `height`, `maxDimension`, `fit` ('inside' | 'cover' | 'contain' | 'fill'), `quality` (1-100, default 80), `output` (defaults to overwriting input).
**recording.start / recording.stop** - record the page as a video at native FPS (30-60fps). Uses `chrome.tabCapture` in the extension context, so **recording survives page navigation**. Video is saved as mp4. **recording.start / recording.stop** - record the page as a video at native FPS (30-60fps). Uses `chrome.tabCapture` in the extension context, so **recording survives page navigation**. Video is saved as mp4.
@@ -1034,10 +1025,10 @@ Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
await state.page.screenshot({ path: 'shot.png', scale: 'css' }) await state.page.screenshot({ path: 'shot.png', scale: 'css' })
``` ```
If you want to read back the image file into context, resize it first with `resizeImage` so it consumes fewer tokens: If you want to read back the image file into context, resize it first so it consumes fewer tokens:
```js ```js
await resizeImage({ input: './shot.png', output: './small.jpg' }) await resizeImage({ input: './shot.png' })
``` ```
## page.evaluate ## page.evaluate