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).
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(() => {});
+}