From eb5e43607df7853f5d873b563f7ee8eb9b04504d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 16 Sep 2026 17:47:16 -0700 Subject: [PATCH] feat(video): allow recording video at a custom fps - `fps` option for `page.screencast.start()`, `recordVideo`, test runner `video` and `video-start` - scale vp8 bitrate and encoder threads with the pixel rate - lock screencast frames to consecutive output slots to absorb Chromium timestamp jitter (https://issues.chromium.org/issues/562567707) --- docs/src/api/class-browser.md | 6 ++++ docs/src/api/class-browsertype.md | 3 ++ docs/src/api/class-screencast.md | 8 +++++ docs/src/api/params.md | 8 +++++ docs/src/test-api/class-testoptions.md | 3 ++ packages/playwright-client/types/types.d.ts | 31 +++++++++++++++++++ .../playwright-core/src/client/channels.d.ts | 13 ++++++++ .../playwright-core/src/client/screencast.ts | 3 +- .../src/server/browserContext.ts | 2 ++ .../playwright-core/src/server/channels.d.ts | 13 ++++++++ .../src/server/dispatchers/pageDispatcher.ts | 2 ++ .../src/server/videoRecorder.ts | 28 ++++++++++++----- .../src/tools/backend/context.ts | 2 +- .../src/tools/backend/video.ts | 3 +- .../src/tools/cli-daemon/commands.ts | 5 +-- packages/playwright-core/types/types.d.ts | 31 +++++++++++++++++++ packages/playwright/src/index.ts | 1 + packages/playwright/types/test.d.ts | 6 +++- packages/protocol/spec/electron.yml | 1 + packages/protocol/spec/mixins.yml | 1 + packages/protocol/spec/page.yml | 1 + packages/protocol/src/validator.ts | 7 +++++ tests/library/screencast.spec.ts | 16 ++++++++++ tests/library/video.spec.ts | 26 ++++++++++++++++ tests/library/videoPlayer.ts | 3 ++ tests/mcp/cli-devtools.spec.ts | 12 +++++++ tests/playwright-test/playwright.spec.ts | 27 ++++++++++++++++ utils/generate_types/overrides-test.d.ts | 2 +- utils/generate_types/overrides.d.ts | 1 + 29 files changed, 251 insertions(+), 14 deletions(-) diff --git a/docs/src/api/class-browser.md b/docs/src/api/class-browser.md index 2c07e98c57b2b..ae0f1ef3a44cc 100644 --- a/docs/src/api/class-browser.md +++ b/docs/src/api/class-browser.md @@ -264,6 +264,9 @@ await browser.CloseAsync(); ### option: Browser.newContext.clientCertificates = %%-context-option-clientCertificates-%% * since: 1.46 +### option: Browser.newContext.recordVideoFps = %%-context-option-recordvideo-fps-%% +* since: v1.64 + ### option: Browser.newContext.storageState = %%-js-python-context-option-storage-state-%% * since: v1.8 @@ -292,6 +295,9 @@ testing frameworks should explicitly create [`method: Browser.newContext`] follo ### option: Browser.newPage.clientCertificates = %%-context-option-clientCertificates-%% * since: 1.46 +### option: Browser.newPage.recordVideoFps = %%-context-option-recordvideo-fps-%% +* since: v1.64 + ### option: Browser.newPage.storageState = %%-js-python-context-option-storage-state-%% * since: v1.8 diff --git a/docs/src/api/class-browsertype.md b/docs/src/api/class-browsertype.md index 1dad175eeca14..b45e139f49bcb 100644 --- a/docs/src/api/class-browsertype.md +++ b/docs/src/api/class-browsertype.md @@ -375,6 +375,9 @@ Chromium/Chrome: Due to recent Chrome policy changes, automating the default Chr ### option: BrowserType.launchPersistentContext.clientCertificates = %%-context-option-clientCertificates-%% * since: 1.46 +### option: BrowserType.launchPersistentContext.recordVideoFps = %%-context-option-recordvideo-fps-%% +* since: v1.64 + ## async method: BrowserType.launchServer * since: v1.8 * langs: js diff --git a/docs/src/api/class-screencast.md b/docs/src/api/class-screencast.md index ba9817177514e..03efa44a39322 100644 --- a/docs/src/api/class-screencast.md +++ b/docs/src/api/class-screencast.md @@ -31,6 +31,14 @@ await page.screencast.start({ await page.screencast.stop(); ``` +### option: Screencast.start.fps +* since: v1.64 +- `fps` <[int]> + +Frame rate of the video recording in frames per second. Only used together with [`option: Screencast.start.path`]. Defaults to `25`. + +Higher frame rates make animations and scrolling smoother at the cost of more CPU spent on encoding. Combine with [`option: Screencast.start.size`] to record high resolution videos. The video can only contain as many distinct frames as the browser produces; Firefox and WebKit currently capture up to 25 frames per second. + ### option: Screencast.start.onFrame * since: v1.59 - `onFrame` <[function]\([Object]\): [Promise]> diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 8cb88d79eb570..3b284b5ad6277 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -858,6 +858,7 @@ When set to `minimal`, only record information necessary for routing from HAR. T Actual picture of each page will be scaled down if necessary to fit the specified size. - `width` <[int]> Video frame width. - `height` <[int]> Video frame height. + - `fps` ?<[int]> Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up to 25 frames per second. - `showActions` ?<[Object]> If specified, enables visual annotations on interacted elements during video recording. - `duration` ?<[float]> How long each annotation is displayed in milliseconds. Defaults to `500`. - `position` ?<[AnnotatePosition]<"top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right">> Position of the action title overlay. Defaults to `"top-right"`. @@ -887,6 +888,13 @@ Dimensions of the recorded videos. If not specified the size will be equal to `v scaled down to fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of each page will be scaled down if necessary to fit the specified size. +## context-option-recordvideo-fps +* langs: csharp, java, python + - alias-python: record_video_fps +- `recordVideoFps` <[int]> + +Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up to 25 frames per second. + ## context-option-proxy - `proxy` <[Object]> * alias: Proxy diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index 06cfe0e883f0c..5aa6c5cde0e9c 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -725,6 +725,7 @@ export default defineConfig({ - `size` ?<[Object]> Size of the recorded video. Optional. - `width` <[int]> - `height` <[int]> + - `fps` ?<[int]> Frame rate of the recorded video in frames per second. Defaults to `25`. - `show` ?<[Object]> If specified, visually annotates the video with test information and action highlights. - `actions` ?<[Object]> Controls visual annotations on interacted elements. - `duration` ?<[float]> How long each annotation is displayed in milliseconds. Defaults to `500`. @@ -749,6 +750,8 @@ See [video modes](../test-use-options.md#video-modes) for a side-by-side compari To control video size, pass an object with `mode` and `size` properties. If video size is not specified, it will be equal to [`property: TestOptions.viewport`] scaled down to fit into 800x800. If `viewport` is not configured explicitly the video size defaults to 800x450. Actual picture of each page will be scaled down if necessary to fit the specified size. +To record smoother video of animations and scrolling, pass `fps`, for example `{ mode: 'on', size: { width: 1920, height: 1080 }, fps: 60 }`. Higher frame rates and sizes use more CPU for encoding. Firefox and WebKit currently capture up to 25 frames per second. + To annotate actions in the video, pass `show` with `action` and/or `test` sub-options. The `action` option controls visual highlights on interacted elements with an optional `delay` in milliseconds (defaults to `500`). The `test` option controls which test information is displayed as a status overlay. **Usage** diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 16a448b39f2f9..9c1d214199f18 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -11591,6 +11591,12 @@ export interface Browser { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ @@ -17954,6 +17960,12 @@ export interface BrowserType { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ @@ -18693,6 +18705,7 @@ export interface Screencast { height: number; }; quality?: number; + fps?: number; }): Promise; /** * Removes action decorations. @@ -24133,6 +24146,12 @@ export interface Electron { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ @@ -24848,6 +24867,12 @@ export interface AndroidDevice { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ @@ -26067,6 +26092,12 @@ export interface BrowserContextOptions { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index f523e61c044cd..68da621e77eb4 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -421,6 +421,7 @@ export type AndroidDeviceLaunchBrowserParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -489,6 +490,7 @@ export type AndroidDeviceLaunchBrowserOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -872,6 +874,7 @@ export type BrowserNewContextParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -943,6 +946,7 @@ export type BrowserNewContextOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1017,6 +1021,7 @@ export type BrowserNewContextForReuseParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1088,6 +1093,7 @@ export type BrowserNewContextForReuseOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1201,6 +1207,7 @@ export type BrowserContextInitializer = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1853,6 +1860,7 @@ export type BrowserTypeLaunchPersistentContextParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1937,6 +1945,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -2039,6 +2048,7 @@ export type ElectronLaunchParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -2078,6 +2088,7 @@ export type ElectronLaunchOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -4498,6 +4509,7 @@ export type PageScreencastStartParams = { height: number, }, quality?: number, + fps?: number, sendFrames?: boolean, record?: boolean, }; @@ -4507,6 +4519,7 @@ export type PageScreencastStartOptions = { height: number, }, quality?: number, + fps?: number, sendFrames?: boolean, record?: boolean, }; diff --git a/packages/playwright-core/src/client/screencast.ts b/packages/playwright-core/src/client/screencast.ts index 6ae7a2ddfe9ed..96244d1f7f5ec 100644 --- a/packages/playwright-core/src/client/screencast.ts +++ b/packages/playwright-core/src/client/screencast.ts @@ -39,7 +39,7 @@ export class Screencast implements api.Screencast { }); } - async start(options: { onFrame?: (frame: { data: Buffer, timestamp: number, viewportWidth: number, viewportHeight: number }) => Promise|any, path?: string, size?: { width: number, height: number }, quality?: number } = {}): Promise { + async start(options: { onFrame?: (frame: { data: Buffer, timestamp: number, viewportWidth: number, viewportHeight: number }) => Promise|any, path?: string, size?: { width: number, height: number }, quality?: number, fps?: number } = {}): Promise { if (this._started) throw new Error('Screencast is already started'); this._started = true; @@ -48,6 +48,7 @@ export class Screencast implements api.Screencast { const result = await this._page._channel.screencastStart({ size: options.size, quality: options.quality, + fps: options.fps, sendFrames: !!options.onFrame, record: !!options.path, }, kNoTimeout); diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index f3e399f5b35c7..cd59af0ba7fe0 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -783,6 +783,8 @@ export function validateBrowserContextOptions(options: types.BrowserContextOptio options.viewport = { width: 1280, height: 720 }; if (options.proxy) options.proxy = normalizeProxySettings(options.proxy); + if (options.recordVideo?.fps !== undefined && options.recordVideo.fps <= 0) + throw new Error(`"recordVideo.fps" must be a positive number, got ${options.recordVideo.fps}`); verifyGeolocation(options.geolocation); } diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 907ed6fd9ff20..81d9c734987d2 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -422,6 +422,7 @@ export type AndroidDeviceLaunchBrowserParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -490,6 +491,7 @@ export type AndroidDeviceLaunchBrowserOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -873,6 +875,7 @@ export type BrowserNewContextParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -944,6 +947,7 @@ export type BrowserNewContextOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1018,6 +1022,7 @@ export type BrowserNewContextForReuseParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1089,6 +1094,7 @@ export type BrowserNewContextForReuseOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1202,6 +1208,7 @@ export type BrowserContextInitializer = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1854,6 +1861,7 @@ export type BrowserTypeLaunchPersistentContextParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -1938,6 +1946,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -2040,6 +2049,7 @@ export type ElectronLaunchParams = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -2079,6 +2089,7 @@ export type ElectronLaunchOptions = { width: number, height: number, }, + fps?: number, showActions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', @@ -4499,6 +4510,7 @@ export type PageScreencastStartParams = { height: number, }, quality?: number, + fps?: number, sendFrames?: boolean, record?: boolean, }; @@ -4508,6 +4520,7 @@ export type PageScreencastStartOptions = { height: number, }, quality?: number, + fps?: number, sendFrames?: boolean, record?: boolean, }; diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index 9066ef864f622..caa9359e43667 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -397,6 +397,8 @@ export class PageDispatcher extends Dispatcher { if (this._screencastClient || this._videoRecorder) throw new Error('Screencast is already running'); + if (params.fps !== undefined && params.fps <= 0) + throw new Error(`"fps" must be a positive number, got ${params.fps}`); if (params.sendFrames) { this._screencastClient = { diff --git a/packages/playwright-core/src/server/videoRecorder.ts b/packages/playwright-core/src/server/videoRecorder.ts index 63d8fc8f21d61..ffddeb56956cc 100644 --- a/packages/playwright-core/src/server/videoRecorder.ts +++ b/packages/playwright-core/src/server/videoRecorder.ts @@ -32,7 +32,7 @@ import type { ChildProcess } from 'child_process'; import type { Screencast, ScreencastClient } from './screencast'; import type { Page, PageDelegate } from './page'; -const fps = 25; +const kDefaultFps = 25; export class VideoRecorder { private _screencast: Screencast; @@ -44,7 +44,7 @@ export class VideoRecorder { this._screencast = screencast; } - start(options: { fileName?: string, size?: { width: number, height: number } }) { + start(options: { fileName?: string, size?: { width: number, height: number }, fps?: number }) { assert(!this._artifact); // Do this first, it likes to throw. const ffmpegPath = registry.findExecutable('ffmpeg')!.executablePathOrDie(this._screencast.page.browserContext._browser.sdkLanguage()); @@ -60,7 +60,7 @@ export class VideoRecorder { const { size } = this._screencast.addClient(this._client); // For video files only, prioritize encoding into the given size, regardless of the actual pixel data. const videoSize = options.size ?? size; - this._videoRecorder = new FfmpegVideoRecorder(ffmpegPath, videoSize, outputFile, this._screencast.page.delegate); + this._videoRecorder = new FfmpegVideoRecorder(ffmpegPath, videoSize, options.fps ?? kDefaultFps, outputFile, this._screencast.page.delegate); this._artifact = new Artifact(this._screencast.page.browserContext, outputFile); return this._artifact; } @@ -92,12 +92,13 @@ export function startAutomaticVideoRecording(page: Page) { if (page.browserContext._options.recordVideo?.showActions) page.screencast.showActions(page.browserContext._options.recordVideo?.showActions); const dir = recordVideo.dir ?? page.browserContext._browser.options.artifactsDir; - const artifact = recorder.start({ size: recordVideo.size, fileName: path.join(dir, page.guid + '.webm') }); + const artifact = recorder.start({ size: recordVideo.size, fps: recordVideo.fps, fileName: path.join(dir, page.guid + '.webm') }); page.video = artifact; } class FfmpegVideoRecorder { private _size: types.Size; + private _fps: number; private _process: ChildProcess | null = null; private _gracefullyClose: (() => Promise) | null = null; private _creationTimeMs: number; @@ -108,12 +109,13 @@ class FfmpegVideoRecorder { private _launchPromise: Promise; private _outputFile: string; - constructor(ffmpegPath: string, size: types.Size, outputFile: string, page: PageDelegate) { + constructor(ffmpegPath: string, size: types.Size, fps: number, outputFile: string, page: PageDelegate) { if (!outputFile.endsWith('.webm')) throw new Error('File must have .webm extension'); this._outputFile = outputFile; this._ffmpegPath = ffmpegPath; this._size = size; + this._fps = fps; this._creationTimeMs = Date.now(); this._launchPromise = this._launch(page).catch(e => e); } @@ -138,8 +140,10 @@ class FfmpegVideoRecorder { // Suggested here: https://trac.ffmpeg.org/wiki/Encode/VP8 // "-crf 8" - constant quality mode, 4-63, lower means better quality. // "-deadline realtime -speed 8" - do not use too much cpu to keep up with incoming frames. - // "-b:v 1M" - video bitrate. Default value is too low for vp8 + // "-b:v 1M" - video bitrate for the default 800x450 video at 25fps. Default value is too low for vp8 // Suggested here: https://trac.ffmpeg.org/wiki/Encode/VP8 + // Larger or faster videos get the bitrate scaled with the pixel rate: at 1920x1080 and 60fps + // the 1M budget visibly blurs scrolling text, while the scaled ~14M is visually lossless. // Note that we can switch to "-qmin 20 -qmax 50 -crf 30" for smaller video size but worse quality. // // We use "pad" and "crop" video filters (-vf option) to resize incoming frames @@ -161,11 +165,21 @@ class FfmpegVideoRecorder { // the input timestamps, so we don't have to repeat frames ourselves. // "-threads 1" means using one thread. This drastically reduces stalling when // cpu is overbooked. By default vp8 tries to use all available threads? + // A single thread can't keep up with larger or faster videos (1920x1080 at 60fps encodes + // below realtime), so we add a thread per 4x the default pixel rate. const w = this._size.width; const h = this._size.height; const videoFilterArgs = page.getFFmpegVideoFilterArgs?.({ width: w, height: h }) ?? `pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`; - const args = `-loglevel error -f matroska -fpsprobesize 0 -probesize 32 -analyzeduration 0 -i pipe:0 -y -an -r ${fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf ${videoFilterArgs}`.split(' '); + // Bitrate and encoder threads scale with the pixel rate relative to 800x450 at 25fps: + // 800x450 at 25fps -> scale 1, bitrate 1000k, 1 thread + // 800x800 at 25fps -> scale 1.78, bitrate 1778k, 1 thread + // 1920x1080 at 25fps -> scale 5.76, bitrate 5760k, 2 threads + // 1920x1080 at 60fps -> scale 13.8, bitrate 13824k, 4 threads + const pixelRateScale = Math.max(1, w * h * this._fps / (800 * 450 * kDefaultFps)); + const bitrate = Math.round(pixelRateScale * 1000); + const threads = Math.min(8, Math.ceil(pixelRateScale / 4)); + const args = `-loglevel error -f matroska -fpsprobesize 0 -probesize 32 -analyzeduration 0 -i pipe:0 -y -an -r ${this._fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v ${bitrate}k -threads ${threads} -vf ${videoFilterArgs}`.split(' '); args.push('-metadata', `creation_time=${new Date(this._creationTimeMs).toISOString()}`); args.push(this._outputFile); diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 43a76660f3360..fbf45c9c8b923 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -94,7 +94,7 @@ export type FilenameTemplate = { date?: Date; }; -type VideoParams = { size?: { width: number; height: number } }; +type VideoParams = { size?: { width: number; height: number }, fps?: number }; export class Context { readonly config: ContextConfig; diff --git a/packages/playwright-core/src/tools/backend/video.ts b/packages/playwright-core/src/tools/backend/video.ts index 02f5dc54abe8c..8d5b0dce441b5 100644 --- a/packages/playwright-core/src/tools/backend/video.ts +++ b/packages/playwright-core/src/tools/backend/video.ts @@ -30,13 +30,14 @@ const videoStart = defineTool({ width: z.number().describe('Video width'), height: z.number().describe('Video height'), }).optional().describe('Video size'), + fps: z.number().optional().describe('Video frame rate in frames per second, defaults to 25'), }), type: 'readOnly', }, handle: async (context, params, response) => { const resolvedFile = await response.resolveClientOutputFile({ prefix: 'video', ext: 'webm', suggestedFilename: params.filename }, 'Video'); - await context.startVideoRecording(resolvedFile.fileName, { size: params.size }); + await context.startVideoRecording(resolvedFile.fileName, { size: params.size, fps: params.fps }); response.addTextResult('Video recording started.'); }, }); diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 08f2c7d9e4518..724b2d15f16b4 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -1077,11 +1077,12 @@ const videoStart = declareCommand({ }), options: z.object({ size: z.string().optional().describe('Video frame size, e.g. "800x600". If not specified, the size of the recorded video will fit 800x800.'), + fps: numberArg.optional().describe('Video frame rate in frames per second, defaults to 25.'), }), toolName: 'browser_start_video', - toolParams: ({ filename, size }) => { + toolParams: ({ filename, size, fps }) => { const parsedSize = size ? size.split('x').map(Number) : undefined; - return { filename, size: parsedSize ? { width: parsedSize[0], height: parsedSize[1] } : undefined }; + return { filename, size: parsedSize ? { width: parsedSize[0], height: parsedSize[1] } : undefined, fps }; } }); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 16a448b39f2f9..9c1d214199f18 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -11591,6 +11591,12 @@ export interface Browser { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ @@ -17954,6 +17960,12 @@ export interface BrowserType { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ @@ -18693,6 +18705,7 @@ export interface Screencast { height: number; }; quality?: number; + fps?: number; }): Promise; /** * Removes action decorations. @@ -24133,6 +24146,12 @@ export interface Electron { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ @@ -24848,6 +24867,12 @@ export interface AndroidDevice { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ @@ -26067,6 +26092,12 @@ export interface BrowserContextOptions { height: number; }; + /** + * Frame rate of the recorded videos in frames per second. Defaults to `25`. Firefox and WebKit currently capture up + * to 25 frames per second. + */ + fps?: number; + /** * If specified, enables visual annotations on interacted elements during video recording. */ diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 9eb21ca724346..b1e14b7810c68 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -414,6 +414,7 @@ const playwrightFixtures: Fixtures { + const context = await browser.newContext(); + const page = await context.newPage(); + const videoPath = testInfo.outputPath('video.webm'); + await page.screencast.start({ path: videoPath, fps: 60 }); + await ensureSomeFrames(page); + await page.screencast.stop(); + expect(new VideoPlayer(videoPath).fps).toBe(60); + await context.close(); +}); + +test('start should throw on invalid fps', async ({ page }, testInfo) => { + const error = await page.screencast.start({ path: testInfo.outputPath('video.webm'), fps: -1 }).catch(e => e); + expect(error.message).toContain('"fps" must be a positive number, got -1'); +}); + test('start should fail when another recording is in progress', async ({ page, trace }, testInfo) => { test.skip(trace === 'on', 'trace=on has different screencast image configuration'); await page.screencast.start({ path: testInfo.outputPath('video.webm') }); diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts index 82ce623a12e86..a3577c02e6a9e 100644 --- a/tests/library/video.spec.ts +++ b/tests/library/video.spec.ts @@ -472,6 +472,32 @@ it.describe('screencast', () => { expect(videoPlayer.videoHeight).toBe(450); }); + it('should record video with the requested fps', async ({ browser }, testInfo) => { + const context = await browser.newContext({ + recordVideo: { + dir: testInfo.outputPath(''), + fps: 60, + }, + }); + + const page = await context.newPage(); + await ensureSomeFrames(page); + await context.close(); + + const videoPlayer = new VideoPlayer(await page.video().path()); + expect(videoPlayer.fps).toBe(60); + }); + + it('should throw on invalid fps', async ({ browser }, testInfo) => { + const error = await browser.newContext({ + recordVideo: { + dir: testInfo.outputPath(''), + fps: 0, + }, + }).catch(e => e); + expect(error.message).toContain('"recordVideo.fps" must be a positive number, got 0'); + }); + it('should be 800x600 with null viewport', async ({ browser, headless, browserName, isBidi }, testInfo) => { it.fixme(browserName === 'firefox' && headless && !isBidi, 'Fails in headless on bots'); diff --git a/tests/library/videoPlayer.ts b/tests/library/videoPlayer.ts index 42721571bb6dc..be94a7935d1fb 100644 --- a/tests/library/videoPlayer.ts +++ b/tests/library/videoPlayer.ts @@ -26,6 +26,7 @@ export class VideoPlayer { frames: number; videoWidth: number; videoHeight: number; + fps: number; cache = new Map(); constructor(fileName: string) { @@ -43,11 +44,13 @@ export class VideoPlayer { const framesMatch = framesLine.match(/frame=\s+(\d+)/); const streamLine = lines.find(l => l.trim().startsWith('Stream #0:0')); const resolutionMatch = streamLine.match(/, (\d+)x(\d+),/); + const fpsMatch = streamLine.match(/, ([\d.]+) fps,/); const durationMatch = lines.find(l => l.trim().startsWith('Duration'))!.match(/Duration: (\d+):(\d\d):(\d\d.\d\d)/); this.duration = (((parseInt(durationMatch![1], 10) * 60) + parseInt(durationMatch![2], 10)) * 60 + parseFloat(durationMatch![3])) * 1000; this.frames = parseInt(framesMatch![1], 10); this.videoWidth = parseInt(resolutionMatch![1], 10); this.videoHeight = parseInt(resolutionMatch![2], 10); + this.fps = parseFloat(fpsMatch![1]); } findFrame(framePredicate: (pixels: Buffer) => boolean, offset?: { x: number, y: number }): any |undefined { diff --git a/tests/mcp/cli-devtools.spec.ts b/tests/mcp/cli-devtools.spec.ts index 7fc023f5e35a2..6bc852c897063 100644 --- a/tests/mcp/cli-devtools.spec.ts +++ b/tests/mcp/cli-devtools.spec.ts @@ -16,6 +16,8 @@ import fs from 'fs'; import path from 'path'; +import { spawnSync } from 'child_process'; +import { registry } from '../../packages/playwright-core/lib/coreBundle'; import { test, expect } from './cli-fixtures'; test('console', async ({ cli, server }) => { @@ -247,6 +249,16 @@ test('video-start-stop', async ({ cli, server }) => { expect(videoStopOutput).toContain(`### Result\n- [Video](recordings${path.sep}video.webm)\n- [Video](recordings${path.sep}video-1.webm)`); }); +test('video-start with fps', async ({ cli, server }, testInfo) => { + await cli('open', server.HELLO_WORLD); + const { output } = await cli('video-start', 'video.webm', '--fps=60'); + expect(output).toContain('Video recording started.'); + await cli('video-stop'); + const ffmpeg = registry.registry.findExecutable('ffmpeg')!.executablePath(); + const { stderr } = spawnSync(ffmpeg, ['-i', testInfo.outputPath('video.webm')]); + expect(stderr.toString()).toContain(', 60 fps,'); +}); + test('video-chapter', async ({ cli, server }) => { await cli('open', server.HELLO_WORLD); await cli('video-start', 'video.webm'); diff --git a/tests/playwright-test/playwright.spec.ts b/tests/playwright-test/playwright.spec.ts index d3214690c221e..29508fa69ab09 100644 --- a/tests/playwright-test/playwright.spec.ts +++ b/tests/playwright-test/playwright.spec.ts @@ -25,6 +25,7 @@ const ffmpeg = registry.registry.findExecutable('ffmpeg')!.executablePath(); export class VideoPlayer { videoWidth: number; videoHeight: number; + fps: number; constructor(fileName: string) { const output = spawnSync(ffmpeg, ['-i', fileName, '-r', '25', `${fileName}-%03d.png`]).stderr.toString(); @@ -33,6 +34,7 @@ export class VideoPlayer { const resolutionMatch = streamLine!.match(/, (\d+)x(\d+),/); this.videoWidth = parseInt(resolutionMatch![1], 10); this.videoHeight = parseInt(resolutionMatch![2], 10); + this.fps = parseFloat(streamLine!.match(/, ([\d.]+) fps,/)![1]); } } @@ -644,6 +646,31 @@ test('should work with video size', async ({ runInlineTest }) => { expect(videoPlayer.videoHeight).toBe(110); }); +test('should work with video fps', async ({ runInlineTest }) => { + const result = await runInlineTest({ + 'playwright.config.js': ` + module.exports = { + use: { video: { mode: 'on', fps: 60 } }, + name: 'chromium', + preserveOutput: 'always', + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({ page }) => { + await page.setContent('
PASS
'); + await page.waitForTimeout(1000); + }); + `, + }, { workers: 1 }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + const folder = test.info().outputPath(`test-results/a-pass-chromium/`); + const [file] = fs.readdirSync(folder); + const videoPlayer = new VideoPlayer(path.join(folder, file)); + expect(videoPlayer.fps).toBe(60); +}); + test('should work with video.path() throwing', async ({ runInlineTest }, testInfo) => { // When running remotely, video.path() is not available, so we must not use it. const result = await runInlineTest({ diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 4a829c80ff689..c5081e02886ed 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -273,7 +273,7 @@ export interface PlaywrightWorkerOptions { reuseContext: boolean; screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screenshots?: boolean, coverage?: boolean, sources?: boolean, attachments?: boolean }; - video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, show?: { actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer' }, test?: { level?: 'file' | 'title' | 'step', position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number } } }; + video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, fps?: number, show?: { actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer' }, test?: { level?: 'file' | 'title' | 'step', position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number } } }; } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure'; diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index dd853e7c3f64a..0b186e03539ce 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -256,6 +256,7 @@ export interface Screencast { height: number; }; quality?: number; + fps?: number; }): Promise; }