From 495035ccefebf66b1653786ffcb55f1ab40ca9b2 Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Mon, 19 Jan 2026 14:07:01 +0100 Subject: [PATCH] fix: sharp fallback with viewport clipping when unavailable --- README.md | 35 +++++++++++++++---------- playwriter/CHANGELOG.md | 7 +++++ playwriter/package.json | 2 +- playwriter/src/aria-snapshot.ts | 45 +++++++++++++++++++++++---------- playwriter/src/mcp.ts | 7 ++++- 5 files changed, 67 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index f4b1ce9..be67169 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,14 @@ } ``` - Restart your MCP client and you're ready to go! Your AI assistant can now control the browser through the extension. +Or use the following command to automatically add the MCP setting for your client: + +```sh +npx -y install-mcp playwriter # pass `--client claude` or `--client opencode` for the client you want to use + +``` + +Restart your MCP client and you're ready to go! Your AI assistant can now control the browser through the extension. ## Usage @@ -161,11 +168,11 @@ When set, the MCP will automatically create an initial tab when a Playwright cli { "mcpServers": { "playwriter": { -"command": "npx", - "args": ["-y", "playwriter@latest"], - "env": { - "PLAYWRITER_AUTO_ENABLE": "1" - } + "command": "npx", + "args": ["-y", "playwriter@latest"], + "env": { + "PLAYWRITER_AUTO_ENABLE": "1" + } } } } @@ -359,8 +366,8 @@ Configure your MCP client with the host and token. You can pass them as CLI argu { "mcpServers": { "playwriter": { -"command": "npx", - "args": ["-y", "playwriter@latest", "--host", "host.docker.internal", "--token", ""] + "command": "npx", + "args": ["-y", "playwriter@latest", "--host", "host.docker.internal", "--token", ""] } } } @@ -372,12 +379,12 @@ Or use environment variables (useful if you want to set them globally in your pr { "mcpServers": { "playwriter": { -"command": "npx", - "args": ["-y", "playwriter@latest"], - "env": { - "PLAYWRITER_HOST": "host.docker.internal", - "PLAYWRITER_TOKEN": "" - } + "command": "npx", + "args": ["-y", "playwriter@latest"], + "env": { + "PLAYWRITER_HOST": "host.docker.internal", + "PLAYWRITER_TOKEN": "" + } } } } diff --git a/playwriter/CHANGELOG.md b/playwriter/CHANGELOG.md index d2f11c0..6500862 100644 --- a/playwriter/CHANGELOG.md +++ b/playwriter/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.0.50 + +### Bug Fixes + +- **Sharp fallback with viewport clipping**: When sharp is unavailable (optional dependency), screenshots now clip to max 1568px instead of relying on Claude's auto-resize +- **Error logging for sharp failures**: Added logging when sharp import or resize fails, making it easier to debug screenshot optimization issues + ## 0.0.49 ### Features diff --git a/playwriter/package.json b/playwriter/package.json index 32db626..2298112 100644 --- a/playwriter/package.json +++ b/playwriter/package.json @@ -1,7 +1,7 @@ { "name": "playwriter", "description": "", - "version": "0.0.49", + "version": "0.0.50", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/playwriter/src/aria-snapshot.ts b/playwriter/src/aria-snapshot.ts index d841cfe..301cc58 100644 --- a/playwriter/src/aria-snapshot.ts +++ b/playwriter/src/aria-snapshot.ts @@ -2,6 +2,11 @@ import type { Page, Locator, ElementHandle } from 'playwright-core' import fs from 'node:fs' import path from 'node:path' +// Import sharp at module level - resolves to null if not available +const sharpPromise = import('sharp') + .then((m) => { return m.default }) + .catch(() => { return null }) + export interface AriaRef { role: string name: string @@ -558,10 +563,11 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise * await page.locator('aria-ref=e5').click() * ``` */ -export async function screenshotWithAccessibilityLabels({ page, interactiveOnly = true, collector }: { +export async function screenshotWithAccessibilityLabels({ page, interactiveOnly = true, collector, logger }: { page: Page interactiveOnly?: boolean collector: ScreenshotResult[] + logger?: { error: (...args: unknown[]) => void } }): Promise { // Show labels and get snapshot const { snapshot, labelCount } = await showAriaRefLabels({ page, interactiveOnly }) @@ -581,23 +587,33 @@ export async function screenshotWithAccessibilityLabels({ page, interactiveOnly // Get viewport size to clip screenshot to visible area const viewport = await page.evaluate('({ width: window.innerWidth, height: window.innerHeight })') as { width: 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 + const sharp = await sharpPromise + + // Clip dimensions: if sharp unavailable, limit capture area to MAX_DIMENSION + const clipWidth = sharp ? viewport.width : Math.min(viewport.width, MAX_DIMENSION) + const clipHeight = sharp ? viewport.height : Math.min(viewport.height, MAX_DIMENSION) + // Take viewport screenshot with scale: 'css' to ignore device pixel ratio const rawBuffer = await page.screenshot({ type: 'jpeg', quality: 80, scale: 'css', - clip: { x: 0, y: 0, width: viewport.width, height: viewport.height }, + clip: { x: 0, y: 0, width: clipWidth, height: clipHeight }, }) - // Resize to fit within Claude's optimal limits: - // - Max 1568px on any edge (larger gets auto-resized by Claude, adding latency) - // - Target ~1.15 megapixels for optimal token usage (~1,533 tokens) - // Token formula: tokens = (width * height) / 750 - const buffer = await Promise.resolve() - .then(() => import('sharp')) - .then(async ({ default: sharp }) => { - const MAX_DIMENSION = 1568 - return sharp(rawBuffer) + // Resize with sharp if available, otherwise use clipped raw buffer + const buffer = await (async () => { + if (!sharp) { + logger?.error('[playwriter] sharp not available, using clipped screenshot (max', MAX_DIMENSION, 'px)') + return rawBuffer + } + try { + return await sharp(rawBuffer) .resize({ width: MAX_DIMENSION, height: MAX_DIMENSION, @@ -606,8 +622,11 @@ export async function screenshotWithAccessibilityLabels({ page, interactiveOnly }) .jpeg({ quality: 80 }) .toBuffer() - }) - .catch(() => rawBuffer) // sharp not available, Claude will auto-resize + } catch (err) { + logger?.error('[playwriter] sharp resize failed, using raw buffer:', err) + return rawBuffer + } + })() // Save to file fs.writeFileSync(screenshotPath, buffer) diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index df179da..991a230 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -918,7 +918,11 @@ server.tool( const screenshotCollector: ScreenshotResult[] = [] const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => { - return screenshotWithAccessibilityLabels({ ...options, collector: screenshotCollector }) + return screenshotWithAccessibilityLabels({ + ...options, + collector: screenshotCollector, + logger: { error: (...args) => { console.error('[playwriter]', ...args) } }, + }) } let vmContextObj: VMContextWithGlobals = { @@ -1134,6 +1138,7 @@ export async function startMcp(options: { host?: string; token?: string } = {}) mcpLog(`Using remote CDP relay server: ${remote.host}:${remote.port}`) await checkRemoteServer(remote) } + const transport = new StdioServerTransport() await server.connect(transport) }