mirror of
https://github.com/hanzoai/playwright.git
synced 2026-09-24 17:01:53 +00:00
feat(screenshot): add webp output format (#41152)
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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/*",
|
||||
|
||||
+9
-6
@@ -13176,7 +13176,8 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
|
||||
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<T=Node> extends JSHandle<T> {
|
||||
/**
|
||||
* Specify screenshot type, defaults to `png`.
|
||||
*/
|
||||
type?: "png"|"jpeg";
|
||||
type?: "png"|"jpeg"|"webp";
|
||||
}): Promise<Buffer>;
|
||||
|
||||
/**
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+6
-4
@@ -3332,7 +3332,7 @@ export type ElementHandleQuerySelectorAllResult = {
|
||||
};
|
||||
export type ElementHandleScreenshotParams = {
|
||||
timeout: number,
|
||||
type?: 'png' | 'jpeg',
|
||||
type?: 'png' | 'jpeg' | 'webp',
|
||||
quality?: number,
|
||||
omitBackground?: boolean,
|
||||
caret?: 'hide' | 'initial',
|
||||
@@ -3346,7 +3346,7 @@ export type ElementHandleScreenshotParams = {
|
||||
style?: string,
|
||||
};
|
||||
export type ElementHandleScreenshotOptions = {
|
||||
type?: 'png' | 'jpeg',
|
||||
type?: 'png' | 'jpeg' | 'webp',
|
||||
quality?: number,
|
||||
omitBackground?: boolean,
|
||||
caret?: 'hide' | 'initial',
|
||||
@@ -4192,6 +4192,7 @@ export type PageExpectScreenshotParams = {
|
||||
threshold?: number,
|
||||
fullPage?: boolean,
|
||||
clip?: Rect,
|
||||
type?: 'png' | 'webp',
|
||||
omitBackground?: boolean,
|
||||
caret?: 'hide' | 'initial',
|
||||
animations?: 'disabled' | 'allow',
|
||||
@@ -4215,6 +4216,7 @@ export type PageExpectScreenshotOptions = {
|
||||
threshold?: number,
|
||||
fullPage?: boolean,
|
||||
clip?: Rect,
|
||||
type?: 'png' | 'webp',
|
||||
omitBackground?: boolean,
|
||||
caret?: 'hide' | 'initial',
|
||||
animations?: 'disabled' | 'allow',
|
||||
@@ -4239,7 +4241,7 @@ export type PageExpectScreenshotErrorDetails = {
|
||||
};
|
||||
export type PageScreenshotParams = {
|
||||
timeout: number,
|
||||
type?: 'png' | 'jpeg',
|
||||
type?: 'png' | 'jpeg' | 'webp',
|
||||
quality?: number,
|
||||
fullPage?: boolean,
|
||||
clip?: Rect,
|
||||
@@ -4255,7 +4257,7 @@ export type PageScreenshotParams = {
|
||||
style?: string,
|
||||
};
|
||||
export type PageScreenshotOptions = {
|
||||
type?: 'png' | 'jpeg',
|
||||
type?: 'png' | 'jpeg' | 'webp',
|
||||
quality?: number,
|
||||
fullPage?: boolean,
|
||||
clip?: Rect,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -493,6 +493,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<Buffer> {
|
||||
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,
|
||||
|
||||
+6
-4
@@ -3335,7 +3335,7 @@ export type ElementHandleQuerySelectorAllResult = {
|
||||
};
|
||||
export type ElementHandleScreenshotParams = {
|
||||
timeout: number,
|
||||
type?: 'png' | 'jpeg',
|
||||
type?: 'png' | 'jpeg' | 'webp',
|
||||
quality?: number,
|
||||
omitBackground?: boolean,
|
||||
caret?: 'hide' | 'initial',
|
||||
@@ -3349,7 +3349,7 @@ export type ElementHandleScreenshotParams = {
|
||||
style?: string,
|
||||
};
|
||||
export type ElementHandleScreenshotOptions = {
|
||||
type?: 'png' | 'jpeg',
|
||||
type?: 'png' | 'jpeg' | 'webp',
|
||||
quality?: number,
|
||||
omitBackground?: boolean,
|
||||
caret?: 'hide' | 'initial',
|
||||
@@ -4195,6 +4195,7 @@ export type PageExpectScreenshotParams = {
|
||||
threshold?: number,
|
||||
fullPage?: boolean,
|
||||
clip?: Rect,
|
||||
type?: 'png' | 'webp',
|
||||
omitBackground?: boolean,
|
||||
caret?: 'hide' | 'initial',
|
||||
animations?: 'disabled' | 'allow',
|
||||
@@ -4218,6 +4219,7 @@ export type PageExpectScreenshotOptions = {
|
||||
threshold?: number,
|
||||
fullPage?: boolean,
|
||||
clip?: Rect,
|
||||
type?: 'png' | 'webp',
|
||||
omitBackground?: boolean,
|
||||
caret?: 'hide' | 'initial',
|
||||
animations?: 'disabled' | 'allow',
|
||||
@@ -4242,7 +4244,7 @@ export type PageExpectScreenshotErrorDetails = {
|
||||
};
|
||||
export type PageScreenshotParams = {
|
||||
timeout: number,
|
||||
type?: 'png' | 'jpeg',
|
||||
type?: 'png' | 'jpeg' | 'webp',
|
||||
quality?: number,
|
||||
fullPage?: boolean,
|
||||
clip?: Rect,
|
||||
@@ -4258,7 +4260,7 @@ export type PageScreenshotParams = {
|
||||
style?: string,
|
||||
};
|
||||
export type PageScreenshotOptions = {
|
||||
type?: 'png' | 'jpeg',
|
||||
type?: 'png' | 'jpeg' | 'webp',
|
||||
quality?: number,
|
||||
fullPage?: boolean,
|
||||
clip?: Rect,
|
||||
|
||||
@@ -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<Buffer> {
|
||||
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<Buffer> {
|
||||
const { visualViewport, contentSize, cssContentSize } = await progress.race(this._mainFrameSession._client.send('Page.getLayoutMetrics'));
|
||||
if (!documentRect) {
|
||||
documentRect = {
|
||||
|
||||
@@ -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<Buffer> {
|
||||
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<Buffer> {
|
||||
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',
|
||||
|
||||
@@ -720,28 +720,26 @@ export class Page extends SdkObject<PageEventMap> {
|
||||
return await this.screenshotter.screenshotPage(progress, options || {});
|
||||
};
|
||||
|
||||
const comparator = getComparator('image/png');
|
||||
let intermediateResult: {
|
||||
actual?: Buffer,
|
||||
previous?: Buffer,
|
||||
errorMessage: string,
|
||||
diff?: Buffer,
|
||||
} | undefined;
|
||||
const areEqualScreenshots = (actual: Buffer | undefined, expected: Buffer | undefined, previous: Buffer | undefined) => {
|
||||
const comparatorResult = actual && expected ? comparator(actual, expected, options) : undefined;
|
||||
if (comparatorResult !== undefined && !!comparatorResult === !!options.isNot)
|
||||
return true;
|
||||
if (comparatorResult)
|
||||
intermediateResult = { errorMessage: comparatorResult.errorMessage, diff: comparatorResult.diff, actual, previous };
|
||||
return false;
|
||||
};
|
||||
|
||||
try {
|
||||
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');
|
||||
const comparator = getComparator(`image/${format}`);
|
||||
const areEqualScreenshots = (actual: Buffer | undefined, expected: Buffer | undefined, previous: Buffer | undefined) => {
|
||||
const comparatorResult = actual && expected ? comparator(actual, expected, options) : undefined;
|
||||
if (comparatorResult !== undefined && !!comparatorResult === !!options.isNot)
|
||||
return true;
|
||||
if (comparatorResult)
|
||||
intermediateResult = { errorMessage: comparatorResult.errorMessage, diff: comparatorResult.diff, actual, previous };
|
||||
return false;
|
||||
};
|
||||
let actual: Buffer | undefined;
|
||||
let previous: Buffer | undefined;
|
||||
const pollIntervals = [0, 100, 250, 500];
|
||||
|
||||
@@ -33,7 +33,7 @@ declare global {
|
||||
}
|
||||
|
||||
export type ScreenshotOptions = {
|
||||
type?: 'png' | 'jpeg';
|
||||
type?: 'png' | 'jpeg' | 'webp';
|
||||
quality?: number;
|
||||
omitBackground?: boolean;
|
||||
animations?: 'disabled' | 'allow';
|
||||
@@ -288,7 +288,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<Buffer> {
|
||||
private async _screenshot(progress: Progress, format: 'png' | 'jpeg' | 'webp', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean, options: ScreenshotOptions): Promise<Buffer> {
|
||||
if ((options as any).__testHookBeforeScreenshot)
|
||||
await progress.race((options as any).__testHookBeforeScreenshot());
|
||||
|
||||
@@ -298,7 +298,7 @@ export class Screenshotter {
|
||||
const cleanupHighlight = await this._maskElements(progress, options);
|
||||
|
||||
try {
|
||||
const quality = format === 'jpeg' ? options.quality ?? 80 : undefined;
|
||||
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)
|
||||
@@ -344,12 +344,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;
|
||||
}
|
||||
|
||||
@@ -357,7 +357,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -868,9 +871,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/<format>;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<frames.Frame | null> {
|
||||
|
||||
@@ -58,7 +58,7 @@ export class Response {
|
||||
readonly toolName: string;
|
||||
readonly toolArgs: Record<string, any>;
|
||||
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<string>();
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -805,12 +805,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({
|
||||
|
||||
@@ -56,7 +56,10 @@ function generateCommandHelp(command: AnyCommandSchema) {
|
||||
const optionsShape = (command.options as zodType.ZodObject<any>).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));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-6
@@ -13176,7 +13176,8 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
|
||||
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<T=Node> extends JSHandle<T> {
|
||||
/**
|
||||
* Specify screenshot type, defaults to `png`.
|
||||
*/
|
||||
type?: "png"|"jpeg";
|
||||
type?: "png"|"jpeg"|"webp";
|
||||
}): Promise<Buffer>;
|
||||
|
||||
/**
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -423,6 +423,7 @@ ElementHandle:
|
||||
literals:
|
||||
- png
|
||||
- jpeg
|
||||
- webp
|
||||
quality: int?
|
||||
$mixin: CommonScreenshotOptions
|
||||
returns:
|
||||
|
||||
@@ -186,6 +186,11 @@ Page:
|
||||
threshold: float?
|
||||
fullPage: boolean?
|
||||
clip: Rect?
|
||||
type:
|
||||
type: enum?
|
||||
literals:
|
||||
- png
|
||||
- webp
|
||||
$mixin: CommonScreenshotOptions
|
||||
returns:
|
||||
actual: binary?
|
||||
@@ -211,6 +216,7 @@ Page:
|
||||
literals:
|
||||
- png
|
||||
- jpeg
|
||||
- webp
|
||||
quality: int?
|
||||
fullPage: boolean?
|
||||
clip: Rect?
|
||||
|
||||
@@ -1897,7 +1897,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'])),
|
||||
@@ -2437,6 +2437,7 @@ scheme.PageExpectScreenshotParams = tObject({
|
||||
threshold: tOptional(tFloat),
|
||||
fullPage: tOptional(tBoolean),
|
||||
clip: tOptional(tType('Rect')),
|
||||
type: tOptional(tEnum(['png', 'webp'])),
|
||||
omitBackground: tOptional(tBoolean),
|
||||
caret: tOptional(tEnum(['hide', 'initial'])),
|
||||
animations: tOptional(tEnum(['disabled', 'allow'])),
|
||||
@@ -2461,7 +2462,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')),
|
||||
|
||||
@@ -16,6 +16,7 @@ node_modules/yazl
|
||||
[comparators.ts]
|
||||
./image_tools
|
||||
./third_party/pixelmatch
|
||||
./webp/webp
|
||||
|
||||
[env.ts]
|
||||
"strict"
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/).
|
||||
|
||||
File diff suppressed because one or more lines are too long
Executable
BIN
Binary file not shown.
@@ -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<string, Record<string, number>> = {
|
||||
'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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -96,7 +96,7 @@ 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']) {
|
||||
for (const type of ['png', 'jpeg', 'webp']) {
|
||||
test(`browser_take_screenshot (type: ${type})`, async ({ startClient, server }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
const { client } = await startClient({
|
||||
|
||||
@@ -278,6 +278,16 @@ it.describe('page screenshot', () => {
|
||||
expect(screenshot).toMatchSnapshot('white.jpg');
|
||||
});
|
||||
|
||||
it('should produce a valid webp screenshot', async ({ page, server, isBidi }) => {
|
||||
it.skip(isBidi, 'webp screenshots are not supported via WebDriver BiDi');
|
||||
|
||||
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' });
|
||||
expect(screenshot).toMatchSnapshot('red.webp');
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 518 B |
Binary file not shown.
|
After Width: | Height: | Size: 44 B |
Binary file not shown.
|
After Width: | Height: | Size: 518 B |
@@ -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 }) => {
|
||||
|
||||
@@ -1027,6 +1027,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',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.build/
|
||||
@@ -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).
|
||||
Executable
+123
@@ -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"
|
||||
@@ -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 <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <emscripten.h>
|
||||
#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);
|
||||
}
|
||||
Reference in New Issue
Block a user