fix: sharp fallback with viewport clipping when unavailable

This commit is contained in:
Tommy D. Rossi
2026-01-19 14:07:01 +01:00
parent 6af2373fe6
commit 495035ccef
5 changed files with 67 additions and 29 deletions
+21 -14
View File
@@ -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 ## Usage
@@ -161,11 +168,11 @@ When set, the MCP will automatically create an initial tab when a Playwright cli
{ {
"mcpServers": { "mcpServers": {
"playwriter": { "playwriter": {
"command": "npx", "command": "npx",
"args": ["-y", "playwriter@latest"], "args": ["-y", "playwriter@latest"],
"env": { "env": {
"PLAYWRITER_AUTO_ENABLE": "1" "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": { "mcpServers": {
"playwriter": { "playwriter": {
"command": "npx", "command": "npx",
"args": ["-y", "playwriter@latest", "--host", "host.docker.internal", "--token", "<secret>"] "args": ["-y", "playwriter@latest", "--host", "host.docker.internal", "--token", "<secret>"]
} }
} }
} }
@@ -372,12 +379,12 @@ Or use environment variables (useful if you want to set them globally in your pr
{ {
"mcpServers": { "mcpServers": {
"playwriter": { "playwriter": {
"command": "npx", "command": "npx",
"args": ["-y", "playwriter@latest"], "args": ["-y", "playwriter@latest"],
"env": { "env": {
"PLAYWRITER_HOST": "host.docker.internal", "PLAYWRITER_HOST": "host.docker.internal",
"PLAYWRITER_TOKEN": "<secret>" "PLAYWRITER_TOKEN": "<secret>"
} }
} }
} }
} }
+7
View File
@@ -1,5 +1,12 @@
# Changelog # 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 ## 0.0.49
### Features ### Features
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "playwriter", "name": "playwriter",
"description": "", "description": "",
"version": "0.0.49", "version": "0.0.50",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
+32 -13
View File
@@ -2,6 +2,11 @@ import type { Page, Locator, ElementHandle } from 'playwright-core'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' 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 { export interface AriaRef {
role: string role: string
name: string name: string
@@ -558,10 +563,11 @@ export async function hideAriaRefLabels({ page }: { page: Page }): Promise<void>
* await page.locator('aria-ref=e5').click() * 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 page: Page
interactiveOnly?: boolean interactiveOnly?: boolean
collector: ScreenshotResult[] collector: ScreenshotResult[]
logger?: { error: (...args: unknown[]) => void }
}): Promise<void> { }): Promise<void> {
// Show labels and get snapshot // Show labels and get snapshot
const { snapshot, labelCount } = await showAriaRefLabels({ page, interactiveOnly }) 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 // 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 } 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 // Take viewport screenshot with scale: 'css' to ignore device pixel ratio
const rawBuffer = await page.screenshot({ const rawBuffer = await page.screenshot({
type: 'jpeg', type: 'jpeg',
quality: 80, quality: 80,
scale: 'css', 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: // Resize with sharp if available, otherwise use clipped raw buffer
// - Max 1568px on any edge (larger gets auto-resized by Claude, adding latency) const buffer = await (async () => {
// - Target ~1.15 megapixels for optimal token usage (~1,533 tokens) if (!sharp) {
// Token formula: tokens = (width * height) / 750 logger?.error('[playwriter] sharp not available, using clipped screenshot (max', MAX_DIMENSION, 'px)')
const buffer = await Promise.resolve() return rawBuffer
.then(() => import('sharp')) }
.then(async ({ default: sharp }) => { try {
const MAX_DIMENSION = 1568 return await sharp(rawBuffer)
return sharp(rawBuffer)
.resize({ .resize({
width: MAX_DIMENSION, width: MAX_DIMENSION,
height: MAX_DIMENSION, height: MAX_DIMENSION,
@@ -606,8 +622,11 @@ export async function screenshotWithAccessibilityLabels({ page, interactiveOnly
}) })
.jpeg({ quality: 80 }) .jpeg({ quality: 80 })
.toBuffer() .toBuffer()
}) } catch (err) {
.catch(() => rawBuffer) // sharp not available, Claude will auto-resize logger?.error('[playwriter] sharp resize failed, using raw buffer:', err)
return rawBuffer
}
})()
// Save to file // Save to file
fs.writeFileSync(screenshotPath, buffer) fs.writeFileSync(screenshotPath, buffer)
+6 -1
View File
@@ -918,7 +918,11 @@ server.tool(
const screenshotCollector: ScreenshotResult[] = [] const screenshotCollector: ScreenshotResult[] = []
const screenshotWithAccessibilityLabelsFn = async (options: { page: Page; interactiveOnly?: boolean }) => { 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 = { 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}`) mcpLog(`Using remote CDP relay server: ${remote.host}:${remote.port}`)
await checkRemoteServer(remote) await checkRemoteServer(remote)
} }
const transport = new StdioServerTransport() const transport = new StdioServerTransport()
await server.connect(transport) await server.connect(transport)
} }