diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 1e751238a7b12..2eec2d6fd3a83 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1303,7 +1303,8 @@ Defaults to `false`. ## screenshot-option-quality - `quality` <[int]> -The quality of the image, between 0-100. Not applicable to `png` images. +The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. +For `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. ## screenshot-option-path - `path` <[path]> @@ -1313,7 +1314,7 @@ relative path, then it is resolved relative to the current working directory. If saved to the disk. ## screenshot-option-type -- `type` <[ScreenshotType]<"png"|"jpeg">> +- `type` <[ScreenshotType]<"png"|"jpeg"|"webp">> Specify screenshot type, defaults to `png`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 8a852e3f0e13f..1ec06aa54a288 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -48,6 +48,7 @@ const ignores = [ "examples", "packages/*/lib/", "packages/playwright-core/bundles/utils/src/third_party/", + "packages/utils/webp/webp_codec.js", "packages/playwright-core/src/generated/*", "packages/playwright-core/src/third_party/", "packages/playwright-core/types/*", diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index da1cec18709e9..ed7f5c099bee9 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -13176,7 +13176,8 @@ export interface ElementHandle extends JSHandle { path?: string; /** - * The quality of the image, between 0-100. Not applicable to `png` images. + * The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For + * `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. */ quality?: number; @@ -13218,7 +13219,7 @@ export interface ElementHandle extends JSHandle { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; }): Promise; /** @@ -25108,7 +25109,8 @@ export interface LocatorScreenshotOptions { path?: string; /** - * The quality of the image, between 0-100. Not applicable to `png` images. + * The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For + * `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. */ quality?: number; @@ -25150,7 +25152,7 @@ export interface LocatorScreenshotOptions { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; } interface ElementHandleWaitForSelectorOptions { @@ -25825,7 +25827,8 @@ export interface PageScreenshotOptions { path?: string; /** - * The quality of the image, between 0-100. Not applicable to `png` images. + * The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For + * `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. */ quality?: number; @@ -25867,7 +25870,7 @@ export interface PageScreenshotOptions { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; } type Devices = { diff --git a/packages/playwright-core/.npmignore b/packages/playwright-core/.npmignore index cc0924db2356e..e94f2a14cee74 100644 --- a/packages/playwright-core/.npmignore +++ b/packages/playwright-core/.npmignore @@ -14,6 +14,8 @@ !lib/**/*.ttf !lib/**/*.json !lib/**/*.md +# Include the WebP codec WASM binary (see @utils/webp/webp). +!lib/**/*.wasm !lib/xdg-open !lib/**/manifest.webmanifest # Exclude injected files. A preprocessed version of these is included via lib/generated. diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 68be295d3a52b..b90676e26d7c6 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -3334,7 +3334,7 @@ export type ElementHandleQuerySelectorAllResult = { }; export type ElementHandleScreenshotParams = { timeout: number, - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, omitBackground?: boolean, caret?: 'hide' | 'initial', @@ -3348,7 +3348,7 @@ export type ElementHandleScreenshotParams = { style?: string, }; export type ElementHandleScreenshotOptions = { - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, omitBackground?: boolean, caret?: 'hide' | 'initial', @@ -4194,6 +4194,7 @@ export type PageExpectScreenshotParams = { threshold?: number, fullPage?: boolean, clip?: Rect, + type?: 'png' | 'jpeg' | 'webp', omitBackground?: boolean, caret?: 'hide' | 'initial', animations?: 'disabled' | 'allow', @@ -4217,6 +4218,7 @@ export type PageExpectScreenshotOptions = { threshold?: number, fullPage?: boolean, clip?: Rect, + type?: 'png' | 'jpeg' | 'webp', omitBackground?: boolean, caret?: 'hide' | 'initial', animations?: 'disabled' | 'allow', @@ -4241,7 +4243,7 @@ export type PageExpectScreenshotErrorDetails = { }; export type PageScreenshotParams = { timeout: number, - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, fullPage?: boolean, clip?: Rect, @@ -4257,7 +4259,7 @@ export type PageScreenshotParams = { style?: string, }; export type PageScreenshotOptions = { - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, fullPage?: boolean, clip?: Rect, diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index effa91c589d27..8f0a63c7a58b3 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -320,13 +320,15 @@ export async function convertInputFiles(files: string | FilePayload | string[] | return { payloads }; } -export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined { +export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' | 'webp' }): 'png' | 'jpeg' | 'webp' | undefined { if (options.path) { const mimeType = getMimeTypeForPath(options.path); if (mimeType === 'image/png') return 'png'; else if (mimeType === 'image/jpeg') return 'jpeg'; + else if (mimeType === 'image/webp') + return 'webp'; throw new Error(`path: unsupported mime type "${mimeType}"`); } return options.type; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 7a9640c1df7c6..9fd7c4848d790 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1898,7 +1898,7 @@ scheme.ElementHandleQuerySelectorAllResult = tObject({ }); scheme.ElementHandleScreenshotParams = tObject({ timeout: tFloat, - type: tOptional(tEnum(['png', 'jpeg'])), + type: tOptional(tEnum(['png', 'jpeg', 'webp'])), quality: tOptional(tInt), omitBackground: tOptional(tBoolean), caret: tOptional(tEnum(['hide', 'initial'])), @@ -2438,6 +2438,7 @@ scheme.PageExpectScreenshotParams = tObject({ threshold: tOptional(tFloat), fullPage: tOptional(tBoolean), clip: tOptional(tType('Rect')), + type: tOptional(tEnum(['png', 'jpeg', 'webp'])), omitBackground: tOptional(tBoolean), caret: tOptional(tEnum(['hide', 'initial'])), animations: tOptional(tEnum(['disabled', 'allow'])), @@ -2462,7 +2463,7 @@ scheme.PageExpectScreenshotErrorDetails = tObject({ }); scheme.PageScreenshotParams = tObject({ timeout: tFloat, - type: tOptional(tEnum(['png', 'jpeg'])), + type: tOptional(tEnum(['png', 'jpeg', 'webp'])), quality: tOptional(tInt), fullPage: tOptional(tBoolean), clip: tOptional(tType('Rect')), diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index aec8cc1249ac8..1c2a6219bbfcb 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -489,6 +489,8 @@ export class BidiPage implements PageDelegate { } async takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { + if (format === 'webp') + throw new Error('webp screenshots are not supported via WebDriver BiDi'); const rect = (documentRect || viewportRect)!; const { data } = await progress.race(this._session.send('browsingContext.captureScreenshot', { context: this._session.sessionId, diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 975dd370607e0..e8a99f60db211 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -3337,7 +3337,7 @@ export type ElementHandleQuerySelectorAllResult = { }; export type ElementHandleScreenshotParams = { timeout: number, - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, omitBackground?: boolean, caret?: 'hide' | 'initial', @@ -3351,7 +3351,7 @@ export type ElementHandleScreenshotParams = { style?: string, }; export type ElementHandleScreenshotOptions = { - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, omitBackground?: boolean, caret?: 'hide' | 'initial', @@ -4197,6 +4197,7 @@ export type PageExpectScreenshotParams = { threshold?: number, fullPage?: boolean, clip?: Rect, + type?: 'png' | 'jpeg' | 'webp', omitBackground?: boolean, caret?: 'hide' | 'initial', animations?: 'disabled' | 'allow', @@ -4220,6 +4221,7 @@ export type PageExpectScreenshotOptions = { threshold?: number, fullPage?: boolean, clip?: Rect, + type?: 'png' | 'jpeg' | 'webp', omitBackground?: boolean, caret?: 'hide' | 'initial', animations?: 'disabled' | 'allow', @@ -4244,7 +4246,7 @@ export type PageExpectScreenshotErrorDetails = { }; export type PageScreenshotParams = { timeout: number, - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, fullPage?: boolean, clip?: Rect, @@ -4260,7 +4262,7 @@ export type PageScreenshotParams = { style?: string, }; export type PageScreenshotOptions = { - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, fullPage?: boolean, clip?: Rect, diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 951930b8fb5bc..aee1443787dc8 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -247,7 +247,7 @@ export class CRPage implements PageDelegate { await this._mainFrameSession._client.send('Emulation.setDefaultBackgroundColorOverride', { color }); } - async takeScreenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { + async takeScreenshot(progress: Progress, format: 'png' | 'jpeg' | 'webp', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { const { visualViewport, contentSize, cssContentSize } = await progress.race(this._mainFrameSession._client.send('Page.getLayoutMetrics')); if (!documentRect) { documentRect = { diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index 5edd22b4b3cea..ab5944d2c45c1 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -459,7 +459,7 @@ export class FFPage implements PageDelegate { throw new Error('Not implemented'); } - async takeScreenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { + async takeScreenshot(progress: Progress, format: 'png' | 'jpeg' | 'webp', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { if (!documentRect) { const scrollOffset = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({ x: window.scrollX, y: window.scrollY })); documentRect = { @@ -470,7 +470,7 @@ export class FFPage implements PageDelegate { }; } const { data } = await progress.race(this._session.send('Page.screenshot', { - mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'), + mimeType: ('image/' + format) as ('image/png' | 'image/jpeg' | 'image/webp'), clip: documentRect, quality, omitDeviceScaleFactor: scale === 'css', diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts index b637071310b03..1d489f2f3dc35 100644 --- a/packages/playwright-core/src/server/page.ts +++ b/packages/playwright-core/src/server/page.ts @@ -719,7 +719,10 @@ export class Page extends SdkObject { return await this.screenshotter.screenshotPage(progress, options || {}); }; - const comparator = getComparator('image/png'); + // Reset to the actual format once options are validated inside the try below; + // validation (clip, quality, ...) must stay inside the try so its errors are + // surfaced as a friendly diff rather than thrown. + let comparator = getComparator('image/png'); let intermediateResult: { actual?: Buffer, previous?: Buffer, @@ -739,8 +742,7 @@ export class Page extends SdkObject { if (!options.expected && options.isNot) throw new Error('"not" matcher requires expected result'); const format = validateScreenshotOptions(options || {}); - if (format !== 'png') - throw new Error('Only PNG screenshots are supported'); + comparator = getComparator(`image/${format}`); let actual: Buffer | undefined; let previous: Buffer | undefined; const pollIntervals = [0, 100, 250, 500]; diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index 41acde79345fd..2771216221f41 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -35,7 +35,7 @@ declare global { } export type ScreenshotOptions = { - type?: 'png' | 'jpeg'; + type?: 'png' | 'jpeg' | 'webp'; quality?: number; omitBackground?: boolean; animations?: 'disabled' | 'allow'; @@ -297,7 +297,7 @@ export class Screenshotter { } } - private async _screenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean, options: ScreenshotOptions): Promise { + private async _screenshot(progress: Progress, format: 'png' | 'jpeg' | 'webp', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean, options: ScreenshotOptions): Promise { if ((options as any).__testHookBeforeScreenshot) await progress.race((options as any).__testHookBeforeScreenshot()); @@ -307,7 +307,8 @@ export class Screenshotter { const cleanupHighlight = await this._maskElements(progress, options); try { - const quality = format === 'jpeg' ? options.quality ?? 80 : undefined; + // webp treats quality 100 (or omitted) as lossless, so default it to that. + const quality = format === 'jpeg' ? options.quality ?? 80 : format === 'webp' ? options.quality ?? 100 : undefined; const buffer = await this._page.delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device'); await progress.race(cleanupHighlight()); if (shouldSetDefaultBackground) @@ -353,12 +354,12 @@ function trimClipToSize(clip: types.Rect, size: types.Size): types.Rect { return result; } -export function validateScreenshotOptions(options: ScreenshotOptions): 'png' | 'jpeg' { - let format: 'png' | 'jpeg' | null = null; +export function validateScreenshotOptions(options: ScreenshotOptions): 'png' | 'jpeg' | 'webp' { + let format: 'png' | 'jpeg' | 'webp' | null = null; // options.type takes precedence over inferring the type from options.path // because it may be a 0-length file with no extension created beforehand (i.e. as a temp file). if (options.type) { - assert(options.type === 'png' || options.type === 'jpeg', 'Unknown options.type value: ' + options.type); + assert(options.type === 'png' || options.type === 'jpeg' || options.type === 'webp', 'Unknown options.type value: ' + options.type); format = options.type; } @@ -366,7 +367,7 @@ export function validateScreenshotOptions(options: ScreenshotOptions): 'png' | ' format = 'png'; if (options.quality !== undefined) { - assert(format === 'jpeg', 'options.quality is unsupported for the ' + format + ' screenshots'); + assert(format !== 'png', 'options.quality is unsupported for the ' + format + ' screenshots'); assert(typeof options.quality === 'number', 'Expected options.quality to be a number but found ' + (typeof options.quality)); assert(Number.isInteger(options.quality), 'Expected options.quality to be an integer'); assert(options.quality >= 0 && options.quality <= 100, 'Expected options.quality to be between 0 and 100 (inclusive), got ' + options.quality); diff --git a/packages/playwright-core/src/server/webkit/webview/wvPage.ts b/packages/playwright-core/src/server/webkit/webview/wvPage.ts index 3552a0bd6553f..57f61147cf2b0 100644 --- a/packages/playwright-core/src/server/webkit/webview/wvPage.ts +++ b/packages/playwright-core/src/server/webkit/webview/wvPage.ts @@ -23,6 +23,7 @@ import { ManualPromise } from '@isomorphic/manualPromise'; import { splitErrorMessage } from '@utils/stackTrace'; import { debugLogger } from '@utils/debugLogger'; import { eventsHelper } from '@utils/eventsHelper'; +import { encodeWebp } from '@utils/webp/webp'; import * as dialog from '../../dialog'; import * as dom from '../../dom'; import { TargetClosedError } from '../../errors'; @@ -767,8 +768,13 @@ export class WVPage implements PageDelegate { const result = await progress.race(this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport' })); const prefix = 'data:image/png;base64,'; let buffer: Buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); - if (format === 'jpeg') + if (format === 'jpeg') { buffer = jpegjs.encode(PNG.sync.read(buffer), quality).data; + } else if (format === 'webp') { + const png = PNG.sync.read(buffer); + // Match the native WebKit encoder: webp quality 100 (or omitted) is lossless. + buffer = (quality === undefined || quality >= 100) ? encodeWebp(png, { lossless: true }) : encodeWebp(png, { quality }); + } return buffer; } diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index dcc17c8b14f25..d163dd950307e 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -15,10 +15,13 @@ * limitations under the License. */ +import { PNG } from 'pngjs'; + import { headersArrayToObject, headersObjectToArray } from '@isomorphic/headers'; import { splitErrorMessage } from '@utils/stackTrace'; import { eventsHelper } from '@utils/eventsHelper'; import { hostPlatform } from '@utils/hostPlatform'; +import { encodeWebp } from '@utils/webp/webp'; import { assert } from '@isomorphic/assert'; import * as dialog from '../dialog'; import * as dom from '../dom'; @@ -871,9 +874,18 @@ export class WKPage implements PageDelegate { const omitDeviceScaleFactor = scale === 'css'; this.validateScreenshotDimension(rect.width, omitDeviceScaleFactor); this.validateScreenshotDimension(rect.height, omitDeviceScaleFactor); - const result = await progress.race(this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor, format: format as 'png' | 'jpeg', quality })); + // WebKit on macOS has no built-in WebP encoder, so capture a PNG and re-encode it. + const recodePngToWebp = format === 'webp' && process.platform === 'darwin'; + const result = await progress.race(this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor, format: (recodePngToWebp ? 'png' : format) as 'png' | 'jpeg' | 'webp', quality: recodePngToWebp ? undefined : quality })); // Strip the 'data:image/;base64,' prefix. - return Buffer.from(result.dataURL.substring(result.dataURL.indexOf(',') + 1), 'base64'); + const buffer = Buffer.from(result.dataURL.substring(result.dataURL.indexOf(',') + 1), 'base64'); + if (recodePngToWebp) { + const png = PNG.sync.read(buffer); + const image = { width: png.width, height: png.height, data: png.data }; + // Match the native WebKit encoder: webp quality 100 (or omitted) is lossless. + return (quality === undefined || quality >= 100) ? encodeWebp(image, { lossless: true }) : encodeWebp(image, { quality }); + } + return buffer; } async getContentFrame(handle: dom.ElementHandle): Promise { diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index 431d4c28ab127..5a35236996240 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -58,7 +58,7 @@ export class Response { readonly toolName: string; readonly toolArgs: Record; private _clientWorkspace: string; - private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = []; + private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' | 'webp' }[] = []; private _raw: boolean; private _json: boolean; private _writtenFiles = new Set(); @@ -127,7 +127,7 @@ export class Response { this.addTextResult(`- [${title}](${relativeName})`); } - async registerImageResult(data: Buffer, imageType: 'png' | 'jpeg') { + async registerImageResult(data: Buffer, imageType: 'png' | 'jpeg' | 'webp') { this._imageResults.push({ data, imageType }); } @@ -209,7 +209,7 @@ export class Response { if (this._context.config.imageResponses !== 'omit') { for (const imageResult of this._imageResults) { const scaledData = scaleImageToFitMessage(imageResult.data, imageResult.imageType); - content.push({ type: 'image', data: scaledData.toString('base64'), mimeType: imageResult.imageType === 'png' ? 'image/png' : 'image/jpeg' }); + content.push({ type: 'image', data: scaledData.toString('base64'), mimeType: `image/${imageResult.imageType}` }); } } diff --git a/packages/playwright-core/src/tools/backend/screenshot.ts b/packages/playwright-core/src/tools/backend/screenshot.ts index 0f83b638ace85..e060a48c718ce 100644 --- a/packages/playwright-core/src/tools/backend/screenshot.ts +++ b/packages/playwright-core/src/tools/backend/screenshot.ts @@ -14,24 +14,41 @@ * limitations under the License. */ +import path from 'path'; + import jpegjs from 'jpeg-js'; import { PNG } from 'pngjs'; import * as z from 'zod'; import { formatObject } from '@isomorphic/stringUtils'; import { scaleImageToSize } from '@isomorphic/imageUtils'; +import { decodeWebp, encodeWebp, isLosslessWebp } from '@utils/webp/webp'; import { defineTabTool } from './tool'; import { optionalElementSchema } from './snapshot'; import type * as playwright from '../../..'; +type ImageFormat = 'png' | 'jpeg' | 'webp'; + const screenshotSchema = optionalElementSchema.extend({ - type: z.enum(['png', 'jpeg']).default('png').describe('Image format for the screenshot. Default is png.'), - filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. Prefer relative file names to stay within the output directory.'), + type: z.enum(['png', 'jpeg', 'webp']).optional().describe('Image format for the screenshot. If unset, inferred from the filename extension, otherwise png.'), + filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg|webp}` if not specified. Prefer relative file names to stay within the output directory.'), fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.'), scale: z.enum(['css', 'device']).default('css').describe('Image resolution scale. "css" produces a screenshot sized in CSS pixels (smaller, consistent across devices). "device" produces a high-resolution screenshot using device pixels (larger, accounts for the device pixel ratio). Default is css.'), }); +function inferTypeFromFilename(filename: string | undefined): ImageFormat | undefined { + if (!filename) + return undefined; + switch (path.extname(filename).toLowerCase()) { + case '.png': return 'png'; + case '.jpg': + case '.jpeg': return 'jpeg'; + case '.webp': return 'webp'; + } + return undefined; +} + const screenshot = defineTabTool({ capability: 'core', schema: { @@ -46,10 +63,10 @@ const screenshot = defineTabTool({ if (params.fullPage && params.target) throw new Error('fullPage cannot be used with element screenshots.'); - const fileType = params.type || 'png'; + const fileType: ImageFormat = params.type ?? inferTypeFromFilename(params.filename) ?? 'png'; const options: playwright.PageScreenshotOptions = { type: fileType, - quality: fileType === 'png' ? undefined : 90, + quality: fileType === 'jpeg' ? 90 : undefined, scale: params.scale, ...tab.actionTimeoutOptions, ...(params.fullPage !== undefined && { fullPage: params.fullPage }) @@ -73,11 +90,18 @@ const screenshot = defineTabTool({ } }); -export function scaleImageToFitMessage(buffer: Buffer, imageType: 'png' | 'jpeg'): Buffer { +export function scaleImageToFitMessage(buffer: Buffer, imageType: 'png' | 'jpeg' | 'webp'): Buffer { // https://docs.claude.com/en/docs/build-with-claude/vision#evaluate-image-size // Not more than 1.15 megapixel, linear size not more than 1568. - const image = imageType === 'png' ? PNG.sync.read(buffer) : jpegjs.decode(buffer, { maxMemoryUsageInMB: 512 }); + const decode = () => { + if (imageType === 'png') + return PNG.sync.read(buffer); + if (imageType === 'webp') + return decodeWebp(buffer); + return jpegjs.decode(buffer, { maxMemoryUsageInMB: 512 }); + }; + const image = decode(); const pixels = image.width * image.height; const shrink = Math.min(1568 / image.width, 1568 / image.height, Math.sqrt(1.15 * 1024 * 1024 / pixels)); @@ -87,6 +111,8 @@ export function scaleImageToFitMessage(buffer: Buffer, imageType: 'png' | 'jpeg' const width = image.width * shrink | 0; const height = image.height * shrink | 0; const scaledImage = scaleImageToSize(image, { width, height }); + if (imageType === 'webp') + return encodeWebp(scaledImage, isLosslessWebp(buffer) ? { lossless: true } : { quality: 80 }); // eslint-disable-next-line no-restricted-syntax return imageType === 'png' ? PNG.sync.write(scaledImage as any) : jpegjs.encode(scaledImage, 80).data; } diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 7505f6a3a466e..a83089d6f0282 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -789,12 +789,13 @@ const screenshot = declareCommand({ target: z.string().optional().describe(elementTargetDescription), }), options: z.object({ - filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'), + filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg|webp}` if not specified.'), + type: z.enum(['png', 'jpeg', 'webp']).optional().describe('Image format. If unset, inferred from the filename extension, otherwise png.'), ['full-page']: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport.'), hires: z.boolean().optional().describe('When true, captures a high-resolution screenshot using device pixels (accounts for the device pixel ratio), instead of CSS pixels.'), }), toolName: 'browser_take_screenshot', - toolParams: ({ target, filename, ['full-page']: fullPage, hires }) => ({ filename, target, fullPage, scale: hires ? 'device' : undefined }), + toolParams: ({ target, filename, type, ['full-page']: fullPage, hires }) => ({ filename, target, type, fullPage, scale: hires ? 'device' : undefined }), }); const pdfSave = declareCommand({ diff --git a/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts b/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts index 0074b5d5cdf7d..7590700a16382 100644 --- a/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts +++ b/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts @@ -56,7 +56,10 @@ function generateCommandHelp(command: AnyCommandSchema) { const optionsShape = (command.options as zodType.ZodObject).shape; for (const [name, schema] of Object.entries(optionsShape)) { const zodSchema = schema as zodType.ZodTypeAny; - const description = (zodSchema.description ?? '').toLowerCase(); + let description = (zodSchema.description ?? '').toLowerCase(); + const unwrapped = unwrapZodType(zodSchema); + if (unwrapped instanceof z.ZodEnum) + description = `${description} (one of: ${unwrapped.options.join(', ')})`.trim(); lines.push(formatWithGap(` --${name}`, description)); } } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index da1cec18709e9..ed7f5c099bee9 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13176,7 +13176,8 @@ export interface ElementHandle extends JSHandle { path?: string; /** - * The quality of the image, between 0-100. Not applicable to `png` images. + * The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For + * `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. */ quality?: number; @@ -13218,7 +13219,7 @@ export interface ElementHandle extends JSHandle { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; }): Promise; /** @@ -25108,7 +25109,8 @@ export interface LocatorScreenshotOptions { path?: string; /** - * The quality of the image, between 0-100. Not applicable to `png` images. + * The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For + * `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. */ quality?: number; @@ -25150,7 +25152,7 @@ export interface LocatorScreenshotOptions { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; } interface ElementHandleWaitForSelectorOptions { @@ -25825,7 +25827,8 @@ export interface PageScreenshotOptions { path?: string; /** - * The quality of the image, between 0-100. Not applicable to `png` images. + * The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For + * `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression. */ quality?: number; @@ -25867,7 +25870,7 @@ export interface PageScreenshotOptions { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; } type Devices = { diff --git a/packages/playwright/src/matchers/toMatchSnapshot.ts b/packages/playwright/src/matchers/toMatchSnapshot.ts index d835b1302a7ae..683a6a7b352d7 100644 --- a/packages/playwright/src/matchers/toMatchSnapshot.ts +++ b/packages/playwright/src/matchers/toMatchSnapshot.ts @@ -343,8 +343,9 @@ export async function toHaveScreenshot( const [page, locator] = (pageOrLocator as any)._apiName === 'Page' ? [(pageOrLocator as PageEx), undefined] : [(pageOrLocator as Locator).page() as PageEx, pageOrLocator as Locator]; const configOptions = expectConfig().toHaveScreenshot || {}; const helper = new SnapshotHelper(this, testInfo, 'toHaveScreenshot', locator, undefined, configOptions, nameOrOptions, optOptions); - if (!helper.expectedPath.toLowerCase().endsWith('.png')) - throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have '.png' extension`); + const screenshotType = ({ 'image/png': 'png', 'image/webp': 'webp' } as const)[helper.mimeType]; + if (!screenshotType) + throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have a '.png' or '.webp' extension`); expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot'); const style = await loadScreenshotStyles(helper.options.stylePath); const timeout = helper.options.timeout ?? this.timeout; @@ -361,6 +362,7 @@ export async function toHaveScreenshot( style, isNot: !!this.isNot, timeout, + type: screenshotType, comparator: helper.options.comparator, maxDiffPixels: helper.options.maxDiffPixels, maxDiffPixelRatio: helper.options.maxDiffPixelRatio, diff --git a/packages/protocol/spec/handles.yml b/packages/protocol/spec/handles.yml index 54440f7018607..8818c936d1b18 100644 --- a/packages/protocol/spec/handles.yml +++ b/packages/protocol/spec/handles.yml @@ -423,6 +423,7 @@ ElementHandle: literals: - png - jpeg + - webp quality: int? $mixin: CommonScreenshotOptions returns: diff --git a/packages/protocol/spec/page.yml b/packages/protocol/spec/page.yml index 22ec5a2d53c73..06e71d2ae773d 100644 --- a/packages/protocol/spec/page.yml +++ b/packages/protocol/spec/page.yml @@ -186,6 +186,12 @@ Page: threshold: float? fullPage: boolean? clip: Rect? + type: + type: enum? + literals: + - png + - jpeg + - webp $mixin: CommonScreenshotOptions returns: actual: binary? @@ -211,6 +217,7 @@ Page: literals: - png - jpeg + - webp quality: int? fullPage: boolean? clip: Rect? diff --git a/packages/utils/DEPS.list b/packages/utils/DEPS.list index 0834f57c8e073..db4f4ba44dcb6 100644 --- a/packages/utils/DEPS.list +++ b/packages/utils/DEPS.list @@ -16,6 +16,7 @@ node_modules/yazl [comparators.ts] ./image_tools ./third_party/pixelmatch +./webp/webp [env.ts] "strict" diff --git a/packages/utils/comparators.ts b/packages/utils/comparators.ts index 8e76ad0342136..8fa72296dde7a 100644 --- a/packages/utils/comparators.ts +++ b/packages/utils/comparators.ts @@ -23,6 +23,7 @@ import { padImageToSize } from '@isomorphic/imageUtils'; // @ts-ignore import pixelmatch from './third_party/pixelmatch'; import { compare } from './image_tools/compare'; +import { decodeWebp } from './webp/webp'; import type { ImageData } from '@isomorphic/imageUtils'; @@ -35,6 +36,8 @@ export function getComparator(mimeType: string): Comparator { return compareImages.bind(null, 'image/png'); if (mimeType === 'image/jpeg') return compareImages.bind(null, 'image/jpeg'); + if (mimeType === 'image/webp') + return compareImages.bind(null, 'image/webp'); if (mimeType === 'text/plain') return compareText; return compareBuffersOrStrings; @@ -57,8 +60,15 @@ function compareImages(mimeType: string, actualBuffer: Buffer | string, expected return { errorMessage: 'Actual result should be a Buffer.' }; validateBuffer(expectedBuffer, mimeType); - let actual: ImageData = mimeType === 'image/png' ? PNG.sync.read(actualBuffer) : jpegjs.decode(actualBuffer, { maxMemoryUsageInMB: JPEG_JS_MAX_BUFFER_SIZE_IN_MB }); - let expected: ImageData = mimeType === 'image/png' ? PNG.sync.read(expectedBuffer) : jpegjs.decode(expectedBuffer, { maxMemoryUsageInMB: JPEG_JS_MAX_BUFFER_SIZE_IN_MB }); + const decode = (buffer: Buffer): ImageData => { + if (mimeType === 'image/png') + return PNG.sync.read(buffer); + if (mimeType === 'image/webp') + return decodeWebp(buffer); + return jpegjs.decode(buffer, { maxMemoryUsageInMB: JPEG_JS_MAX_BUFFER_SIZE_IN_MB }); + }; + let actual: ImageData = decode(actualBuffer); + let expected: ImageData = decode(expectedBuffer); const size = { width: Math.max(expected.width, actual.width), height: Math.max(expected.height, actual.height) }; let sizesMismatchError = ''; if (expected.width !== actual.width || expected.height !== actual.height) { @@ -105,6 +115,10 @@ function validateBuffer(buffer: Buffer, mimeType: string): void { const jpegMagicNumber = [255, 216]; if (buffer.length < jpegMagicNumber.length || !jpegMagicNumber.every((byte, index) => buffer[index] === byte)) throw new Error('Could not decode expected image as JPEG.'); + } else if (mimeType === 'image/webp') { + // A WebP bitstream is a RIFF container tagged 'WEBP': "RIFF????WEBP". + if (buffer.length < 12 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP') + throw new Error('Could not decode expected image as WEBP.'); } } diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 57ea9146b9587..682d36fc31360 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -19,6 +19,7 @@ export * from './chromiumChannels'; export * from './comparators'; export * from './crypto'; export * from './debug'; +export * from './webp/webp'; export * from './debugLogger'; export * from './env'; export * from './eventsHelper'; diff --git a/packages/utils/webp/webp.ts b/packages/utils/webp/webp.ts new file mode 100644 index 0000000000000..e308bd3a07358 --- /dev/null +++ b/packages/utils/webp/webp.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Synchronous WebP encode/decode backed by our own libwebp WASM build. The +// codec module (webp_codec.js + webp_codec.wasm) is produced by +// utils/libwebp-wasm/build.sh; see that folder for the build and its license. + +import fs from 'fs'; +import path from 'path'; + +// @ts-ignore - generated Emscripten glue, no type declarations. +import initWebpModule from './webp_codec'; + +export type WebpImage = { width: number, height: number, data: Buffer }; + +type WebpModule = { + _malloc(size: number): number; + _free(ptr: number): void; + _webp_free(ptr: number): void; + _webp_encode_rgba(rgba: number, width: number, height: number, quality: number, lossless: number, outSize: number): number; + _webp_decode_rgba(data: number, size: number, widthPtr: number, heightPtr: number): number; + HEAPU8: Uint8Array; + HEAPU32: Uint32Array; +}; + +let module: WebpModule | undefined; + +function webpModule(): WebpModule { + if (!module) { + // The module is built with WASM_ASYNC_COMPILATION=0, so the factory + // instantiates synchronously and attaches the exports to `instance` before + // returning; the returned promise is ignored. We pass the .wasm bytes + // explicitly (it sits next to this file / the bundle) rather than relying on + // Emscripten's own file lookup. + const instance: any = { wasmBinary: fs.readFileSync(path.join(__dirname, 'webp_codec.wasm')) }; + initWebpModule(instance); + module = instance as WebpModule; + } + return module; +} + +export function encodeWebp(image: WebpImage, options: { quality?: number, lossless?: boolean } = {}): Buffer { + // For lossy, `quality` is the 0..100 quality factor; for lossless it is the + // 0..100 compression effort (higher = smaller/slower). + const { quality = 75, lossless = false } = options; + const m = webpModule(); + const inPtr = m._malloc(image.data.length); + m.HEAPU8.set(image.data, inPtr); + const sizePtr = m._malloc(4); + const outPtr = m._webp_encode_rgba(inPtr, image.width, image.height, quality, lossless ? 1 : 0, sizePtr); + const size = m.HEAPU32[sizePtr >> 2]; + let out: Buffer | undefined; + if (outPtr && size) { + out = Buffer.from(m.HEAPU8.subarray(outPtr, outPtr + size)); + m._webp_free(outPtr); + } + m._free(inPtr); + m._free(sizePtr); + if (!out) + throw new Error('WebP encode failed'); + return out; +} + +export function decodeWebp(buffer: Buffer): WebpImage { + const m = webpModule(); + const inPtr = m._malloc(buffer.length); + m.HEAPU8.set(buffer, inPtr); + const widthPtr = m._malloc(4); + const heightPtr = m._malloc(4); + const outPtr = m._webp_decode_rgba(inPtr, buffer.length, widthPtr, heightPtr); + const width = m.HEAPU32[widthPtr >> 2]; + const height = m.HEAPU32[heightPtr >> 2]; + let result: WebpImage | undefined; + if (outPtr) { + const data = Buffer.from(m.HEAPU8.subarray(outPtr, outPtr + width * height * 4)); + result = { data, width, height }; + m._webp_free(outPtr); + } + m._free(inPtr); + m._free(widthPtr); + m._free(heightPtr); + if (!result) + throw new Error('WebP decode failed'); + return result; +} + +// Whether a WebP bitstream is lossless, determined by parsing the RIFF header. +// The frame is 'VP8L' (lossless) or 'VP8 ' (lossy); the 'VP8X' extended format +// wraps one of those, so scan its chunks. (The lossy quality factor is an +// encoder input and is not stored in the bitstream, so it cannot be recovered.) +export function isLosslessWebp(buffer: Buffer): boolean { + if (buffer.length < 16 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP') + return false; + let offset = 12; + while (offset + 8 <= buffer.length) { + const fourcc = buffer.toString('ascii', offset, offset + 4); + if (fourcc === 'VP8L') + return true; + if (fourcc === 'VP8 ') + return false; + // Skip this chunk (8-byte header + payload, padded to an even size) and + // keep scanning — only 'VP8X' is expected to lead here. + if (fourcc !== 'VP8X') + return false; + const size = buffer.readUInt32LE(offset + 4); + offset += 8 + size + (size & 1); + } + return false; +} diff --git a/packages/utils/webp/webp_codec.LICENSE b/packages/utils/webp/webp_codec.LICENSE new file mode 100644 index 0000000000000..b7275562ad005 --- /dev/null +++ b/packages/utils/webp/webp_codec.LICENSE @@ -0,0 +1,173 @@ +Third-party license notices for the bundled WebP codec (webp_codec.wasm and +its Emscripten glue webp_codec.js). + +webp_codec.wasm is compiled from libwebp (https://github.com/webmproject/libwebp) +and webp_codec.js is generated by Emscripten (https://emscripten.org). Their +licenses are reproduced below. + +================================================================================ +libwebp — BSD 3-Clause License +================================================================================ + +Copyright (c) 2010, Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of Google nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +-------------------------------------------------------------------------------- +Additional IP Rights Grant (Patents) +------------------------------------ + +"These implementations" means the copyrightable works that implement the WebM +codecs distributed by Google as part of the WebM Project. + +Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable (except as stated in this section) patent license to +make, have made, use, offer to sell, sell, import, transfer, and otherwise +run, modify and propagate the contents of these implementations of WebM, where +such license applies only to those patent claims, both currently owned by +Google and acquired in the future, licensable by Google that are necessarily +infringed by these implementations of WebM. This grant does not include claims +that would be infringed only as a consequence of further modification of these +implementations. If you or your agent or exclusive licensee institute or order +or agree to the institution of patent litigation or any other patent +enforcement activity against any entity (including a cross-claim or +counterclaim in a lawsuit) alleging that any of these implementations of WebM +or any code incorporated within any of these implementations of WebM +constitute direct or contributory patent infringement, or inducement of +patent infringement, then any patent rights granted to you under this License +for these implementations of WebM shall terminate as of the date such +litigation is filed. + +================================================================================ +Emscripten (webp_codec.js glue + runtime linked into webp_codec.wasm) +================================================================================ + +Emscripten is available under 2 licenses, the MIT license and the +University of Illinois/NCSA Open Source License. + +Both are permissive open source licenses, with little if any +practical difference between them. + +The reason for offering both is that (1) the MIT license is +well-known, while (2) the University of Illinois/NCSA Open Source +License allows Emscripten's code to be integrated upstream into +LLVM, which uses that license, should the opportunity arise. + +The full text of both licenses follows. + +============================================================================== + +Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +============================================================================== + +Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file. +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal with the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimers. + + Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimers + in the documentation and/or other materials provided with the + distribution. + + Neither the names of Mozilla, + nor the names of its contributors may be used to endorse + or promote products derived from this Software without specific prior + written permission. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR +ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE. + +============================================================================== + +This program uses portions of Node.js source code located in src/library_path.js, +in accordance with the terms of the MIT license. Node's license follows: + + """ + Copyright Joyent, Inc. and other Node contributors. All rights reserved. + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal in the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + IN THE SOFTWARE. + """ + +The musl libc project is bundled in this repo, and it has the MIT license, see +system/lib/libc/musl/COPYRIGHT + +The third_party/ subdirectory contains code with other licenses. None of it is +used by default, but certain options use it (e.g., the optional closure compiler +flag will run closure compiler from third_party/). + diff --git a/packages/utils/webp/webp_codec.js b/packages/utils/webp/webp_codec.js new file mode 100644 index 0000000000000..ba8378fd019f1 --- /dev/null +++ b/packages/utils/webp/webp_codec.js @@ -0,0 +1,2 @@ +var Module=(()=>{var _scriptName=globalThis.document?.currentScript?.src;return async function(moduleArg={}){var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";var programArgs=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};if(typeof __filename!="undefined"){_scriptName=__filename}else{}var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");scriptDirectory=__dirname+"/";readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}programArgs=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var EXITSTATUS;var isFileURI=filename=>filename.startsWith("file://");class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var runtimeInitialized=false;function getMemoryBuffer(){try{var b=wasmMemory.toResizableBuffer();return b}catch{}return wasmMemory.buffer}function updateMemoryViews(){if(HEAP8?.buffer?.resizable)return;var b=getMemoryBuffer();HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){var preRun=Module["preRun"];if(preRun){if(typeof preRun=="function")preRun=[preRun];onPreRuns.push(...preRun)}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["g"]()}function postRun(){var postRun=Module["postRun"];if(postRun){if(typeof postRun=="function")postRun=[postRun];onPostRuns.push(...postRun)}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("webp_codec.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw'sync fetching of the wasm failed: you can preload it to Module["wasmBinary"] manually, or emcc.py will do that for you when generating HTML (but not JS)'}function instantiateSync(file,info){var module;var binary=getBinarySync(file);module=new WebAssembly.Module(binary);var instance=new WebAssembly.Instance(module,info);return[instance,module]}function getWasmImports(){var imports={a:wasmImports};return imports}function createWasm(){function receiveInstance(instance){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}var info=getWasmImports();var instantiateWasm=Module["instantiateWasm"];if(instantiateWasm){return new Promise(resolve=>{instantiateWasm(info,inst=>resolve(receiveInstance(inst)))})}wasmBinaryFile??=findWasmBinary();var result=instantiateSync(wasmBinaryFile,info);return receiveInstance(result[0])}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var onPreRuns=[];var noExitRuntime=true;var __abort_js=()=>abort("");var runtimeKeepaliveCounter=0;var __emscripten_runtime_keepalive_clear=()=>{noExitRuntime=false;runtimeKeepaliveCounter=0};var timers={};var handleException=e=>{if(e instanceof ExitStatus||e=="unwind"){return EXITSTATUS}quit_(1,e)};var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status)};var _exit=exitJS;var maybeExit=()=>{if(!keepRuntimeAlive()){try{_exit(EXITSTATUS)}catch(e){handleException(e)}}};var callUserCallback=func=>{if(ABORT){return}try{return func()}catch(e){handleException(e)}finally{maybeExit()}};var _emscripten_get_now=()=>performance.now();var __setitimer_js=(which,timeout_ms)=>{if(timers[which]){clearTimeout(timers[which].id);delete timers[which]}if(!timeout_ms)return 0;var id=setTimeout(()=>{delete timers[which];callUserCallback(()=>__emscripten_timeout(which,_emscripten_get_now()))},timeout_ms);timers[which]={id,timeout_ms};return 0};var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])programArgs=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var preInit=Module["preInit"];if(preInit){if(typeof preInit=="function")Module["preInit"]=preInit=[preInit];while(preInit.length>0){preInit.shift()()}}}var _webp_encode_rgba,_webp_decode_rgba,_webp_free,_malloc,_free,__emscripten_timeout,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_webp_encode_rgba=Module["_webp_encode_rgba"]=wasmExports["h"];_webp_decode_rgba=Module["_webp_decode_rgba"]=wasmExports["i"];_webp_free=Module["_webp_free"]=wasmExports["j"];_malloc=Module["_malloc"]=wasmExports["k"];_free=Module["_free"]=wasmExports["l"];__emscripten_timeout=wasmExports["m"];memory=wasmMemory=wasmExports["f"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={e:__abort_js,d:__emscripten_runtime_keepalive_clear,a:__setitimer_js,b:_emscripten_resize_heap,c:_proc_exit};async function run(){preRun();var setStatus=Module["setStatus"];if(setStatus){setStatus("Running...");await new Promise(resolve=>setTimeout(resolve,1));setTimeout(setStatus,1,"")}if(ABORT)return;initRuntime();Module["onRuntimeInitialized"]?.();postRun()}var wasmExports;wasmExports=createWasm();await run(); +;return Module}})();if(typeof exports==="object"&&typeof module==="object"){module.exports=Module;module.exports.default=Module}else if(typeof define==="function"&&define["amd"])define([],()=>Module); diff --git a/packages/utils/webp/webp_codec.wasm b/packages/utils/webp/webp_codec.wasm new file mode 100755 index 0000000000000..123677272bfbc Binary files /dev/null and b/packages/utils/webp/webp_codec.wasm differ diff --git a/tests/installation/bundle-licenses.spec.ts b/tests/installation/bundle-licenses.spec.ts index c1a52decfa912..9f2462a3fe1b8 100644 --- a/tests/installation/bundle-licenses.spec.ts +++ b/tests/installation/bundle-licenses.spec.ts @@ -19,10 +19,15 @@ import { test, expect } from './npmTest'; // Lower bounds on the number of inlined npm packages per LICENSE. If a bundle drops below // these, something is broken with bundle generation or dependencies were silently removed. +// Package count per bundle .LICENSE sidecar; `0` marks a non-bundle license +// (e.g. a vendored binary's third-party license) that has no "Total Packages" +// summary and is only checked for presence. const EXPECTED: Record> = { 'playwright-core': { 'lib/serverRegistry.js.LICENSE': 10, 'lib/utilsBundle.js.LICENSE': 80, + // Vendored WebP codec (libwebp compiled to WASM), not a bundle sidecar. + 'lib/webp_codec.LICENSE': 0, }, 'playwright': { 'lib/matchers/expect.js.LICENSE': 20, @@ -65,6 +70,11 @@ for (const [pkg, licenses] of Object.entries(EXPECTED)) { for (const [relPath, minPackages] of Object.entries(licenses)) { const absPath = path.join(extractDir, 'package', relPath); const contents = await fs.promises.readFile(absPath, 'utf8'); + if (!minPackages) { + // Non-bundle license (no "Total Packages" summary) — just verify it ships. + expect(contents.trim().length, `${pkg}/${relPath} is empty`).toBeGreaterThan(0); + continue; + } const match = contents.match(/^Total Packages: (\d+)$/m); expect(match, `${pkg}/${relPath} is missing the "Total Packages" summary line`).toBeTruthy(); const count = Number(match![1]); diff --git a/tests/mcp/cli-save-as.spec.ts b/tests/mcp/cli-save-as.spec.ts index 7aaf9c2ca415e..e6b3309c08966 100644 --- a/tests/mcp/cli-save-as.spec.ts +++ b/tests/mcp/cli-save-as.spec.ts @@ -53,6 +53,17 @@ test('screenshot --filename', async ({ cli, server, mcpBrowser }) => { expect(attachments[0].data).toEqual(expect.any(Buffer)); }); +test('screenshot --filename infers webp from extension', async ({ cli, server, mcpBrowser }) => { + test.skip(mcpBrowser === 'webkit' && process.platform === 'darwin', 'CG on macOS does not include a webp encoder UTI'); + + await cli('open', server.HELLO_WORLD); + const { output, attachments } = await cli('screenshot', '--filename=screenshot.webp'); + expect(output).toContain('[Screenshot of viewport](./screenshot.webp)'); + const buffer = attachments[0].data as Buffer; + expect(buffer.subarray(0, 4).toString('latin1')).toBe('RIFF'); + expect(buffer.subarray(8, 12).toString('latin1')).toBe('WEBP'); +}); + test('pdf', async ({ cli, server, mcpBrowser }) => { test.skip(mcpBrowser !== 'chromium' && mcpBrowser !== 'chrome', 'PDF is only supported in Chromium and Chrome'); await cli('open', server.HELLO_WORLD); diff --git a/tests/mcp/screenshot.spec.ts b/tests/mcp/screenshot.spec.ts index 16d84b5640b5f..325380ff1fc51 100644 --- a/tests/mcp/screenshot.spec.ts +++ b/tests/mcp/screenshot.spec.ts @@ -96,8 +96,10 @@ test('--output-dir should work', async ({ startClient, server }, testInfo) => { expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.png$/); }); -for (const type of ['png', 'jpeg']) { - test(`browser_take_screenshot (type: ${type})`, async ({ startClient, server }, testInfo) => { +for (const type of ['png', 'jpeg', 'webp']) { + test(`browser_take_screenshot (type: ${type})`, async ({ startClient, server, mcpBrowser }, testInfo) => { + test.skip(type === 'webp' && mcpBrowser === 'webkit' && process.platform === 'darwin', 'CG on macOS does not include a webp encoder UTI'); + const outputDir = testInfo.outputPath('output'); const { client } = await startClient({ config: { outputDir }, diff --git a/tests/page/page-screenshot.spec.ts b/tests/page/page-screenshot.spec.ts index 4fc5008de9d4c..98fad56350f88 100644 --- a/tests/page/page-screenshot.spec.ts +++ b/tests/page/page-screenshot.spec.ts @@ -278,6 +278,36 @@ it.describe('page screenshot', () => { expect(screenshot).toMatchSnapshot('white.jpg'); }); + it('should produce a valid webp screenshot', async ({ page, server, isBidi, browserName, platform }) => { + it.skip(isBidi, 'webp screenshots are not supported via WebDriver BiDi'); + it.skip(browserName === 'webkit' && platform === 'darwin', 'CG on macOS does not include a webp encoder UTI'); + + await page.setViewportSize({ width: 300, height: 300 }); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => (document.body.style.background = 'rgb(255, 0, 0)')); + const screenshot = await page.screenshot({ type: 'webp' }); + // Valid WebP container: "RIFF" "WEBP". + expect(screenshot.subarray(0, 4).toString('latin1')).toBe('RIFF'); + expect(screenshot.subarray(8, 12).toString('latin1')).toBe('WEBP'); + // Decode it back in the page to confirm it carries the rendered content. + const pixel = await page.evaluate(async base64 => { + const img = new Image(); + img.src = 'data:image/webp;base64,' + base64; + await img.decode(); + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const context = canvas.getContext('2d')!; + context.drawImage(img, 0, 0); + const data = context.getImageData(canvas.width >> 1, canvas.height >> 1, 1, 1).data; + return { width: img.naturalWidth, r: data[0], g: data[1], b: data[2] }; + }, screenshot.toString('base64')); + expect(pixel.width).toBeGreaterThan(0); + expect(pixel.r).toBeGreaterThan(200); + expect(pixel.g).toBeLessThan(60); + expect(pixel.b).toBeLessThan(60); + }); + it('should work with odd clip size on Retina displays', async ({ page, isElectron }) => { it.skip(isElectron, 'electron does not set device scale factor to 1'); diff --git a/tests/playwright-test/to-have-screenshot.spec.ts b/tests/playwright-test/to-have-screenshot.spec.ts index 9a8945b0143d4..26ba659bde42d 100644 --- a/tests/playwright-test/to-have-screenshot.spec.ts +++ b/tests/playwright-test/to-have-screenshot.spec.ts @@ -554,7 +554,7 @@ test('should fail when screenshot is different size', async ({ runInlineTest }) expect(result.output).toContain('Expected an image 22px by 33px, received 1280px by 720px.'); }); -test('should fail when given non-png snapshot name', async ({ runInlineTest }) => { +test('should fail when given unsupported snapshot name', async ({ runInlineTest }) => { const result = await runInlineTest({ ...playwrightConfig({ snapshotPathTemplate: '__screenshots__/{testFilePath}/{arg}{ext}', @@ -567,7 +567,7 @@ test('should fail when given non-png snapshot name', async ({ runInlineTest }) = ` }); expect(result.exitCode).toBe(1); - expect(result.output).toContain(`Screenshot name "snapshot.jpeg" must have '.png' extension`); + expect(result.output).toContain(`Screenshot name "snapshot.jpeg" must have a '.png' or '.webp' extension`); }); test('should fail when given buffer', async ({ runInlineTest }) => { diff --git a/utils/build/build.js b/utils/build/build.js index 2c0fbf6502b93..ae4470f2eb952 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -1024,6 +1024,21 @@ copyFiles.push({ to: 'packages/playwright-core/lib', }); +// WebP codec: ship the WASM binary and its third-party license into lib/ next +// to coreBundle.js, where @utils/webp/webp reads them at runtime. The .js glue +// is inlined into coreBundle; the .wasm and .LICENSE ship as assets. The +// license is generated from the pinned libwebp source by utils/libwebp-wasm/build.sh. +copyFiles.push({ + files: 'packages/utils/webp/webp_codec.wasm', + from: 'packages/utils/webp', + to: 'packages/playwright-core/lib', +}); +copyFiles.push({ + files: 'packages/utils/webp/webp_codec.LICENSE', + from: 'packages/utils/webp', + to: 'packages/playwright-core/lib', +}); + copyFiles.push({ files: 'packages/playwright/src/agents/*.md', diff --git a/utils/libwebp-wasm/.gitignore b/utils/libwebp-wasm/.gitignore new file mode 100644 index 0000000000000..30bcfa4ed5ccf --- /dev/null +++ b/utils/libwebp-wasm/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/utils/libwebp-wasm/README.md b/utils/libwebp-wasm/README.md new file mode 100644 index 0000000000000..30fab3b62bf60 --- /dev/null +++ b/utils/libwebp-wasm/README.md @@ -0,0 +1,117 @@ +# libwebp → WASM (self-built) + +The **build tooling** for a self-contained WebP codec compiled from +[libwebp](https://github.com/webmproject/libwebp) to WebAssembly, built entirely +from source with no third-party npm codec dependency. Running `build.sh` +produces the codec artifacts and emits them into the shipped location, +`packages/utils/webp/`. + +**This folder (`utils/libwebp-wasm/`) — build tooling only:** + +| File | What | +|------|------| +| `build.sh` | Reproducible build: fetches emsdk + libwebp, compiles, links, regenerates the license. | +| `webp_wasm.c` | Tiny C wrapper over libwebp's config-based encode + `WebPDecodeRGBA` + `WebPFree`. | + +**Shipped codec — `packages/utils/webp/` (importable as `@utils/webp/webp`):** + +| File | What | +|------|------| +| `webp.ts` | Typed **synchronous** API: `encodeWebp(image, {quality\|lossless})` / `decodeWebp(buffer)`. | +| `webp_codec.js` / `webp_codec.wasm` | Built artifacts (Emscripten CommonJS glue + binary). **SIMD, synchronous.** | +| `webp_codec.LICENSE` | Third-party license, regenerated by the build: libwebp BSD from the pinned source + Emscripten's license copied from the compiler. | + +Pinned: **libwebp `3757b8a`** (a post-1.6.0 `main` commit — see below), built +with Emscripten (tested emcc 6.0.2). + +## API + +Exported from the `@utils` barrel; **synchronous** — no `await` (see +"Synchronous" below): + +```ts +import { encodeWebp, decodeWebp } from '@utils/webp/webp'; + +// Lossy (quality is the 0..100 quality factor): +const webp = encodeWebp({ data: rgba, width, height }, { quality: 80 }); // -> Buffer +// Lossless (pixel-exact; quality is the 0..100 compression effort): +const exact = encodeWebp({ data: rgba, width, height }, { lossless: true }); + +const { data, width, height } = decodeWebp(webp); // RGBA Buffer +``` + +Lossless output is verified pixel-exact (decode MAE 0.0000). `webp.ts` reads +`webp_codec.wasm` next to itself (shipped into `lib/` by the build), +instantiating lazily on first call. + +## Build + +```bash +./build.sh # SIMD build (default) — ~552 KB wasm +SIMD=0 ./build.sh # scalar build — ~333 KB wasm, ~2x slower encode +``` + +The first run downloads and activates emsdk under `.build/` (set `EMSDK_DIR` to +reuse an existing one). libwebp is built with all CLI tools, threads, mux and +demux disabled — just the encoder + decoder + dsp needed for RGBA <-> WebP. + +## Performance (photo 1920×1080, quality 80) + +| Build | encode | decode | +|-------|-------:|-------:| +| SIMD (default) | **~370 ms** | ~75 ms | +| scalar (`SIMD=0`) | ~720 ms | ~75 ms | + +The SIMD build (SSE2 → wasm SIMD) is ~2× faster on encode. Output is standard +WebP; lossless round-trips pixel-exact. + +## Why a pinned post-1.6.0 commit (and not v1.6.0) + +Building the **v1.6.0 release** with a modern Emscripten needed two workarounds; +both were fixed upstream *after* 1.6.0 (released 2025-07-08) but are not in any +tagged release yet, so we pin a `main` commit that includes them: + +1. **Runtime SIMD dispatch.** libwebp's wasm SIMD selector (`src/dsp/cpu.c`) was + guarded by the *legacy* `defined(EMSCRIPTEN)` macro, which modern emcc no + longer defines (only `__EMSCRIPTEN__`) — so the compiled SSE2 code was never + selected at runtime and you silently got scalar speed. Fixed by + [`5339483`](https://github.com/webmproject/libwebp/commit/5339483509d9) + *"dsp/cpu.c: avoid use of legacy EMSCRIPTEN definition"* (2026-04-12). + +2. **SIMD compile on Emscripten.** libwebp's cmake probed `-mavx2`/`-msse4.1` + (which emcc accepts) and those codepaths failed to compile. Fixed by + [`453a18c`](https://github.com/webmproject/libwebp/commit/453a18c42f39) + *"cmake/cpu.cmake: fix SSE2 check on Emscripten"* and + [`b51704f`](https://github.com/webmproject/libwebp/commit/b51704f9b9fe) + *"dsp/cpu.c: allow use of AVX2 on Emscripten"* (2026-04-24). + +(Older toolchains sidestep both: Emscripten ≤ 2.0.34 still defined the legacy +`EMSCRIPTEN` macro, and older libwebp lacked the AVX2/SSE41 probes.) **Switch +`LIBWEBP_COMMIT` in `build.sh` to a release tag once one ships with these +fixes** (expected 1.6.1 / 1.7.0). + +## Synchronous + +The module is linked with `-sWASM_ASYNC_COMPILATION=0` and CommonJS output (no +`EXPORT_ES6`), so it instantiates with `new WebAssembly.Instance` and the exports +attach to the module object during the (synchronous) factory call — `encodeWebp` +/ `decodeWebp` are then plain synchronous calls. This matches playwright-core's +synchronous image `Comparator`, and the CommonJS glue inlines cleanly into +`coreBundle` (via `@utils`). + +Synchronous instantiation is **Node-only in practice**: browsers cap synchronous +WASM compilation on the main thread (a large module must be compiled async or in +a worker). Playwright uses it server-side in Node, so this is fine. + +## Packaging + +The codec ships with playwright-core: + +- `webp_codec.js` (glue) is inlined into `coreBundle.js` via the `@utils` barrel. +- `webp_codec.wasm` and `webp_codec.LICENSE` are copied into `lib/` by + `utils/build/build.js` (`copyFiles`) and included by + `packages/playwright-core/.npmignore` (`!lib/**/*.wasm`, `!lib/**/*.LICENSE`). + `ThirdPartyNotices.txt` already covers per-`lib/` license sidecars generically. + +`webp.ts` reads `webp_codec.wasm` via `path.join(__dirname, 'webp_codec.wasm')`, +which resolves to `lib/` at runtime (where `coreBundle.js` lives). diff --git a/utils/libwebp-wasm/build.sh b/utils/libwebp-wasm/build.sh new file mode 100755 index 0000000000000..ebd1bbf9a0ea9 --- /dev/null +++ b/utils/libwebp-wasm/build.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# +# Build libwebp to a self-contained WASM module exposing a minimal RGBA +# encode/decode API (see webp_wasm.c). Emits everything the shipped codec needs +# into packages/utils/webp/ (where the typed wrapper webp.ts imports them, +# @utils/webp/webp): the synchronous Emscripten CommonJS glue (webp_codec.js), +# the binary (webp_codec.wasm) and the regenerated third-party license +# (webp_codec.LICENSE). +# +# Usage: +# ./build.sh # SIMD build (default; SSE2 -> wasm SIMD) +# SIMD=0 ./build.sh # scalar build (smaller, ~2x slower encode) +# +# libwebp is pinned to a post-1.6.0 `main` commit that contains the Emscripten +# SIMD fixes (5339483 "avoid legacy EMSCRIPTEN definition" and 453a18c "fix SSE2 +# check on Emscripten", both 2026-04, unreleased as of v1.6.0 / 2025-07). With +# those, no cmake patching or -DEMSCRIPTEN is needed. Switch to a release tag +# once one ships with the fixes. +# +# Reproducibility: uses whatever emsdk "latest" resolves to (tested emcc 6.0.2). +# Set EMSDK_DIR to reuse an existing emsdk. +# +set -euo pipefail + +# Pinned to a commit rather than a release tag on purpose: the latest release +# (v1.6.0, 2025-07-08) predates the upstream Emscripten SIMD fixes (5339483 and +# 453a18c, both 2026-04) and building it needs local patches + -DEMSCRIPTEN. +# This `main` commit already has them, so the build stays patch-free. Move to a +# release tag once one ships with these fixes (expected 1.6.1 / 1.7.0). +LIBWEBP_COMMIT="${LIBWEBP_COMMIT:-3757b8afeb54e305eaef18502812a9a88b7ed662}" +SIMD="${SIMD:-1}" + +HERE="$(cd "$(dirname "$0")" && pwd)" +OUT="$(cd "$HERE/../../packages/utils/webp" && pwd)" # shipped codec (imported via @utils/webp/webp) +WORK="${WORK:-$HERE/.build}" +mkdir -p "$WORK" + +# --- 1. Emscripten toolchain ------------------------------------------------- +if ! command -v emcc >/dev/null 2>&1; then + EMSDK_DIR="${EMSDK_DIR:-$WORK/emsdk}" + if [ ! -d "$EMSDK_DIR" ]; then + git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$EMSDK_DIR" + fi + "$EMSDK_DIR/emsdk" install latest + "$EMSDK_DIR/emsdk" activate latest + # shellcheck disable=SC1091 + source "$EMSDK_DIR/emsdk_env.sh" +fi +echo "emcc: $(emcc --version | head -1)" + +# --- 2. libwebp source (pinned commit) --------------------------------------- +SRC="$WORK/libwebp-$LIBWEBP_COMMIT" +if [ ! -d "$SRC" ]; then + mkdir -p "$SRC" + curl -sSL "https://github.com/webmproject/libwebp/archive/${LIBWEBP_COMMIT}.tar.gz" \ + | tar xz --strip 1 -C "$SRC" +fi + +# --- 3. Configure + build libwebp static libs -------------------------------- +# On Emscripten, cmake auto-adds -msimd128 to the SIMD sources and libwebp's +# runtime dispatch (dsp/cpu.c) keys off __EMSCRIPTEN__, so only SSE2 (the SIMD +# level that lowers to wasm) is used at runtime. SIMD is opt-in on Emscripten. +BUILD="$WORK/build-$([ "$SIMD" = 1 ] && echo simd || echo scalar)" +emcmake cmake -S "$SRC" -B "$BUILD" \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF \ + -DWEBP_USE_THREAD=OFF \ + -DWEBP_ENABLE_SIMD="$SIMD" \ + -DWEBP_BUILD_CWEBP=OFF -DWEBP_BUILD_DWEBP=OFF -DWEBP_BUILD_GIF2WEBP=OFF \ + -DWEBP_BUILD_IMG2WEBP=OFF -DWEBP_BUILD_VWEBP=OFF -DWEBP_BUILD_WEBPINFO=OFF \ + -DWEBP_BUILD_WEBPMUX=OFF -DWEBP_BUILD_EXTRAS=OFF -DWEBP_BUILD_ANIM_UTILS=OFF +emmake make -C "$BUILD" webp sharpyuv -j"$(nproc)" + +# --- 4. Link the wrapper into a synchronous CommonJS module ------------------ +# WASM_ASYNC_COMPILATION=0 instantiates the module with `new WebAssembly.Instance` +# (synchronous), so the exports attach to the module object during the factory +# call and encode/decode are plain synchronous calls (needed for Playwright's +# synchronous image Comparator). CommonJS output (no EXPORT_ES6) inlines cleanly +# into playwright-core's coreBundle via @utils. Sync compilation is Node-only in +# practice (browsers cap synchronous WASM compilation on the main thread). +emcc "$HERE/webp_wasm.c" "$BUILD/libwebp.a" "$BUILD/libsharpyuv.a" \ + -I "$SRC/src" -I "$SRC" \ + -O3 -flto \ + -sMODULARIZE=1 -sENVIRONMENT=node,web \ + -sWASM_ASYNC_COMPILATION=0 \ + -sEXPORTED_FUNCTIONS=_webp_encode_rgba,_webp_decode_rgba,_webp_free,_malloc,_free \ + -sEXPORTED_RUNTIME_METHODS=HEAPU8,HEAPU32 \ + -sALLOW_MEMORY_GROWTH=1 -sFILESYSTEM=0 -sMALLOC=emmalloc \ + --no-entry \ + -o "$OUT/webp_codec.js" + +# --- 5. Regenerate the third-party license from the pinned libwebp source ---- +# Ships with playwright-core (copied into lib/ by the build; see build.js). +{ + cat <<'EOF' +Third-party license notices for the bundled WebP codec (webp_codec.wasm and +its Emscripten glue webp_codec.js). + +webp_codec.wasm is compiled from libwebp (https://github.com/webmproject/libwebp) +and webp_codec.js is generated by Emscripten (https://emscripten.org). Their +licenses are reproduced below. + +================================================================================ +libwebp — BSD 3-Clause License +================================================================================ + +EOF + cat "$SRC/COPYING" + echo + echo "--------------------------------------------------------------------------------" + cat "$SRC/PATENTS" + cat <<'EOF' + +================================================================================ +Emscripten (webp_codec.js glue + runtime linked into webp_codec.wasm) +================================================================================ + +EOF + # Copy the license verbatim from the Emscripten compiler rather than hardcoding. + cat "$(dirname "$(command -v emcc)")/LICENSE" +} > "$OUT/webp_codec.LICENSE" + +echo "Built into $OUT: webp_codec.wasm ($(stat -c%s "$OUT/webp_codec.wasm") bytes), webp_codec.js, webp_codec.LICENSE" diff --git a/utils/libwebp-wasm/webp_wasm.c b/utils/libwebp-wasm/webp_wasm.c new file mode 100644 index 0000000000000..fe4167afad77f --- /dev/null +++ b/utils/libwebp-wasm/webp_wasm.c @@ -0,0 +1,82 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Minimal WASM wrapper around libwebp's encode/decode RGBA API. Exposes exactly +// what the codec benchmark needs: RGBA -> WebP (lossy or lossless) and +// WebP -> RGBA. Memory is owned by libwebp and released via webp_free(). +#include +#include +#include +#include "webp/encode.h" +#include "webp/decode.h" + +// Encode RGBA (width*height*4, row stride = width*4) to a WebP bitstream. +// lossless == 0: lossy, `quality` is the 0..100 quality factor. +// lossless != 0: lossless (pixels preserved exactly); `quality` is the 0..100 +// compression effort (higher = smaller/slower). +// Returns a pointer to the libwebp-allocated output and writes its byte length +// to *out_size. Returns NULL (and *out_size == 0) on failure. Free the returned +// pointer with webp_free(). +EMSCRIPTEN_KEEPALIVE +uint8_t* webp_encode_rgba(const uint8_t* rgba, int width, int height, + float quality, int lossless, size_t* out_size) { + *out_size = 0; + + WebPConfig config; + if (!WebPConfigInit(&config)) + return NULL; + config.lossless = lossless ? 1 : 0; + config.quality = quality; + if (!WebPValidateConfig(&config)) + return NULL; + + WebPPicture pic; + if (!WebPPictureInit(&pic)) + return NULL; + pic.use_argb = config.lossless; // lossless encodes from the ARGB buffer + pic.width = width; + pic.height = height; + + WebPMemoryWriter writer; + WebPMemoryWriterInit(&writer); + pic.writer = WebPMemoryWrite; + pic.custom_ptr = &writer; + + const int ok = WebPPictureImportRGBA(&pic, rgba, width * 4) && + WebPEncode(&config, &pic); + WebPPictureFree(&pic); + if (!ok) { + WebPMemoryWriterClear(&writer); + return NULL; + } + *out_size = writer.size; + return writer.mem; // WebPMalloc'd; release with webp_free() +} + +// Decode a WebP bitstream to RGBA. Returns a pointer to the libwebp-allocated +// RGBA buffer (width*height*4) and writes the dimensions to *width/*height. +// Returns NULL on failure. Free the returned pointer with webp_free(). +EMSCRIPTEN_KEEPALIVE +uint8_t* webp_decode_rgba(const uint8_t* data, size_t size, + int* width, int* height) { + return WebPDecodeRGBA(data, size, width, height); +} + +// Release a buffer returned by webp_encode_rgba/webp_decode_rgba. +EMSCRIPTEN_KEEPALIVE +void webp_free(void* ptr) { + WebPFree(ptr); +}