diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d4baf8c9b..918e4142b6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,6 +171,8 @@ jobs: with: cache: npm node-version: lts/* + - name: Set up FFmpeg + uses: FedericoCarboni/setup-ffmpeg@5058c9851b649ced05c3e73a4fb5ef2995a89127 # v2.0.0 - name: Install dependencies run: npm ci env: @@ -261,6 +263,8 @@ jobs: with: cache: npm node-version: lts/* + - name: Set up FFmpeg + uses: FedericoCarboni/setup-ffmpeg@5058c9851b649ced05c3e73a4fb5ef2995a89127 # v2.0.0 - name: Install dependencies run: npm ci env: diff --git a/docs/api/index.md b/docs/api/index.md index cc834402edc..54e3e92b517 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -36,6 +36,7 @@ sidebar_label: API | [ProtocolError](./puppeteer.protocolerror.md) | ProtocolError is emitted whenever there is an error from the protocol. | | [Puppeteer](./puppeteer.puppeteer.md) |

The main Puppeteer class.

IMPORTANT: if you are using Puppeteer in a Node environment, you will get an instance of [PuppeteerNode](./puppeteer.puppeteernode.md) when you import or require puppeteer. That class extends Puppeteer, so has all the methods documented below as well as all that are defined on [PuppeteerNode](./puppeteer.puppeteernode.md).

| | [PuppeteerNode](./puppeteer.puppeteernode.md) |

Extends the main [Puppeteer](./puppeteer.puppeteer.md) class with Node specific behaviour for fetching and downloading browsers.

If you're using Puppeteer in a Node environment, this is the class you'll get when you run require('puppeteer') (or the equivalent ES import).

| +| [ScreenRecorder](./puppeteer.screenrecorder.md) | | | [SecurityDetails](./puppeteer.securitydetails.md) | The SecurityDetails class represents the security details of a response that was received over a secure connection. | | [Target](./puppeteer.target.md) | Target represents a [CDP target](https://chromedevtools.github.io/devtools-protocol/tot/Target/). In CDP a target is something that can be debugged such a frame, a page or a worker. | | [TimeoutError](./puppeteer.timeouterror.md) | TimeoutError is emitted whenever certain operations are terminated due to timeout. | @@ -124,6 +125,7 @@ sidebar_label: API | [PuppeteerLaunchOptions](./puppeteer.puppeteerlaunchoptions.md) | | | [RemoteAddress](./puppeteer.remoteaddress.md) | | | [ResponseForRequest](./puppeteer.responseforrequest.md) | Required response data to fulfill a request with. | +| [ScreencastOptions](./puppeteer.screencastoptions.md) | | | [ScreenshotClip](./puppeteer.screenshotclip.md) | | | [ScreenshotOptions](./puppeteer.screenshotoptions.md) | | | [SerializedAXNode](./puppeteer.serializedaxnode.md) | Represents a Node and the properties of it that are relevant to Accessibility. | diff --git a/docs/api/puppeteer.page.md b/docs/api/puppeteer.page.md index 0a75028d400..dadd9752340 100644 --- a/docs/api/puppeteer.page.md +++ b/docs/api/puppeteer.page.md @@ -127,6 +127,7 @@ page.off('request', logRequest); | [reload(options)](./puppeteer.page.reload.md) | | Reloads the page. | | [removeExposedFunction(name)](./puppeteer.page.removeexposedfunction.md) | | The method removes a previously added function via $[Page.exposeFunction()](./puppeteer.page.exposefunction.md) called name from the page's window object. | | [removeScriptToEvaluateOnNewDocument(identifier)](./puppeteer.page.removescripttoevaluateonnewdocument.md) | | Removes script that injected into page by Page.evaluateOnNewDocument. | +| [screencast(options)](./puppeteer.page.screencast.md) | | Captures a screencast of this [page](./puppeteer.page.md). | | [screenshot(options)](./puppeteer.page.screenshot.md) | | Captures a screenshot of this [page](./puppeteer.page.md). | | [screenshot(options)](./puppeteer.page.screenshot_1.md) | | | | [select(selector, values)](./puppeteer.page.select.md) | | Triggers a change and input event once all the provided options have been selected. If there's no <select> element matching selector, the method throws an error. | diff --git a/docs/api/puppeteer.page.screencast.md b/docs/api/puppeteer.page.screencast.md new file mode 100644 index 00000000000..c87b30ba7a8 --- /dev/null +++ b/docs/api/puppeteer.page.screencast.md @@ -0,0 +1,58 @@ +--- +sidebar_label: Page.screencast +--- + +# Page.screencast() method + +Captures a screencast of this [page](./puppeteer.page.md). + +#### Signature: + +```typescript +class Page { + screencast(options?: Readonly): Promise; +} +``` + +## Parameters + +| Parameter | Type | Description | +| --------- | --------------------------------------------------------------------- | -------------------------------------------- | +| options | Readonly<[ScreencastOptions](./puppeteer.screencastoptions.md)> | _(Optional)_ Configures screencast behavior. | + +**Returns:** + +Promise<[ScreenRecorder](./puppeteer.screenrecorder.md)> + +## Remarks + +All recordings will be \[WebM\](https://www.webmproject.org/) format using the \[VP9\](https://www.webmproject.org/vp9/) video codec. The FPS is 30. + +You must have \[ffmpeg\](https://ffmpeg.org/) installed on your system. + +## Example + +Recording a [page](./puppeteer.page.md): + +``` +import puppeteer from 'puppeteer'; + +// Launch a browser +const browser = await puppeteer.launch(); + +// Create a new page +const page = await browser.newPage(); + +// Go to your site. +await page.goto("https://www.example.com"); + +// Start recording. +const recorder = await page.screencast({path: 'recording.webm'}); + +// Do something. + +// Stop recording. +await recorder.stop(); + +browser.close(); +``` diff --git a/docs/api/puppeteer.screencastoptions.md b/docs/api/puppeteer.screencastoptions.md new file mode 100644 index 00000000000..d680f643769 --- /dev/null +++ b/docs/api/puppeteer.screencastoptions.md @@ -0,0 +1,21 @@ +--- +sidebar_label: ScreencastOptions +--- + +# ScreencastOptions interface + +#### Signature: + +```typescript +export interface ScreencastOptions +``` + +## Properties + +| Property | Modifiers | Type | Description | Default | +| ---------- | --------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | +| crop | optional | [BoundingBox](./puppeteer.boundingbox.md) | Specifies the region of the viewport to crop. | | +| ffmpegPath | optional | string |

Path to the \[ffmpeg\](https://ffmpeg.org/).

Required if ffmpeg is not in your PATH.

| | +| path | optional | \`${string}.webm\` | File path to save the screencast to. | | +| scale | optional | number |

Scales the output video.

For example, 0.5 will shrink the width and height of the output video by half. 2 will double the width and height of the output video.

| 1 | +| speed | optional | number |

Specifies the speed to record at.

For example, 0.5 will slowdown the output video by 50%. 2 will double the speed of the output video.

| 1 | diff --git a/docs/api/puppeteer.screenrecorder.md b/docs/api/puppeteer.screenrecorder.md new file mode 100644 index 00000000000..5315090cc07 --- /dev/null +++ b/docs/api/puppeteer.screenrecorder.md @@ -0,0 +1,23 @@ +--- +sidebar_label: ScreenRecorder +--- + +# ScreenRecorder class + +#### Signature: + +```typescript +export declare class ScreenRecorder extends PassThrough +``` + +**Extends:** PassThrough + +## Remarks + +The constructor for this class is marked as internal. Third-party code should not call the constructor directly or create subclasses that extend the `ScreenRecorder` class. + +## Methods + +| Method | Modifiers | Description | +| -------------------------------------------- | --------- | ------------------- | +| [stop()](./puppeteer.screenrecorder.stop.md) | | Stops the recorder. | diff --git a/docs/api/puppeteer.screenrecorder.stop.md b/docs/api/puppeteer.screenrecorder.stop.md new file mode 100644 index 00000000000..042a42e5707 --- /dev/null +++ b/docs/api/puppeteer.screenrecorder.stop.md @@ -0,0 +1,19 @@ +--- +sidebar_label: ScreenRecorder.stop +--- + +# ScreenRecorder.stop() method + +Stops the recorder. + +#### Signature: + +```typescript +class ScreenRecorder { + stop(): Promise; +} +``` + +**Returns:** + +Promise<void> diff --git a/docs/api/puppeteer.screenshotclip.md b/docs/api/puppeteer.screenshotclip.md index 5ab8be38642..61deeff542b 100644 --- a/docs/api/puppeteer.screenshotclip.md +++ b/docs/api/puppeteer.screenshotclip.md @@ -7,15 +7,13 @@ sidebar_label: ScreenshotClip #### Signature: ```typescript -export interface ScreenshotClip +export interface ScreenshotClip extends BoundingBox ``` +**Extends:** [BoundingBox](./puppeteer.boundingbox.md) + ## Properties | Property | Modifiers | Type | Description | Default | | -------- | --------------------- | ------ | ----------- | -------------- | -| height | | number | | | | scale | optional | number | | 1 | -| width | | number | | | -| x | | number | | | -| y | | number | | | diff --git a/packages/puppeteer-core/src/api/Page.ts b/packages/puppeteer-core/src/api/Page.ts index 6662e88a989..ea94e169ce3 100644 --- a/packages/puppeteer-core/src/api/Page.ts +++ b/packages/puppeteer-core/src/api/Page.ts @@ -80,12 +80,14 @@ import { withSourcePuppeteerURLIfNone, } from '../common/util.js'; import type {Viewport} from '../common/Viewport.js'; +import type {ScreenRecorder} from '../node/ScreenRecorder.js'; import {assert} from '../util/assert.js'; import {guarded} from '../util/decorators.js'; import type {Deferred} from '../util/Deferred.js'; import { AsyncDisposableStack, asyncDisposeSymbol, + DisposableStack, disposeSymbol, } from '../util/disposable.js'; @@ -93,7 +95,11 @@ import type {Browser} from './Browser.js'; import type {BrowserContext} from './BrowserContext.js'; import type {CDPSession} from './CDPSession.js'; import type {Dialog} from './Dialog.js'; -import type {ClickOptions, ElementHandle} from './ElementHandle.js'; +import type { + BoundingBox, + ClickOptions, + ElementHandle, +} from './ElementHandle.js'; import type { Frame, FrameAddScriptTagOptions, @@ -212,11 +218,7 @@ export interface MediaFeature { /** * @public */ -export interface ScreenshotClip { - x: number; - y: number; - width: number; - height: number; +export interface ScreenshotClip extends BoundingBox { /** * @defaultValue `1` */ @@ -290,6 +292,44 @@ export interface ScreenshotOptions { allowViewportExpansion?: boolean; } +/** + * @experimental + */ +export interface ScreencastOptions { + /** + * File path to save the screencast to. + */ + path?: `${string}.webm`; + /** + * Specifies the region of the viewport to crop. + */ + crop?: BoundingBox; + /** + * Scales the output video. + * + * For example, `0.5` will shrink the width and height of the output video by + * half. `2` will double the width and height of the output video. + * + * @defaultValue `1` + */ + scale?: number; + /** + * Specifies the speed to record at. + * + * For example, `0.5` will slowdown the output video by 50%. `2` will double the + * speed of the output video. + * + * @defaultValue `1` + */ + speed?: number; + /** + * Path to the [ffmpeg](https://ffmpeg.org/). + * + * Required if `ffmpeg` is not in your PATH. + */ + ffmpegPath?: string; +} + /** * All the events that a page instance may emit. * @@ -2274,6 +2314,179 @@ export abstract class Page extends EventEmitter { await fs.writeFile(path, buffer); } + /** + * Captures a screencast of this {@link Page | page}. + * + * @remarks All recordings will be [WebM](https://www.webmproject.org/) format using + * the [VP9](https://www.webmproject.org/vp9/) video codec. The FPS is 30. + * + * You must have [ffmpeg](https://ffmpeg.org/) installed on your system. + * + * @example Recording a {@link Page | page}: + * + * ``` + * import puppeteer from 'puppeteer'; + * + * // Launch a browser + * const browser = await puppeteer.launch(); + * + * // Create a new page + * const page = await browser.newPage(); + * + * // Go to your site. + * await page.goto("https://www.example.com"); + * + * // Start recording. + * const recorder = await page.screencast({path: 'recording.webm'}); + * + * // Do something. + * + * // Stop recording. + * await recorder.stop(); + * + * browser.close(); + * ``` + * + * @param options - Configures screencast behavior. + * + * @experimental + */ + async screencast( + options: Readonly = {} + ): Promise { + const [{ScreenRecorder}, [width, height, devicePixelRatio]] = + await Promise.all([ + import('../node/ScreenRecorder.js'), + this.#getNativePixelDimensions(), + ]); + + let crop: BoundingBox | undefined; + if (options.crop) { + const { + x, + y, + width: cropWidth, + height: cropHeight, + } = roundRectangle(normalizeRectangle(options.crop)); + if (x < 0 || y < 0) { + throw new Error( + `\`crop.x\` and \`crop.x\` must be greater than or equal to 0.` + ); + } + if (cropWidth <= 0 || cropHeight <= 0) { + throw new Error( + `\`crop.height\` and \`crop.width\` must be greater than or equal to 0.` + ); + } + + const viewportWidth = width / devicePixelRatio; + const viewportHeight = width / devicePixelRatio; + if (x + cropWidth > viewportWidth) { + throw new Error( + `\`crop.width\` cannot be larger than the viewport width (${viewportWidth}).` + ); + } + if (y + cropHeight > viewportHeight) { + throw new Error( + `\`crop.height\` cannot be larger than the viewport height (${viewportHeight}).` + ); + } + + crop = { + x: x * devicePixelRatio, + y: y * devicePixelRatio, + width: cropWidth * devicePixelRatio, + height: cropHeight * devicePixelRatio, + }; + } + if (options.speed !== undefined && options.speed <= 0) { + throw new Error(`\`speed\` must be greater than 0.`); + } + if (options.scale !== undefined && options.scale <= 0) { + throw new Error(`\`scale\` must be greater than 0.`); + } + + const recorder = new ScreenRecorder(this, width, height, { + ...options, + path: options.ffmpegPath, + crop, + }); + try { + await this._startScreencast(); + } catch (error) { + void recorder.stop(); + throw error; + } + if (options.path) { + const {createWriteStream} = await import('fs'); + const stream = createWriteStream(options.path, 'binary'); + recorder.pipe(stream); + } + return recorder; + } + + #screencastSessionCount = 0; + #startScreencastPromise: Promise | undefined; + + /** + * @internal + */ + async _startScreencast(): Promise { + ++this.#screencastSessionCount; + if (!this.#startScreencastPromise) { + this.#startScreencastPromise = this.mainFrame() + .client.send('Page.startScreencast', {format: 'png'}) + .then(() => { + // Wait for the first frame. + return new Promise(resolve => { + return this.mainFrame().client.once('Page.screencastFrame', () => { + return resolve(); + }); + }); + }); + } + await this.#startScreencastPromise; + } + + /** + * @internal + */ + async _stopScreencast(): Promise { + --this.#screencastSessionCount; + if (!this.#startScreencastPromise) { + return; + } + this.#startScreencastPromise = undefined; + if (this.#screencastSessionCount === 0) { + await this.mainFrame().client.send('Page.stopScreencast'); + } + } + + /** + * Gets the native, non-emulated dimensions of the viewport. + */ + async #getNativePixelDimensions(): Promise< + readonly [width: number, height: number, devicePixelRatio: number] + > { + const viewport = this.viewport(); + using stack = new DisposableStack(); + if (viewport && viewport.deviceScaleFactor !== 0) { + await this.setViewport({...viewport, deviceScaleFactor: 0}); + stack.defer(() => { + void this.setViewport(viewport).catch(debugError); + }); + } + return await this.mainFrame() + .isolatedRealm() + .evaluate(() => { + return [ + window.visualViewport!.width * window.devicePixelRatio, + window.visualViewport!.height * window.devicePixelRatio, + window.devicePixelRatio, + ] as const; + }); + } + /** * Captures a screenshot of this {@link Page | page}. * @@ -2374,7 +2587,8 @@ export abstract class Page extends EventEmitter { setDefaultScreenshotOptions(options); - options.clip = options.clip && roundClip(normalizeClip(options.clip)); + options.clip = + options.clip && roundRectangle(normalizeRectangle(options.clip)); await using stack = new AsyncDisposableStack(); if (options.allowViewportExpansion || options.captureBeyondViewport) { @@ -3030,8 +3244,11 @@ function convertPrintParameterToInches( } /** @see https://w3c.github.io/webdriver-bidi/#normalize-rect */ -function normalizeClip(clip: Readonly): ScreenshotClip { +function normalizeRectangle( + clip: Readonly +): BoundingBoxType { return { + ...clip, ...(clip.width < 0 ? { x: clip.x + clip.width, @@ -3050,14 +3267,15 @@ function normalizeClip(clip: Readonly): ScreenshotClip { y: clip.y, height: clip.height, }), - scale: clip.scale, }; } -function roundClip(clip: Readonly): ScreenshotClip { +function roundRectangle( + clip: Readonly +): BoundingBoxType { const x = Math.round(clip.x); const y = Math.round(clip.y); const width = Math.round(clip.width + clip.x - x); const height = Math.round(clip.height + clip.y - y); - return {x, y, width, height, scale: clip.scale}; + return {...clip, x, y, width, height}; } diff --git a/packages/puppeteer-core/src/node/ScreenRecorder.ts b/packages/puppeteer-core/src/node/ScreenRecorder.ts new file mode 100644 index 00000000000..ad284612d3b --- /dev/null +++ b/packages/puppeteer-core/src/node/ScreenRecorder.ts @@ -0,0 +1,272 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import type {ChildProcessWithoutNullStreams} from 'child_process'; +import {spawn, spawnSync} from 'child_process'; +import {PassThrough} from 'stream'; + +import debug from 'debug'; +import type Protocol from 'devtools-protocol'; + +import type { + Observable, + OperatorFunction, +} from '../../third_party/rxjs/rxjs.js'; +import { + bufferCount, + concatMap, + filter, + from, + fromEvent, + lastValueFrom, + map, + takeUntil, + tap, +} from '../../third_party/rxjs/rxjs.js'; +import {CDPSessionEvent} from '../api/CDPSession.js'; +import type {BoundingBox} from '../api/ElementHandle.js'; +import type {Page} from '../api/Page.js'; +import {debugError} from '../common/util.js'; +import {guarded} from '../util/decorators.js'; +import {asyncDisposeSymbol} from '../util/disposable.js'; + +const CRF_VALUE = 30; +const DEFAULT_FPS = 30; + +const debugFfmpeg = debug('puppeteer:ffmpeg'); + +/** + * @internal + */ +export interface ScreenRecorderOptions { + speed?: number; + crop?: BoundingBox; + format?: 'gif' | 'webm'; + scale?: number; + path?: string; +} + +/** + * @public + */ +export class ScreenRecorder extends PassThrough { + #page: Page; + + #process: ChildProcessWithoutNullStreams; + + #controller = new AbortController(); + #lastFrame: Promise; + + /** + * @internal + */ + constructor( + page: Page, + width: number, + height: number, + {speed, scale, crop, format, path}: ScreenRecorderOptions = {} + ) { + super({allowHalfOpen: false}); + + path ??= 'ffmpeg'; + + // Tests if `ffmpeg` exists. + const {error} = spawnSync(path); + if (error) { + throw error; + } + + this.#process = spawn( + path, + // See https://trac.ffmpeg.org/wiki/Encode/VP9 for more information on flags. + [ + ['-loglevel', 'error'], + // Reduces general buffering. + ['-avioflags', 'direct'], + // Reduces initial buffering while analyzing input fps and other stats. + [ + '-fpsprobesize', + `${0}`, + '-probesize', + `${32}`, + '-analyzeduration', + `${0}`, + '-fflags', + 'nobuffer', + ], + // Forces input to be read from standard input, and forces png input + // image format. + ['-f', 'image2pipe', '-c:v', 'png', '-i', 'pipe:0'], + // Overwrite output and no audio. + ['-y', '-an'], + // This drastically reduces stalling when cpu is overbooked. By default + // VP9 tries to use all available threads? + ['-threads', '1'], + // Specifies the frame rate we are giving ffmpeg. + ['-framerate', `${DEFAULT_FPS}`], + // Specifies the encoding and format we are using. + this.#getFormatArgs(format ?? 'webm'), + // Disable bitrate. + ['-b:v', `${0}`], + // Filters to ensure the images are piped correctly. + [ + '-vf', + `${ + speed ? `setpts=${1 / speed}*PTS,` : '' + }crop='min(${width},iw):min(${height},ih):${0}:${0}',pad=${width}:${height}:${0}:${0}${ + crop ? `,crop=${crop.width}:${crop.height}:${crop.x}:${crop.y}` : '' + }${scale ? `,scale=iw*${scale}:-1` : ''}`, + ], + 'pipe:1', + ].flat(), + {stdio: ['pipe', 'pipe', 'pipe']} + ); + this.#process.stdout.pipe(this); + this.#process.stderr.on('data', (data: Buffer) => { + debugFfmpeg(data.toString('utf8')); + }); + + this.#page = page; + + const {client} = this.#page.mainFrame(); + client.once(CDPSessionEvent.Disconnected, () => { + void this.stop().catch(debugError); + }); + + this.#lastFrame = lastValueFrom( + ( + fromEvent( + client, + 'Page.screencastFrame' + ) as Observable + ).pipe( + tap(event => { + void client.send('Page.screencastFrameAck', { + sessionId: event.sessionId, + }); + }), + filter(event => { + return event.metadata.timestamp !== undefined; + }), + map(event => { + return { + buffer: Buffer.from(event.data, 'base64'), + timestamp: event.metadata.timestamp!, + }; + }), + bufferCount(2, 1) as OperatorFunction< + {buffer: Buffer; timestamp: number}, + [ + {buffer: Buffer; timestamp: number}, + {buffer: Buffer; timestamp: number}, + ] + >, + concatMap(([{timestamp: previousTimestamp, buffer}, {timestamp}]) => { + return from( + Array( + Math.round(DEFAULT_FPS * (timestamp - previousTimestamp)) + ).fill(buffer) + ); + }), + map(buffer => { + void this.#writeFrame(buffer); + return [buffer, performance.now()] as const; + }), + takeUntil(fromEvent(this.#controller.signal, 'abort')) + ), + {defaultValue: [Buffer.from([]), performance.now()] as const} + ); + } + + #getFormatArgs(format: 'webm' | 'gif') { + switch (format) { + case 'webm': + return [ + // Sets the codec to use. + ['-c:v', 'vp9'], + // Sets the format + ['-f', 'webm'], + // Sets the quality. Lower the better. + ['-crf', `${CRF_VALUE}`], + // Sets the quality and how efficient the compression will be. + ['-deadline', 'realtime', '-cpu-used', `${8}`], + ].flat(); + case 'gif': + return [ + // Sets the frame rate and uses a custom palette generated from the + // input. + [ + '-vf', + 'fps=5,split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse', + ], + // Sets the format + ['-f', 'gif'], + ].flat(); + } + } + + @guarded() + async #writeFrame(buffer: Buffer) { + const error = await new Promise(resolve => { + this.#process.stdin.write(buffer, resolve); + }); + if (error) { + console.log(`ffmpeg failed to write: ${error.message}.`); + } + } + + /** + * Stops the recorder. + * + * @public + */ + @guarded() + async stop(): Promise { + if (this.#controller.signal.aborted) { + return; + } + // Stopping the screencast will flush the frames. + await this.#page._stopScreencast().catch(debugError); + + this.#controller.abort(); + + // Repeat the last frame for the remaining frames. + const [buffer, timestamp] = await this.#lastFrame; + await Promise.all( + Array( + Math.max( + 1, + Math.round((DEFAULT_FPS * (performance.now() - timestamp)) / 1000) + ) + ) + .fill(buffer) + .map(this.#writeFrame.bind(this)) + ); + + // Close stdin to notify FFmpeg we are done. + this.#process.stdin.end(); + await new Promise(resolve => { + this.#process.once('close', resolve); + }); + } + + /** + * @internal + */ + async [asyncDisposeSymbol](): Promise { + await this.stop(); + } +} diff --git a/packages/puppeteer-core/src/node/node.ts b/packages/puppeteer-core/src/node/node.ts index da815faf166..687297a24aa 100644 --- a/packages/puppeteer-core/src/node/node.ts +++ b/packages/puppeteer-core/src/node/node.ts @@ -20,3 +20,4 @@ export * from './LaunchOptions.js'; export * from './PipeTransport.js'; export * from './ProductLauncher.js'; export * from './PuppeteerNode.js'; +export * from './ScreenRecorder.js'; diff --git a/packages/puppeteer-core/third_party/rxjs/rxjs.ts b/packages/puppeteer-core/third_party/rxjs/rxjs.ts index a2ab61437d8..4ab426721a5 100644 --- a/packages/puppeteer-core/third_party/rxjs/rxjs.ts +++ b/packages/puppeteer-core/third_party/rxjs/rxjs.ts @@ -14,7 +14,9 @@ * limitations under the License. */ export { + bufferCount, catchError, + concatMap, defaultIfEmpty, defer, delay, @@ -26,6 +28,7 @@ export { fromEvent, identity, ignoreElements, + lastValueFrom, map, merge, mergeMap, @@ -40,12 +43,13 @@ export { retry, startWith, switchMap, + takeUntil, tap, throwIfEmpty, timer, } from 'rxjs'; -import {mergeMap, from, filter, map, type Observable} from 'rxjs'; +import {filter, from, map, mergeMap, type Observable} from 'rxjs'; export function filterAsync( predicate: (value: T) => boolean | PromiseLike diff --git a/test/TestExpectations.json b/test/TestExpectations.json index 1ad629f4121..cbd181fc787 100644 --- a/test/TestExpectations.json +++ b/test/TestExpectations.json @@ -323,6 +323,12 @@ "parameters": ["webDriverBiDi"], "expectations": ["PASS"] }, + { + "testIdPattern": "[screencast.spec] *", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["FAIL"] + }, { "testIdPattern": "[screenshot.spec] *", "platforms": ["darwin", "linux", "win32"], @@ -1109,6 +1115,18 @@ "parameters": ["webDriverBiDi"], "expectations": ["FAIL", "PASS", "TIMEOUT"] }, + { + "testIdPattern": "[prerender.spec] Prerender can screencast", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[prerender.spec] Prerender can screencast", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["new-headless"], + "expectations": ["FAIL", "PASS"] + }, { "testIdPattern": "[proxy.spec] *", "platforms": ["darwin", "linux", "win32"], @@ -1187,6 +1205,12 @@ "parameters": ["cdp", "firefox"], "expectations": ["FAIL", "SKIP"] }, + { + "testIdPattern": "[screencast.spec] Screencasts Page.screencast should validate options", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["PASS"] + }, { "testIdPattern": "[screenshot.spec] Screenshots Page.screenshot should clip clip bigger than the viewport without \"captureBeyondViewport\"", "platforms": ["darwin", "linux", "win32"], @@ -3299,6 +3323,12 @@ "parameters": ["firefox", "headful"], "expectations": ["PASS", "TIMEOUT"] }, + { + "testIdPattern": "[prerender.spec] Prerender can screencast", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["chrome", "webDriverBiDi"], + "expectations": ["TIMEOUT"] + }, { "testIdPattern": "[proxy.spec] request proxy in incognito browser context should proxy requests when configured at browser level", "platforms": ["darwin", "linux", "win32"], diff --git a/test/assets/prerender/target.html b/test/assets/prerender/target.html index df78fcc394e..f384b3cbb02 100644 --- a/test/assets/prerender/target.html +++ b/test/assets/prerender/target.html @@ -2,4 +2,4 @@ -target +target diff --git a/test/src/cdp/prerender.spec.ts b/test/src/cdp/prerender.spec.ts index 1add6d8360d..5993a9462e2 100644 --- a/test/src/cdp/prerender.spec.ts +++ b/test/src/cdp/prerender.spec.ts @@ -14,9 +14,12 @@ * limitations under the License. */ +import {statSync} from 'fs'; + import expect from 'expect'; import {getTestState, setupTestBrowserHooks} from '../mocha-utils.js'; +import {getUniqueVideoFilePlaceholder} from '../utils.js'; describe('Prerender', function () { setupTestBrowserHooks(); @@ -90,6 +93,33 @@ describe('Prerender', function () { }); }); + it('can screencast', async () => { + using file = getUniqueVideoFilePlaceholder(); + + const {page, server} = await getTestState(); + + const recorder = await page.screencast({ + path: file.filename, + scale: 0.5, + crop: {width: 100, height: 100, x: 0, y: 0}, + speed: 0.5, + }); + + await page.goto(server.PREFIX + '/prerender/index.html'); + + using button = await page.waitForSelector('button'); + await button?.click(); + + using link = await page.locator('a').waitHandle(); + await Promise.all([page.waitForNavigation(), link.click()]); + using input = await page.locator('input').waitHandle(); + await input.type('ab', {delay: 100}); + + await recorder.stop(); + + expect(statSync(file.filename).size).toBeGreaterThan(0); + }); + describe('with network requests', () => { it('can receive requests from the prerendered page', async () => { const {page, server} = await getTestState(); diff --git a/test/src/screencast.spec.ts b/test/src/screencast.spec.ts new file mode 100644 index 00000000000..0f7e787f62e --- /dev/null +++ b/test/src/screencast.spec.ts @@ -0,0 +1,109 @@ +/** + * Copyright 2023 Google Inc. All rights reserved. + * + * 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. + */ + +import {statSync} from 'fs'; + +import expect from 'expect'; + +import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; +import {getUniqueVideoFilePlaceholder} from './utils.js'; + +describe('Screencasts', function () { + setupTestBrowserHooks(); + + describe('Page.screencast', function () { + it('should work', async () => { + using file = getUniqueVideoFilePlaceholder(); + + const {page} = await getTestState(); + + const recorder = await page.screencast({ + path: file.filename, + scale: 0.5, + crop: {width: 100, height: 100, x: 0, y: 0}, + speed: 0.5, + }); + + await page.goto('data:text/html,'); + using input = await page.locator('input').waitHandle(); + await input.type('ab', {delay: 100}); + + await recorder.stop(); + + expect(statSync(file.filename).size).toBeGreaterThan(0); + }); + it('should work concurrently', async () => { + using file1 = getUniqueVideoFilePlaceholder(); + using file2 = getUniqueVideoFilePlaceholder(); + + const {page} = await getTestState(); + + const recorder = await page.screencast({path: file1.filename}); + const recorder2 = await page.screencast({path: file2.filename}); + + await page.goto('data:text/html,'); + using input = await page.locator('input').waitHandle(); + + await input.type('ab', {delay: 100}); + await recorder.stop(); + + await input.type('ab', {delay: 100}); + await recorder2.stop(); + + // Since file2 spent about double the time of file1 recording, so file2 + // should be around double the size of file1. + const ratio = + statSync(file2.filename).size / statSync(file1.filename).size; + + // We use a range because we cannot be precise. + const DELTA = 1.3; + expect(ratio).toBeGreaterThan(2 - DELTA); + expect(ratio).toBeLessThan(2 + DELTA); + }); + it('should validate options', async () => { + const {page} = await getTestState(); + + await expect(page.screencast({scale: 0})).rejects.toBeDefined(); + await expect(page.screencast({scale: -1})).rejects.toBeDefined(); + + await expect(page.screencast({speed: 0})).rejects.toBeDefined(); + await expect(page.screencast({speed: -1})).rejects.toBeDefined(); + + await expect( + page.screencast({crop: {x: 0, y: 0, height: 1, width: 0}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: 0, y: 0, height: 0, width: 1}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: -1, y: 0, height: 1, width: 1}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: 0, y: -1, height: 1, width: 1}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: 0, y: 0, height: 10000, width: 1}}) + ).rejects.toBeDefined(); + await expect( + page.screencast({crop: {x: 0, y: 0, height: 1, width: 10000}}) + ).rejects.toBeDefined(); + + await expect( + page.screencast({ffmpegPath: 'non-existent-path'}) + ).rejects.toBeDefined(); + }); + }); +}); diff --git a/test/src/utils.ts b/test/src/utils.ts index 1148da99613..7e786d15da8 100644 --- a/test/src/utils.ts +++ b/test/src/utils.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import {rm} from 'fs/promises'; +import {tmpdir} from 'os'; import path from 'path'; import expect from 'expect'; @@ -157,3 +159,23 @@ export const waitEvent = async ( emitter.off(eventName, handler); } }; + +export interface FilePlaceholder { + filename: `${string}.webm`; + [Symbol.dispose](): void; +} + +export function getUniqueVideoFilePlaceholder(): FilePlaceholder { + return { + filename: `${tmpdir()}/test-video-${Math.round( + Math.random() * 10000 + )}.webm`, + [Symbol.dispose]() { + void rmIfExists(this.filename); + }, + }; +} + +export function rmIfExists(file: string): Promise { + return rm(file).catch(() => {}); +}