feat: implement screencasting (#11084)
This commit is contained in:
parent
ddbb43cd09
commit
f060d467c0
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -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:
|
||||
|
@ -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) | <p>The main Puppeteer class.</p><p>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 <code>puppeteer</code>. That class extends <code>Puppeteer</code>, so has all the methods documented below as well as all that are defined on [PuppeteerNode](./puppeteer.puppeteernode.md).</p> |
|
||||
| [PuppeteerNode](./puppeteer.puppeteernode.md) | <p>Extends the main [Puppeteer](./puppeteer.puppeteer.md) class with Node specific behaviour for fetching and downloading browsers.</p><p>If you're using Puppeteer in a Node environment, this is the class you'll get when you run <code>require('puppeteer')</code> (or the equivalent ES <code>import</code>).</p> |
|
||||
| [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. |
|
||||
|
@ -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 <code>name</code> from the page's <code>window</code> 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 <code>change</code> and <code>input</code> event once all the provided options have been selected. If there's no <code><select></code> element matching <code>selector</code>, the method throws an error. |
|
||||
|
58
docs/api/puppeteer.page.screencast.md
Normal file
58
docs/api/puppeteer.page.screencast.md
Normal file
@ -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<ScreencastOptions>): Promise<ScreenRecorder>;
|
||||
}
|
||||
```
|
||||
|
||||
## 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();
|
||||
```
|
21
docs/api/puppeteer.screencastoptions.md
Normal file
21
docs/api/puppeteer.screencastoptions.md
Normal file
@ -0,0 +1,21 @@
|
||||
---
|
||||
sidebar_label: ScreencastOptions
|
||||
---
|
||||
|
||||
# ScreencastOptions interface
|
||||
|
||||
#### Signature:
|
||||
|
||||
```typescript
|
||||
export interface ScreencastOptions
|
||||
```
|
||||
|
||||
## Properties
|
||||
|
||||
| Property | Modifiers | Type | Description | Default |
|
||||
| ---------- | --------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- |
|
||||
| crop | <code>optional</code> | [BoundingBox](./puppeteer.boundingbox.md) | Specifies the region of the viewport to crop. | |
|
||||
| ffmpegPath | <code>optional</code> | string | <p>Path to the \[ffmpeg\](https://ffmpeg.org/).</p><p>Required if <code>ffmpeg</code> is not in your PATH.</p> | |
|
||||
| path | <code>optional</code> | \`${string}.webm\` | File path to save the screencast to. | |
|
||||
| scale | <code>optional</code> | number | <p>Scales the output video.</p><p>For example, <code>0.5</code> will shrink the width and height of the output video by half. <code>2</code> will double the width and height of the output video.</p> | <code>1</code> |
|
||||
| speed | <code>optional</code> | number | <p>Specifies the speed to record at.</p><p>For example, <code>0.5</code> will slowdown the output video by 50%. <code>2</code> will double the speed of the output video.</p> | <code>1</code> |
|
23
docs/api/puppeteer.screenrecorder.md
Normal file
23
docs/api/puppeteer.screenrecorder.md
Normal file
@ -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. |
|
19
docs/api/puppeteer.screenrecorder.stop.md
Normal file
19
docs/api/puppeteer.screenrecorder.stop.md
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
sidebar_label: ScreenRecorder.stop
|
||||
---
|
||||
|
||||
# ScreenRecorder.stop() method
|
||||
|
||||
Stops the recorder.
|
||||
|
||||
#### Signature:
|
||||
|
||||
```typescript
|
||||
class ScreenRecorder {
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:**
|
||||
|
||||
Promise<void>
|
@ -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 | <code>optional</code> | number | | <code>1</code> |
|
||||
| width | | number | | |
|
||||
| x | | number | | |
|
||||
| y | | number | | |
|
||||
|
@ -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<PageEvents> {
|
||||
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<ScreencastOptions> = {}
|
||||
): Promise<ScreenRecorder> {
|
||||
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<void> | undefined;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
async _startScreencast(): Promise<void> {
|
||||
++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<void> {
|
||||
--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<PageEvents> {
|
||||
|
||||
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>): ScreenshotClip {
|
||||
function normalizeRectangle<BoundingBoxType extends BoundingBox>(
|
||||
clip: Readonly<BoundingBoxType>
|
||||
): BoundingBoxType {
|
||||
return {
|
||||
...clip,
|
||||
...(clip.width < 0
|
||||
? {
|
||||
x: clip.x + clip.width,
|
||||
@ -3050,14 +3267,15 @@ function normalizeClip(clip: Readonly<ScreenshotClip>): ScreenshotClip {
|
||||
y: clip.y,
|
||||
height: clip.height,
|
||||
}),
|
||||
scale: clip.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function roundClip(clip: Readonly<ScreenshotClip>): ScreenshotClip {
|
||||
function roundRectangle<BoundingBoxType extends BoundingBox>(
|
||||
clip: Readonly<BoundingBoxType>
|
||||
): 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};
|
||||
}
|
||||
|
272
packages/puppeteer-core/src/node/ScreenRecorder.ts
Normal file
272
packages/puppeteer-core/src/node/ScreenRecorder.ts
Normal file
@ -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<readonly [Buffer, number]>;
|
||||
|
||||
/**
|
||||
* @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<Protocol.Page.ScreencastFrameEvent>
|
||||
).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<Buffer>(
|
||||
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<Error | null | undefined>(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<void> {
|
||||
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<Buffer>(
|
||||
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<void> {
|
||||
await this.stop();
|
||||
}
|
||||
}
|
@ -20,3 +20,4 @@ export * from './LaunchOptions.js';
|
||||
export * from './PipeTransport.js';
|
||||
export * from './ProductLauncher.js';
|
||||
export * from './PuppeteerNode.js';
|
||||
export * from './ScreenRecorder.js';
|
||||
|
@ -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<T>(
|
||||
predicate: (value: T) => boolean | PromiseLike<boolean>
|
||||
|
@ -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"],
|
||||
|
@ -2,4 +2,4 @@
|
||||
<head>
|
||||
<script>fetch('target.html?fromPrerendered')</script>
|
||||
</head>
|
||||
<body>target</body>
|
||||
<body>target<input></input></body>
|
||||
|
@ -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();
|
||||
|
109
test/src/screencast.spec.ts
Normal file
109
test/src/screencast.spec.ts
Normal file
@ -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,<input>');
|
||||
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,<input>');
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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 <T = any>(
|
||||
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<void> {
|
||||
return rm(file).catch(() => {});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user