├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── PageVideoCapture.ts ├── ScreencastFrameCollector.ts ├── SortedFrameQueue.ts ├── VideoWriter.ts ├── example.js ├── index.ts ├── saveVideo.ts └── utils.ts ├── tests ├── PageVideoCapture.test.ts ├── ScreencastFrameCollector.test.ts ├── SortedFrameQueue.test.ts ├── VideoWriter.test.ts ├── saveVideo.test.ts └── utils.test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [*.md] 10 | insert_final_newline = false 11 | trim_trailing_whitespace = false 12 | 13 | [*.{js,jsx,json,ts,tsx,yml}] 14 | indent_size = 2 15 | indent_style = space 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /**/*.js 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "createDefaultProgram": true, 10 | "project": "tsconfig.json", 11 | "sourceType": "module" 12 | }, 13 | "plugins": ["@typescript-eslint", "jest"], 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:@typescript-eslint/eslint-recommended", 17 | "plugin:@typescript-eslint/recommended", 18 | "plugin:jest/recommended", 19 | "prettier", 20 | "prettier/@typescript-eslint" 21 | ], 22 | "rules": {} 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | node-version: [10.x] 17 | test: [lint, chromium] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Install chromium dependencies 23 | if: matrix.test == 'chromium' 24 | run: sudo apt-get install libgbm1 25 | 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v1 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | 31 | - uses: actions/cache@v1 32 | with: 33 | path: ~/.npm 34 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 35 | restore-keys: | 36 | ${{ runner.os }}-node- 37 | 38 | - run: npm install 39 | 40 | - run: npm run lint 41 | if: matrix.test == 'lint' 42 | 43 | - run: npm run build 44 | 45 | - run: npm test 46 | if: matrix.test != 'lint' 47 | env: 48 | CI: true 49 | 50 | timeout-minutes: 20 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Dependencies 7 | node_modules/ 8 | 9 | # Coverage 10 | coverage 11 | 12 | # Transpiled files 13 | build/ 14 | 15 | # VS Code 16 | .vscode 17 | !.vscode/tasks.js 18 | 19 | # JetBrains IDEs 20 | .idea/ 21 | 22 | # Optional npm cache directory 23 | .npm 24 | 25 | # Optional eslint cache 26 | .eslintcache 27 | 28 | # Misc 29 | .DS_Store -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "overrides": [ 5 | { 6 | "files": "*.ts", 7 | "options": { 8 | "parser": "typescript" 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 QA Wolf (https://qawolf.com). 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name QA Wolf nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # playwright-video 2 | 3 | [![npm version](https://badge.fury.io/js/playwright-video.svg)](https://badge.fury.io/js/playwright-video) ![Unit Tests](https://github.com/qawolf/playwright-video/workflows/Unit%20Tests/badge.svg) 4 | 5 | 🎬 Save a video recording of a [Playwright](https://github.com/microsoft/playwright) page (Chromium only for now) 6 | 7 | When Playwright adds support for the Screencast API in Firefox and WebKit, this will be updated to support these browsers. 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm i playwright playwright-video @ffmpeg-installer/ffmpeg 13 | ``` 14 | 15 | If you already have [FFmpeg](https://www.ffmpeg.org) installed, you can skip the `@ffmpeg-installer/ffmpeg` installation by setting the `FFMPEG_PATH` environment variable. 16 | 17 | ```sh 18 | npm i playwright playwright-video 19 | ``` 20 | 21 | ## Use 22 | 23 | ```js 24 | const { chromium } = require('playwright'); 25 | const { saveVideo } = require('playwright-video'); 26 | 27 | (async () => { 28 | const browser = await chromium.launch(); 29 | const context = await browser.newContext(); 30 | const page = await context.newPage(); 31 | 32 | await saveVideo(page, '/tmp/video.mp4'); 33 | await page.goto('http://example.org'); 34 | await page.click('a'); 35 | 36 | await browser.close(); 37 | })(); 38 | ``` 39 | 40 | The video will be saved at the specified `savePath` (`/tmp/video.mp4` in the above example). 41 | 42 | ## API 43 | 44 | #### playwright-video.saveVideo(page, savePath[, options]) 45 | 46 | - `page` <[Page]> Save a video of this page. Only supports Chromium for now. 47 | - `savePath` <[string]> Where to save the video. 48 | - `options` <[Object]> 49 | - `followPopups` <[boolean]> Whether or not to follow browser focus when popups are opened. Defaults to `false`. Note: this option will only work correctly if the popups opened are the same size as the original page. If a smaller or larger popup is open, frames will be scaled to fit the original size. 50 | - `fps` <[number]> The frames per second for the recording. Defaults to `25`. A higher number will improve the recording quality but also increase the file size. 51 | - returns: <[Promise]<[PageVideoCapture](#class-pagevideocapture)>> 52 | 53 | Records video of a page and saves it at the specified path. 54 | 55 | ```js 56 | await saveVideo(page, '/tmp/video.mp4'); 57 | ``` 58 | 59 | ### class: PageVideoCapture 60 | 61 | A `PageVideoCapture` is created when you call `saveVideo`. It manages capturing the video of your page and saving it. 62 | 63 | ### pageVideoCapture.stop() 64 | 65 | - returns <[Promise]> 66 | 67 | Stop the video capture if needed and save the video. The returned `Promise` resolves when the video is saved. 68 | 69 | The video capture will be stopped automatically if you close the page, so you should not need to call this unless you want to explicitly wait until the video is saved. 70 | 71 | ```js 72 | const capture = await saveVideo(page, '/tmp/video.mp4'); 73 | await capture.stop(); 74 | ``` 75 | 76 | [object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object 'Object' 77 | [page]: https://github.com/microsoft/playwright/blob/master/docs/api.md#class-page 'Page' 78 | [promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise 'Promise' 79 | [string]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type 'string' 80 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 7 | testRegex: '(/tests/.*.(test|spec)).(jsx?|tsx?)$', 8 | coverageDirectory: 'coverage', 9 | collectCoverageFrom: ['src/**/*.{ts,tsx,js,jsx}', '!src/**/*.d.ts'], 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "QA Wolf", 3 | "name": "playwright-video", 4 | "license": "BSD-3-Clause-Clear", 5 | "version": "2.4.0", 6 | "description": "Capture a video of a Playwright page", 7 | "main": "./build/index.js", 8 | "types": "./build/index.d.ts", 9 | "files": [ 10 | "build", 11 | "src" 12 | ], 13 | "engines": { 14 | "node": ">=10.15.0" 15 | }, 16 | "scripts": { 17 | "clean": "rimraf coverage build tmp", 18 | "build": "tsc -p tsconfig.json", 19 | "watch": "tsc -w -p tsconfig.json", 20 | "lint": "eslint . --ext .ts,.tsx", 21 | "release": "np --no-cleanup", 22 | "test": "jest", 23 | "test:watch": "jest --watch", 24 | "version": "npm run clean && npm run build" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/qawolf/playwright-video.git" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/qawolf/playwright-video/issues" 32 | }, 33 | "dependencies": { 34 | "debug": "*", 35 | "fluent-ffmpeg": "^2.1.2", 36 | "fs-extra": "^9.0.1", 37 | "playwright-core": "^1.4.1", 38 | "tslib": "^2.0.1" 39 | }, 40 | "devDependencies": { 41 | "@ffmpeg-installer/ffmpeg": "^1.0.20", 42 | "@types/debug": "^4.1.5", 43 | "@types/fluent-ffmpeg": "^2.1.15", 44 | "@types/fs-extra": "^9.0.1", 45 | "@types/jest": "^26.0.14", 46 | "@types/node": "^14.11.2", 47 | "@typescript-eslint/eslint-plugin": "^4.2.0", 48 | "@typescript-eslint/parser": "^4.2.0", 49 | "eslint": "^7.9.0", 50 | "eslint-config-prettier": "^6.11.0", 51 | "eslint-plugin-jest": "^24.0.2", 52 | "jest": "^26.4.2", 53 | "np": "^6.5.0", 54 | "playwright": "^1.4.1", 55 | "playwright-core": "^1.4.1", 56 | "prettier": "^2.1.2", 57 | "rimraf": "^3.0.2", 58 | "ts-jest": "^26.4.0", 59 | "tsutils": "^3.17.1", 60 | "typescript": "^4.0.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/PageVideoCapture.ts: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | import { Page } from 'playwright-core'; 3 | import { SortedFrameQueue } from './SortedFrameQueue'; 4 | import { 5 | ScreencastFrame, 6 | ScreencastFrameCollector, 7 | } from './ScreencastFrameCollector'; 8 | import { VideoWriter } from './VideoWriter'; 9 | 10 | const debug = Debug('pw-video:PageVideoCapture'); 11 | 12 | export interface CaptureOptions { 13 | followPopups: boolean; 14 | fps?: number; 15 | } 16 | 17 | interface ConstructorArgs { 18 | collector: ScreencastFrameCollector; 19 | queue: SortedFrameQueue; 20 | page: Page; 21 | writer: VideoWriter; 22 | } 23 | 24 | interface StartArgs { 25 | page: Page; 26 | savePath: string; 27 | options?: CaptureOptions; 28 | } 29 | 30 | export class PageVideoCapture { 31 | public static async start({ 32 | page, 33 | savePath, 34 | options, 35 | }: StartArgs): Promise { 36 | debug('start'); 37 | 38 | const collector = await ScreencastFrameCollector.create(page, options); 39 | const queue = new SortedFrameQueue(); 40 | const writer = await VideoWriter.create(savePath, options); 41 | 42 | const capture = new PageVideoCapture({ collector, queue, page, writer }); 43 | await collector.start(); 44 | 45 | return capture; 46 | } 47 | 48 | // public for tests 49 | public _collector: ScreencastFrameCollector; 50 | private _previousFrame?: ScreencastFrame; 51 | private _queue: SortedFrameQueue; 52 | // public for tests 53 | public _stopped = false; 54 | private _writer: VideoWriter; 55 | 56 | protected constructor({ collector, queue, page, writer }: ConstructorArgs) { 57 | this._collector = collector; 58 | this._queue = queue; 59 | this._writer = writer; 60 | 61 | this._writer.on('ffmpegerror', (error) => { 62 | debug(`stop due to ffmpeg error "${error.trim()}"`); 63 | this.stop(); 64 | }); 65 | 66 | page.on('close', () => this.stop()); 67 | 68 | this._listenForFrames(); 69 | } 70 | 71 | private _listenForFrames(): void { 72 | this._collector.on('screencastframe', (screencastFrame) => { 73 | debug(`collected frame from screencast: ${screencastFrame.timestamp}`); 74 | this._queue.insert(screencastFrame); 75 | }); 76 | 77 | this._queue.on('sortedframes', (frames) => { 78 | debug(`received ${frames.length} frames from queue`); 79 | frames.forEach((frame) => this._writePreviousFrame(frame)); 80 | }); 81 | } 82 | 83 | private _writePreviousFrame(currentFrame: ScreencastFrame): void { 84 | // write the previous frame based on the duration between it and the current frame 85 | if (this._previousFrame) { 86 | const durationSeconds = 87 | currentFrame.timestamp - this._previousFrame.timestamp; 88 | this._writer.write(this._previousFrame.data, durationSeconds); 89 | } 90 | 91 | this._previousFrame = currentFrame; 92 | } 93 | 94 | private _writeFinalFrameUpToTimestamp(stoppedTimestamp: number): void { 95 | if (!this._previousFrame) return; 96 | 97 | // write the final frame based on the duration between it and when the screencast was stopped 98 | debug('write final frame'); 99 | const durationSeconds = stoppedTimestamp - this._previousFrame.timestamp; 100 | this._writer.write(this._previousFrame.data, durationSeconds); 101 | } 102 | 103 | public async stop(): Promise { 104 | if (this._stopped) return; 105 | 106 | debug('stop'); 107 | this._stopped = true; 108 | 109 | const stoppedTimestamp = await this._collector.stop(); 110 | this._queue.drain(); 111 | this._writeFinalFrameUpToTimestamp(stoppedTimestamp); 112 | 113 | return this._writer.stop(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/ScreencastFrameCollector.ts: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | import { EventEmitter } from 'events'; 3 | import { CDPSession, ChromiumBrowserContext, Page } from 'playwright-core'; 4 | import { CaptureOptions } from './PageVideoCapture'; 5 | import { ensurePageType } from './utils'; 6 | 7 | const debug = Debug('pw-video:ScreencastFrameCollector'); 8 | 9 | export interface ScreencastFrame { 10 | data: Buffer; 11 | received: number; 12 | timestamp: number; 13 | } 14 | 15 | export class ScreencastFrameCollector extends EventEmitter { 16 | public static async create(originalPage: Page, options?: CaptureOptions): Promise { 17 | ensurePageType(originalPage); 18 | 19 | const collector = new ScreencastFrameCollector(originalPage, options); 20 | 21 | return collector; 22 | } 23 | 24 | // public for tests 25 | public _clients: [CDPSession?]; 26 | private _originalPage: Page; 27 | private _stoppedTimestamp: number; 28 | private _endedPromise: Promise; 29 | // public for tests 30 | public _followPopups: boolean; 31 | 32 | protected constructor(page: Page, options: CaptureOptions) { 33 | super(); 34 | this._originalPage = page; 35 | this._clients = []; 36 | this._followPopups = options ? options.followPopups : false; 37 | 38 | this._popupFollower = this._popupFollower.bind(this); 39 | } 40 | 41 | private async _popupFollower(popup: Page): Promise { 42 | await this._activatePage(popup); 43 | 44 | // for tests 45 | this.emit('popupFollowed'); 46 | 47 | popup.once('close', async () => { 48 | await this._deactivatePage(popup); 49 | }); 50 | } 51 | 52 | private _installPopupFollower(page: Page): void { 53 | page.on('popup', this._popupFollower); 54 | } 55 | 56 | private _uninstallPopupFollower(page: Page): void { 57 | page.off('popup', this._popupFollower); 58 | } 59 | 60 | private async _buildClient(page: Page): Promise { 61 | const context = page.context() as ChromiumBrowserContext; 62 | const client = await context.newCDPSession(page); 63 | 64 | return client; 65 | } 66 | 67 | private _getActiveClient(): CDPSession | null { 68 | return this._clients[this._clients.length - 1]; 69 | } 70 | 71 | private _listenForFrames(client: CDPSession): void { 72 | this._endedPromise = new Promise((resolve) => { 73 | client.on('Page.screencastFrame', async (payload) => { 74 | if (!payload.metadata.timestamp) { 75 | debug('skipping frame without timestamp'); 76 | return; 77 | } 78 | 79 | if (this._stoppedTimestamp && payload.metadata.timestamp > this._stoppedTimestamp) { 80 | debug('all frames received'); 81 | resolve(); 82 | return; 83 | } 84 | 85 | debug(`received frame with timestamp ${payload.metadata.timestamp}`); 86 | 87 | const ackPromise = client.send('Page.screencastFrameAck', { 88 | sessionId: payload.sessionId, 89 | }); 90 | 91 | this.emit('screencastframe', { 92 | data: Buffer.from(payload.data, 'base64'), 93 | received: Date.now(), 94 | timestamp: payload.metadata.timestamp, 95 | }); 96 | 97 | try { 98 | // capture error so it does not propagate to the user 99 | // most likely it is due to the active page closing 100 | await ackPromise; 101 | } catch (e) { 102 | debug('error sending screencastFrameAck %j', e.message); 103 | } 104 | }); 105 | }); 106 | } 107 | 108 | private async _activatePage(page: Page): Promise { 109 | debug('activating page: ', page.url()); 110 | 111 | let client; 112 | 113 | try { 114 | client = await this._buildClient(page); 115 | } catch (e) { 116 | // capture error so it does not propagate to the user 117 | // this is most likely due to the page not being open 118 | // long enough to attach the CDP session 119 | debug('error building client %j', e.message); 120 | return; 121 | } 122 | 123 | const previousClient = this._getActiveClient(); 124 | 125 | if (previousClient) { 126 | await previousClient.send('Page.stopScreencast'); 127 | } 128 | 129 | this._clients.push(client); 130 | this._listenForFrames(client); 131 | 132 | try { 133 | await client.send('Page.startScreencast', { 134 | everyNthFrame: 1, 135 | }); 136 | } catch (e) { 137 | // capture error so it does not propagate to the user 138 | // this is most likely due to the page not being open 139 | // long enough to start recording after attaching the CDP session 140 | this._deactivatePage(page); 141 | debug('error activating page %j', e.message); 142 | } 143 | } 144 | 145 | private async _deactivatePage(page: Page): Promise { 146 | debug('deactivating page: ', page.url()); 147 | 148 | this._clients.pop(); 149 | 150 | const previousClient = this._getActiveClient(); 151 | try { 152 | // capture error so it does not propagate to the user 153 | // most likely it is due to the original page closing 154 | await previousClient.send('Page.startScreencast', { 155 | everyNthFrame: 1, 156 | }); 157 | } catch (e) { 158 | debug('error reactivating previous page %j', e.message); 159 | } 160 | } 161 | 162 | public async start(): Promise { 163 | debug('start'); 164 | 165 | await this._activatePage(this._originalPage); 166 | 167 | if (this._followPopups) { 168 | this._installPopupFollower(this._originalPage); 169 | } 170 | } 171 | 172 | public async stop(): Promise { 173 | if (this._stoppedTimestamp) { 174 | throw new Error( 175 | 'pw-video: Cannot call stop twice on the same capture.', 176 | ); 177 | } 178 | 179 | if (this._followPopups) { 180 | this._uninstallPopupFollower(this._originalPage); 181 | } 182 | 183 | this._stoppedTimestamp = Date.now() / 1000; 184 | debug(`stopping screencast at ${this._stoppedTimestamp}`); 185 | 186 | // Make sure stopping takes no longer than 1s in cases when the screencast API 187 | // doesn't emit frames all the way up to the stopped timestamp. 188 | await Promise.race([ 189 | this._endedPromise, 190 | new Promise((resolve) => setTimeout(resolve, 1000)), 191 | ]); 192 | 193 | try { 194 | debug('detaching client'); 195 | for (const client of this._clients) { 196 | await client.detach(); 197 | } 198 | } catch (e) { 199 | debug('error detaching client', e.message); 200 | } 201 | 202 | debug('stopped'); 203 | 204 | return this._stoppedTimestamp; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/SortedFrameQueue.ts: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | import { EventEmitter } from 'events'; 3 | import { ScreencastFrame } from './ScreencastFrameCollector'; 4 | 5 | const debug = Debug('pw-video:SortedFrameQueue'); 6 | 7 | // Frames are sorted as they're inserted into the queue. This allows us 8 | // to preserve frames that are sent out of order from CDP instead of discarding them. 9 | // When the queue is full, half of the frames are emitted for processing. 10 | // When we're done working with the queue, we can drain the remaining frames. 11 | 12 | export class SortedFrameQueue extends EventEmitter { 13 | // public for tests 14 | public _frames = []; 15 | private _size = 40; 16 | 17 | constructor(size?: number) { 18 | super(); 19 | 20 | if (size) { 21 | this._size = size; 22 | } 23 | } 24 | 25 | private _findInsertionIndex(timestamp: number): number { 26 | if (this._frames.length === 0) { 27 | return 0; 28 | } 29 | 30 | let i: number; 31 | let frame: ScreencastFrame; 32 | 33 | for (i = this._frames.length - 1; i >= 0; i--) { 34 | frame = this._frames[i]; 35 | 36 | if (timestamp > frame.timestamp) { 37 | break; 38 | } 39 | } 40 | 41 | return i + 1; 42 | } 43 | 44 | private _emitFrames(frames: ScreencastFrame[]): void { 45 | debug(`emitting ${frames.length} frames`); 46 | 47 | this.emit('sortedframes', frames); 48 | } 49 | 50 | public insert(frame: ScreencastFrame): void { 51 | // If the queue is already full, send half of the frames for processing first 52 | if (this._frames.length === this._size) { 53 | const numberOfFramesToSplice = Math.floor(this._size / 2); 54 | const framesToProcess = this._frames.splice(0, numberOfFramesToSplice); 55 | 56 | this._emitFrames(framesToProcess); 57 | } 58 | 59 | const insertionIndex = this._findInsertionIndex(frame.timestamp); 60 | 61 | if (insertionIndex === this._frames.length) { 62 | debug(`inserting frame into queue at end: ${frame.timestamp}`); 63 | // If this frame is in order, push it 64 | this._frames.push(frame); 65 | } else { 66 | debug( 67 | `inserting frame into queue at index ${insertionIndex}: ${frame.timestamp}`, 68 | ); 69 | // If this frame is out of order, splice it in 70 | this._frames.splice(insertionIndex, 0, frame); 71 | } 72 | } 73 | 74 | public drain(): void { 75 | debug('draining queue'); 76 | 77 | // Send all remaining frames for processing 78 | this._emitFrames(this._frames); 79 | 80 | this._frames = []; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/VideoWriter.ts: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | import { EventEmitter } from 'events'; 3 | import ffmpeg from 'fluent-ffmpeg'; 4 | import { ensureDir } from 'fs-extra'; 5 | import { dirname } from 'path'; 6 | import { PassThrough } from 'stream'; 7 | import { ensureFfmpegPath } from './utils'; 8 | import { CaptureOptions } from './PageVideoCapture'; 9 | 10 | const debug = Debug('pw-video:VideoWriter'); 11 | 12 | export class VideoWriter extends EventEmitter { 13 | public static async create( 14 | savePath: string, 15 | options?: CaptureOptions, 16 | ): Promise { 17 | await ensureDir(dirname(savePath)); 18 | 19 | return new VideoWriter(savePath, options); 20 | } 21 | 22 | private _endedPromise: Promise; 23 | private _framesPerSecond = 25; 24 | private _receivedFrame = false; 25 | private _stopped = false; 26 | private _stream: PassThrough = new PassThrough(); 27 | 28 | protected constructor(savePath: string, options?: CaptureOptions) { 29 | super(); 30 | 31 | ensureFfmpegPath(); 32 | if (options && options.fps) { 33 | this._framesPerSecond = options.fps; 34 | } 35 | this._writeVideo(savePath); 36 | } 37 | 38 | private _writeVideo(savePath: string): void { 39 | debug(`write video to ${savePath}`); 40 | 41 | this._endedPromise = new Promise((resolve, reject) => { 42 | ffmpeg({ source: this._stream, priority: 20 }) 43 | .videoCodec('libx264') 44 | .inputFormat('image2pipe') 45 | .inputFPS(this._framesPerSecond) 46 | .outputOptions('-preset ultrafast') 47 | .outputOptions('-pix_fmt yuv420p') 48 | .on('error', (e) => { 49 | this.emit('ffmpegerror', e.message); 50 | 51 | // do not reject as a result of not having frames 52 | if ( 53 | !this._receivedFrame && 54 | e.message.includes('pipe:0: End of file') 55 | ) { 56 | resolve(); 57 | return; 58 | } 59 | 60 | reject(`pw-video: error capturing video: ${e.message}`); 61 | }) 62 | .on('end', () => { 63 | resolve(); 64 | }) 65 | .save(savePath); 66 | }); 67 | } 68 | 69 | public stop(): Promise { 70 | if (this._stopped) { 71 | return this._endedPromise; 72 | } 73 | 74 | this._stopped = true; 75 | this._stream.end(); 76 | 77 | return this._endedPromise; 78 | } 79 | 80 | public write(data: Buffer, durationSeconds = 1): void { 81 | this._receivedFrame = true; 82 | 83 | const numFrames = Math.max( 84 | Math.round(durationSeconds * this._framesPerSecond), 85 | 1, 86 | ); 87 | debug(`write ${numFrames} frames for duration ${durationSeconds}s`); 88 | 89 | for (let i = 0; i < numFrames; i++) { 90 | this._stream.write(data); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/example.js: -------------------------------------------------------------------------------- 1 | const { chromium } = require('playwright'); 2 | const { saveVideo } = require('playwright-video'); 3 | 4 | (async () => { 5 | const browser = await chromium.launch(); 6 | const context = await browser.newContext(); 7 | const page = await context.newPage(); 8 | 9 | const capture = await saveVideo(page, '/tmp/video.mp4'); 10 | await page.goto('http://example.org'); 11 | await page.click('a'); 12 | await capture.stop(); 13 | 14 | await browser.close(); 15 | })(); 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { PageVideoCapture } from './PageVideoCapture'; 2 | export { saveVideo } from './saveVideo'; 3 | export { getFfmpegPath } from './utils'; 4 | -------------------------------------------------------------------------------- /src/saveVideo.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'playwright-core'; 2 | import { CaptureOptions, PageVideoCapture } from './PageVideoCapture'; 3 | 4 | export const saveVideo = ( 5 | page: Page, 6 | savePath: string, 7 | options?: CaptureOptions, 8 | ): Promise => { 9 | return PageVideoCapture.start({ page, savePath, options }); 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { setFfmpegPath as setFluentFfmpegPath } from 'fluent-ffmpeg'; 2 | import { ChromiumBrowserContext, Page } from 'playwright-core'; 3 | 4 | export const getFfmpegFromModule = (): string | null => { 5 | try { 6 | const ffmpeg = require('@ffmpeg-installer/ffmpeg'); // eslint-disable-line @typescript-eslint/no-var-requires 7 | if (ffmpeg.path) { 8 | return ffmpeg.path; 9 | } 10 | } catch (e) {} // eslint-disable-line no-empty 11 | 12 | return null; 13 | }; 14 | 15 | export const getFfmpegPath = (): string | null => { 16 | if (process.env.FFMPEG_PATH) { 17 | return process.env.FFMPEG_PATH; 18 | } 19 | 20 | return getFfmpegFromModule(); 21 | }; 22 | 23 | export const ensureFfmpegPath = (): void => { 24 | const ffmpegPath = getFfmpegPath(); 25 | if (!ffmpegPath) { 26 | throw new Error( 27 | 'pw-video: FFmpeg path not set. Set the FFMPEG_PATH env variable or install @ffmpeg-installer/ffmpeg as a dependency.', 28 | ); 29 | } 30 | 31 | setFluentFfmpegPath(ffmpegPath); 32 | }; 33 | 34 | export const ensurePageType = (page: Page): void => { 35 | const context = page.context(); 36 | 37 | if (!(context as ChromiumBrowserContext).newCDPSession) { 38 | throw new Error('pw-video: page context must be chromium'); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /tests/PageVideoCapture.test.ts: -------------------------------------------------------------------------------- 1 | import { pathExists } from 'fs-extra'; 2 | import { tmpdir } from 'os'; 3 | import { join } from 'path'; 4 | import { chromium, ChromiumBrowser } from 'playwright'; 5 | import { PageVideoCapture } from '../src/PageVideoCapture'; 6 | 7 | const buildSavePath = (): string => join(tmpdir(), `${Date.now()}.mp4`); 8 | 9 | describe('PageVideoCapture', () => { 10 | let browser: ChromiumBrowser; 11 | 12 | beforeAll(async () => { 13 | browser = await chromium.launch(); 14 | }); 15 | 16 | afterAll(async () => { 17 | await browser.close(); 18 | }); 19 | 20 | it('captures a video of the page', async () => { 21 | const page = await browser.newPage(); 22 | const savePath = buildSavePath(); 23 | 24 | const capture = await PageVideoCapture.start({ page, savePath }); 25 | await page.setContent('hello world'); 26 | await capture.stop(); 27 | 28 | const videoPathExists = await pathExists(savePath); 29 | expect(videoPathExists).toBe(true); 30 | 31 | await page.close(); 32 | }); 33 | 34 | it('passes followPopups option to the collector', async () => { 35 | const page = await browser.newPage(); 36 | const savePath = buildSavePath(); 37 | const options = { followPopups: true }; 38 | 39 | const capture = await PageVideoCapture.start({ page, savePath, options }); 40 | 41 | expect(capture._collector._followPopups).toBe(true); 42 | 43 | await capture.stop(); 44 | await page.close(); 45 | }); 46 | 47 | it('stops on page close', async () => { 48 | const page = await browser.newPage(); 49 | 50 | const capture = await PageVideoCapture.start({ 51 | page, 52 | savePath: buildSavePath(), 53 | }); 54 | 55 | await page.close(); 56 | expect(capture._stopped).toBe(true); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /tests/ScreencastFrameCollector.test.ts: -------------------------------------------------------------------------------- 1 | import { chromium, ChromiumBrowser, firefox } from 'playwright'; 2 | import { ScreencastFrameCollector } from '../src/ScreencastFrameCollector'; 3 | 4 | describe('ScreencastFrameCollector', () => { 5 | let browser: ChromiumBrowser; 6 | 7 | beforeAll(async () => { 8 | browser = await chromium.launch(); 9 | }); 10 | 11 | afterAll(async () => { 12 | await browser.close(); 13 | }); 14 | 15 | it('emits screencast frames of a page', async () => { 16 | const page = await browser.newPage(); 17 | 18 | const collector = await ScreencastFrameCollector.create(page); 19 | await collector.start(); 20 | 21 | await new Promise((resolve) => { 22 | collector.on('screencastframe', (payload) => { 23 | expect(payload.received).toBeTruthy(); 24 | resolve(); 25 | }); 26 | }); 27 | 28 | await page.close(); 29 | }); 30 | 31 | it('creates a new CDP connection when a popup is opened if followPopups is set', async () => { 32 | const page = await browser.newPage(); 33 | await page.setContent('Google'); 34 | 35 | const collector = await ScreencastFrameCollector.create(page, { followPopups: true }); 36 | await collector.start(); 37 | 38 | expect(collector._clients.length).toBe(1); 39 | 40 | const popupPromise = new Promise((resolve) => { 41 | collector.once('popupFollowed', () => { 42 | resolve(); 43 | }); 44 | }); 45 | 46 | await page.click('a'); 47 | 48 | await popupPromise; 49 | 50 | expect(collector._clients.length).toBe(2); 51 | 52 | await page.close(); 53 | }); 54 | 55 | it('does not create a new CDP connection when a popup is opened if followPopups is unset', async () => { 56 | const page = await browser.newPage(); 57 | await page.setContent('Google'); 58 | 59 | const collector = await ScreencastFrameCollector.create(page); 60 | await collector.start(); 61 | 62 | expect(collector._clients.length).toBe(1); 63 | 64 | const framePromise = Promise.race([ 65 | new Promise((resolve) => { 66 | collector.once('screencastframe', () => { 67 | resolve(); 68 | }); 69 | }), 70 | new Promise((resolve) => setTimeout(resolve, 1000)), 71 | ]); 72 | 73 | await page.click('a'); 74 | 75 | await framePromise; 76 | 77 | expect(collector._clients.length).toBe(1); 78 | 79 | await page.close(); 80 | }); 81 | 82 | it('throws an error if page context not chromium', async () => { 83 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 84 | const firefoxBrowser = (await firefox.launch()) as any; 85 | const page = await firefoxBrowser.newPage(); 86 | 87 | const testFn = (): Promise => 88 | ScreencastFrameCollector.create(page); 89 | 90 | await expect(testFn()).rejects.toThrow( 91 | 'pw-video: page context must be chromium', 92 | ); 93 | 94 | await firefoxBrowser.close(); 95 | }, 10000); // increase timeout since Firefox slow to launch 96 | 97 | it('disposes the CDP session when stopped', async () => { 98 | const page = await browser.newPage(); 99 | 100 | const collector = await ScreencastFrameCollector.create(page); 101 | await collector.start(); 102 | 103 | const client = collector._clients[0]; 104 | 105 | const detachSpy = jest.spyOn(client, "detach"); 106 | expect(detachSpy).not.toHaveBeenCalled(); 107 | 108 | await collector.stop(); 109 | 110 | expect(detachSpy).toHaveBeenCalledTimes(1); 111 | 112 | await page.close(); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /tests/SortedFrameQueue.test.ts: -------------------------------------------------------------------------------- 1 | import { SortedFrameQueue } from '../src/SortedFrameQueue'; 2 | 3 | const buffer1 = Buffer.from('abc'); 4 | const buffer2 = Buffer.from('def'); 5 | const buffer3 = Buffer.from('ghi'); 6 | const buffer4 = Buffer.from('jkl'); 7 | const buffer5 = Buffer.from('mno'); 8 | 9 | const screencastFrame1 = { 10 | data: buffer1, 11 | received: 1583187587500, 12 | timestamp: 1583187587, 13 | }; 14 | 15 | const screencastFrame2 = { 16 | data: buffer2, 17 | received: 1583187587600, 18 | timestamp: 1583187588, 19 | }; 20 | 21 | const screencastFrame3 = { 22 | data: buffer3, 23 | received: 1583187587700, 24 | timestamp: 1583187589, 25 | }; 26 | 27 | const screencastFrame4 = { 28 | data: buffer4, 29 | received: 1583187587800, 30 | timestamp: 1583187590, 31 | }; 32 | 33 | const screencastFrame5 = { 34 | data: buffer5, 35 | received: 1583187587900, 36 | timestamp: 1583187591, 37 | }; 38 | 39 | describe('SortedFrameQueue', () => { 40 | it('inserts the first frame correctly', () => { 41 | const frameQueue = new SortedFrameQueue(); 42 | frameQueue.insert(screencastFrame1); 43 | 44 | expect(frameQueue._frames).toEqual([screencastFrame1]); 45 | }); 46 | 47 | it('inserts correctly if frames are sent in order', () => { 48 | const frameQueue = new SortedFrameQueue(); 49 | frameQueue.insert(screencastFrame1); 50 | frameQueue.insert(screencastFrame2); 51 | frameQueue.insert(screencastFrame3); 52 | 53 | expect(frameQueue._frames).toEqual([ 54 | screencastFrame1, 55 | screencastFrame2, 56 | screencastFrame3, 57 | ]); 58 | }); 59 | 60 | it('inserts correctly if frames are sent out of order', () => { 61 | const frameQueue = new SortedFrameQueue(); 62 | frameQueue.insert(screencastFrame1); 63 | frameQueue.insert(screencastFrame3); 64 | frameQueue.insert(screencastFrame2); 65 | 66 | expect(frameQueue._frames).toEqual([ 67 | screencastFrame1, 68 | screencastFrame2, 69 | screencastFrame3, 70 | ]); 71 | }); 72 | 73 | it('emits half of the frames when the maximum size is reached', async () => { 74 | const frameQueue = new SortedFrameQueue(4); 75 | frameQueue.insert(screencastFrame1); 76 | frameQueue.insert(screencastFrame2); 77 | frameQueue.insert(screencastFrame4); 78 | frameQueue.insert(screencastFrame3); 79 | 80 | expect(frameQueue._frames).toEqual([ 81 | screencastFrame1, 82 | screencastFrame2, 83 | screencastFrame3, 84 | screencastFrame4, 85 | ]); 86 | 87 | const framesPromise = new Promise((resolve) => { 88 | frameQueue.once('sortedframes', (payload) => { 89 | expect(payload).toEqual([screencastFrame1, screencastFrame2]); 90 | resolve(); 91 | }); 92 | }); 93 | 94 | frameQueue.insert(screencastFrame5); 95 | 96 | await framesPromise; 97 | 98 | expect(frameQueue._frames).toEqual([ 99 | screencastFrame3, 100 | screencastFrame4, 101 | screencastFrame5, 102 | ]); 103 | }); 104 | 105 | it('emits all remaining frames when drained', async () => { 106 | const frameQueue = new SortedFrameQueue(4); 107 | frameQueue.insert(screencastFrame1); 108 | frameQueue.insert(screencastFrame2); 109 | frameQueue.insert(screencastFrame3); 110 | 111 | const framesPromise = new Promise((resolve) => { 112 | frameQueue.once('sortedframes', (payload) => { 113 | expect(payload).toEqual([ 114 | screencastFrame1, 115 | screencastFrame2, 116 | screencastFrame3, 117 | screencastFrame4, 118 | ]); 119 | resolve(); 120 | }); 121 | }); 122 | 123 | frameQueue.insert(screencastFrame4); 124 | frameQueue.drain(); 125 | 126 | await framesPromise; 127 | 128 | expect(frameQueue._frames).toEqual([]); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /tests/VideoWriter.test.ts: -------------------------------------------------------------------------------- 1 | import { pathExists } from 'fs-extra'; 2 | import { tmpdir } from 'os'; 3 | import { join } from 'path'; 4 | import * as utils from '../src/utils'; 5 | import { VideoWriter } from '../src/VideoWriter'; 6 | 7 | describe('VideoWriter', () => { 8 | it('throws an error when ffmpeg path is not found', async () => { 9 | jest.spyOn(utils, 'getFfmpegFromModule').mockReturnValue(null); 10 | 11 | const testFn = (): Promise => 12 | VideoWriter.create('/tmp/video.mp4'); 13 | await expect(testFn()).rejects.toThrow('FFmpeg path not set'); 14 | 15 | jest.restoreAllMocks(); 16 | }); 17 | 18 | it('stop resolves after the video is saved', async () => { 19 | const savePath = join(tmpdir(), `${Date.now()}.mp4`); 20 | 21 | const writer = await VideoWriter.create(savePath); 22 | writer.write( 23 | // White 1×1 PNG http://proger.i-forge.net/%D0%9A%D0%BE%D0%BC%D0%BF%D1%8C%D1%8E%D1%82%D0%B5%D1%80/[20121112]%20The%20smallest%20transparent%20pixel.html 24 | Buffer.from( 25 | 'iVBORw0KGgoAAAANSUhEUgAAAAYAAACiCAYAAABiQbywAAAAKklEQVRYhe3JMQEAAAjDMMC/52EAARzp2XSS1NFcEwAAAAAAAAAAAD9gARW/BUBIVRRtAAAAAElFTkSuQmCC', 26 | 'base64', 27 | ), 28 | ); 29 | 30 | await writer.stop(); 31 | 32 | const videoPathExists = await pathExists(savePath); 33 | expect(videoPathExists).toBe(true); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/saveVideo.test.ts: -------------------------------------------------------------------------------- 1 | import { pathExists } from 'fs-extra'; 2 | import { tmpdir } from 'os'; 3 | import { join } from 'path'; 4 | import { chromium, ChromiumBrowser } from 'playwright'; 5 | import { saveVideo } from '../src/saveVideo'; 6 | 7 | jest.setTimeout(10 * 1000) 8 | 9 | describe('saveVideo', () => { 10 | let browser: ChromiumBrowser; 11 | 12 | beforeAll(async () => { 13 | browser = await chromium.launch(); 14 | }); 15 | 16 | afterAll(async () => { 17 | await browser.close(); 18 | }); 19 | 20 | it('captures a video of the page', async () => { 21 | const context = await browser.newContext(); 22 | const page = await context.newPage(); 23 | const savePath = join(tmpdir(), `${Date.now()}.mp4`); 24 | 25 | const capture = await saveVideo(page, savePath); 26 | 27 | for (let i = 0; i < 10; i++) { 28 | await page.setContent(`hello world ${i}`); 29 | await new Promise((r) => setTimeout(r, 100)); 30 | } 31 | 32 | await capture.stop(); 33 | 34 | const videoPathExists = await pathExists(savePath); 35 | expect(videoPathExists).toBe(true); 36 | 37 | await page.close(); 38 | }); 39 | 40 | it('passes followPopups option to PageVideoCapture', async () => { 41 | const context = await browser.newContext(); 42 | const page = await context.newPage(); 43 | const savePath = join(tmpdir(), `${Date.now()}.mp4`); 44 | 45 | const capture = await saveVideo(page, savePath, { followPopups: true }); 46 | 47 | expect(capture._collector._followPopups).toBe(true); 48 | 49 | await capture.stop(); 50 | await page.close(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '../src/utils'; 2 | 3 | const { getFfmpegPath } = utils; 4 | 5 | afterAll(() => jest.restoreAllMocks()); 6 | 7 | describe('getFfmpegPath', () => { 8 | it('returns FFMPEG_PATH environment variable if set', () => { 9 | process.env.FFMPEG_PATH = 'ffmpeg/path'; 10 | 11 | const path = getFfmpegPath(); 12 | expect(path).toBe('ffmpeg/path'); 13 | 14 | process.env.FFMPEG_PATH = ''; 15 | }); 16 | 17 | it('returns @ffmpeg-installer/ffmpeg path if installed', () => { 18 | jest 19 | .spyOn(utils, 'getFfmpegFromModule') 20 | .mockReturnValue('@ffmpeg-installer/ffmpeg/path'); 21 | 22 | const path = getFfmpegPath(); 23 | expect(path).toBe('@ffmpeg-installer/ffmpeg/path'); 24 | }); 25 | 26 | it('returns null when no path is found', () => { 27 | jest.spyOn(utils, 'getFfmpegFromModule').mockReturnValue(null); 28 | 29 | const path = getFfmpegPath(); 30 | expect(path).toBeNull(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "rootDir": "./src", 9 | "outDir": "./build", 10 | "esModuleInterop": true 11 | }, 12 | "include": ["src/**/*.ts"], 13 | "exclude": ["node_modules"] 14 | } 15 | --------------------------------------------------------------------------------