From 4cc4f950a9268c0f4e25a533d332b57290121b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Thu, 28 May 2026 13:30:36 +0200 Subject: [PATCH] feat(video): allow custom ffmpeg options and executable in recordVideo --- docs/src/api/params.md | 36 +++++ docs/src/test-api/class-testoptions.md | 4 + packages/playwright-client/types/types.d.ts | 140 ++++++++++++++++++ .../src/client/browserContext.ts | 10 +- .../playwright-core/src/protocol/validator.ts | 20 +++ .../server/dispatchers/browserDispatcher.ts | 10 +- .../src/server/videoRecorder.ts | 54 +++++-- packages/playwright-core/types/types.d.ts | 140 ++++++++++++++++++ packages/playwright/src/index.ts | 10 +- packages/playwright/types/test.d.ts | 2 +- packages/protocol/spec/mixins.yml | 4 + packages/protocol/src/channels.d.ts | 36 +++++ tests/library/video.spec.ts | 65 ++++++++ tests/playwright-test/playwright.spec.ts | 27 ++++ utils/generate_types/overrides-test.d.ts | 2 +- 15 files changed, 538 insertions(+), 22 deletions(-) diff --git a/docs/src/api/params.md b/docs/src/api/params.md index d64bfc79577cd..14b3c65c0f706 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -827,6 +827,10 @@ When set to `minimal`, only record information necessary for routing from HAR. T - `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"`. - `fontSize` ?<[int]> Font size of the action title in pixels. Defaults to `24`. + - `ffmpegExecutable` ?<[path]> Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + - `ffmpegOptions` ?<[string]> Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed when `fps` differs. + - `fps` ?<[int]> Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` yourself to match it. Defaults to `25`. + - `outputExtension` ?<[string]> Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by your ffmpeg binary to record in a different format. Enables video recording for all pages into `recordVideo.dir` directory. If not specified videos are not recorded. Make sure to await [`method: BrowserContext.close`] for videos to be saved. @@ -851,6 +855,34 @@ 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-ffmpegexecutable +* langs: csharp, java, python + - alias-python: record_video_ffmpeg_executable +- `recordVideoFfmpegExecutable` <[path]> + +Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + +## context-option-recordvideo-ffmpegoptions +* langs: csharp, java, python + - alias-python: record_video_ffmpeg_options +- `recordVideoFfmpegOptions` <[string]> + +Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg -i pipe:0`), for the `-vf` filter that resizes them to `recordVideoSize`, and for setting `-r ` before `-i pipe:0` to match `recordVideoFps` — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed when `recordVideoFps` differs. + +## context-option-recordvideo-fps +* langs: csharp, java, python + - alias-python: record_video_fps +- `recordVideoFps` <[int]> + +Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When `recordVideoFfmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `recordVideoFfmpegOptions`, set `-r ` yourself to match it. Defaults to `25`. + +## context-option-recordvideo-outputextension +* langs: csharp, java, python + - alias-python: record_video_output_extension +- `recordVideoOutputExtension` <[string]> + +Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by your ffmpeg binary to record in a different format. + ## context-option-proxy - `proxy` <[Object]> * alias: Proxy @@ -1075,6 +1107,10 @@ between the same pixel in compared images, between zero (strict) and one (lax), - %%-context-option-recordvideo-%% - %%-context-option-recordvideo-dir-%% - %%-context-option-recordvideo-size-%% +- %%-context-option-recordvideo-ffmpegexecutable-%% +- %%-context-option-recordvideo-ffmpegoptions-%% +- %%-context-option-recordvideo-fps-%% +- %%-context-option-recordvideo-outputextension-%% - %%-context-option-strict-%% - %%-context-option-service-worker-policy-%% diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index 62425a56c0ff7..11c35e1c91e34 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -648,6 +648,10 @@ export default defineConfig({ - `level` ?<[TestAnnotationLevel]<"file"|"test"|"step">> Level of the detail to include about the current test. - `position` ?<[AnnotatePosition]<"top-left"|"top"|"top-right"|"bottom-left"|"bottom"|"bottom-right">> Position of the test information overlay. Defaults to `"top-left"`. - `fontSize` ?<[int]> Font size of the test information in pixels. Defaults to `14`. + - `ffmpegExecutable` ?<[string]> Path to a system ffmpeg binary to use instead of the one bundled with Playwright. + - `ffmpegOptions` ?<[string]> Full ffmpeg command line replacing Playwright's default encoder pipeline. See [`option: BrowserType.launchPersistentContext.recordVideo`] for the semantics. + - `fps` ?<[int]> Frame rate. Defaults to `25`. + - `outputExtension` ?<[string]> Output container extension without leading `.`. Defaults to `webm`. Whether to record video for each test. Defaults to `'off'`. * `'off'`: Do not record video. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 185bb6dce242a..2fd54fe581f82 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -10499,6 +10499,34 @@ export interface Browser { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** @@ -15947,6 +15975,34 @@ export interface BrowserType { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** @@ -22518,6 +22574,34 @@ export interface Electron { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** @@ -23206,6 +23290,34 @@ export interface AndroidDevice { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** @@ -24386,6 +24498,34 @@ export interface BrowserContextOptions { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 31accbf0cb8cf..5c9201ccbabd8 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -544,8 +544,13 @@ async function prepareStorageState(platform: Platform, storageState: string | Se export async function prepareBrowserContextParams(platform: Platform, options: BrowserContextOptions): Promise { if (options.extraHTTPHeaders) network.validateHeaders(options.extraHTTPHeaders); + const { recordVideo, ...rest } = options; + const recordVideoForWire = recordVideo ? { + ...recordVideo, + dir: recordVideo.dir ? platform.path().resolve(recordVideo.dir) : recordVideo.dir, + } : undefined; const contextParams: channels.BrowserNewContextParams = { - ...options, + ...rest, viewport: options.viewport === null ? undefined : options.viewport, noDefaultViewport: options.viewport === null, extraHTTPHeaders: options.extraHTTPHeaders ? headersObjectToArray(options.extraHTTPHeaders) : undefined, @@ -557,9 +562,8 @@ export async function prepareBrowserContextParams(platform: Platform, options: B contrast: options.contrast === null ? 'no-override' : options.contrast, acceptDownloads: toAcceptDownloadsProtocol(options.acceptDownloads), clientCertificates: await toClientCertificatesProtocol(platform, options.clientCertificates), + recordVideo: recordVideoForWire, }; - if (contextParams.recordVideo && contextParams.recordVideo.dir) - contextParams.recordVideo.dir = platform.path().resolve(contextParams.recordVideo.dir); return contextParams; } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 656817e780068..5c0da87fc74a6 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -209,6 +209,10 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({ fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), })), + ffmpegExecutable: tOptional(tString), + ffmpegOptions: tOptional(tString), + fps: tOptional(tInt), + outputExtension: tOptional(tString), })), strictSelectors: tOptional(tBoolean), serviceWorkers: tOptional(tEnum(['allow', 'block'])), @@ -494,6 +498,10 @@ scheme.BrowserNewContextParams = tObject({ fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), })), + ffmpegExecutable: tOptional(tString), + ffmpegOptions: tOptional(tString), + fps: tOptional(tInt), + outputExtension: tOptional(tString), })), strictSelectors: tOptional(tBoolean), serviceWorkers: tOptional(tEnum(['allow', 'block'])), @@ -571,6 +579,10 @@ scheme.BrowserNewContextForReuseParams = tObject({ fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), })), + ffmpegExecutable: tOptional(tString), + ffmpegOptions: tOptional(tString), + fps: tOptional(tInt), + outputExtension: tOptional(tString), })), strictSelectors: tOptional(tBoolean), serviceWorkers: tOptional(tEnum(['allow', 'block'])), @@ -670,6 +682,10 @@ scheme.BrowserContextInitializer = tObject({ fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), })), + ffmpegExecutable: tOptional(tString), + ffmpegOptions: tOptional(tString), + fps: tOptional(tInt), + outputExtension: tOptional(tString), })), strictSelectors: tOptional(tBoolean), serviceWorkers: tOptional(tEnum(['allow', 'block'])), @@ -1074,6 +1090,10 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({ fontSize: tOptional(tInt), cursor: tOptional(tEnum(['none', 'pointer'])), })), + ffmpegExecutable: tOptional(tString), + ffmpegOptions: tOptional(tString), + fps: tOptional(tInt), + outputExtension: tOptional(tString), })), strictSelectors: tOptional(tBoolean), serviceWorkers: tOptional(tEnum(['allow', 'block'])), diff --git a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts index 6cc9b81cc1c57..59e6bb3c0f2a1 100644 --- a/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/browserDispatcher.ts @@ -60,8 +60,16 @@ export class BrowserDispatcher extends Dispatcher { - if (params.recordVideo && this._object.attribution.playwright.options.isServer) + if (params.recordVideo && this._object.attribution.playwright.options.isServer) { params.recordVideo.dir = undefined; + // Same rationale as `dir`: a remote client must not be able to inject + // arbitrary ffmpeg arguments — or pick an arbitrary executable — on the + // server (it would allow arbitrary file reads/writes and process + // invocation via filters / inputs / external binaries). + params.recordVideo.ffmpegExecutable = undefined; + params.recordVideo.ffmpegOptions = undefined; + params.recordVideo.outputExtension = undefined; + } if (!this._options.isolateContexts) { const context = await this._object.newContext(progress, params); diff --git a/packages/playwright-core/src/server/videoRecorder.ts b/packages/playwright-core/src/server/videoRecorder.ts index 4be4c100eb110..68203dd158041 100644 --- a/packages/playwright-core/src/server/videoRecorder.ts +++ b/packages/playwright-core/src/server/videoRecorder.ts @@ -31,7 +31,7 @@ import type { ChildProcess } from 'child_process'; import type { Screencast, ScreencastClient } from './screencast'; import type { Page } from './page'; -const fps = 25; +const defaultFps = 25; export class VideoRecorder { private _screencast: Screencast; @@ -43,11 +43,21 @@ export class VideoRecorder { this._screencast = screencast; } - start(options: { fileName?: string, size?: { width: number, height: number } }) { + start(options: { + fileName?: string, + size?: { width: number, height: number }, + ffmpegExecutable?: string, + ffmpegOptions?: string, + fps?: number, + outputExtension?: string, + }) { assert(!this._artifact); // Do this first, it likes to throw. - const ffmpegPath = registry.findExecutable('ffmpeg')!.executablePathOrDie(this._screencast.page.browserContext._browser.sdkLanguage()); - const outputFile = options.fileName ?? path.join(this._screencast.page.browserContext._browser.options.artifactsDir, createGuid() + '.webm'); + const ffmpegPath = options.ffmpegExecutable + ?? registry.findExecutable('ffmpeg')!.executablePathOrDie(this._screencast.page.browserContext._browser.sdkLanguage()); + const ext = options.outputExtension ?? 'webm'; + const outputFile = options.fileName ?? path.join(this._screencast.page.browserContext._browser.options.artifactsDir, createGuid() + '.' + ext); + const fps = options.fps ?? defaultFps; this._client = { onFrame: frame => this._videoRecorder!.writeFrame(frame.buffer, frame.frameSwapWallTime / 1000), @@ -59,7 +69,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._videoRecorder = new FfmpegVideoRecorder(ffmpegPath, videoSize, outputFile, fps, options.ffmpegOptions); this._artifact = new Artifact(this._screencast.page.browserContext, outputFile); return this._artifact; } @@ -91,7 +101,15 @@ 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 ext = recordVideo.outputExtension ?? 'webm'; + const artifact = recorder.start({ + size: recordVideo.size, + fileName: path.join(dir, page.guid + '.' + ext), + ffmpegExecutable: recordVideo.ffmpegExecutable, + ffmpegOptions: recordVideo.ffmpegOptions, + fps: recordVideo.fps, + outputExtension: recordVideo.outputExtension, + }); page.video = artifact; } @@ -106,15 +124,17 @@ class FfmpegVideoRecorder { private _frameQueue: Buffer[] = []; private _isStopped = false; private _ffmpegPath: string; + private _fps: number; + private _userFfmpegOptions: string | undefined; private _launchPromise: Promise; private _outputFile: string; - constructor(ffmpegPath: string, size: types.Size, outputFile: string) { - if (!outputFile.endsWith('.webm')) - throw new Error('File must have .webm extension'); + constructor(ffmpegPath: string, size: types.Size, outputFile: string, fps: number, userFfmpegOptions: string | undefined) { this._outputFile = outputFile; this._ffmpegPath = ffmpegPath; this._size = size; + this._fps = fps; + this._userFfmpegOptions = userFfmpegOptions; this._launchPromise = this._launch().catch(e => e); } @@ -133,7 +153,7 @@ class FfmpegVideoRecorder { // How to stress-test video recording (runs 10 recorders in parallel to book all cpus available): // $ node ./utils/video_stress.js // - // We use the following vp8 options: + // We use the following vp8 options when ``recordVideo.ffmpegOptions`` is not set: // "-qmin 0 -qmax 50" - quality variation from 0 to 50. // Suggested here: https://trac.ffmpeg.org/wiki/Encode/VP8 // "-crf 8" - constant quality mode, 4-63, lower means better quality. @@ -161,8 +181,16 @@ class FfmpegVideoRecorder { const w = this._size.width; const h = this._size.height; - const args = `-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -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 pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`.split(' '); - args.push(this._outputFile); + // When ``ffmpegOptions`` is provided the user owns the full ffmpeg command + // line — Playwright passes it through verbatim. In particular, the user is + // responsible for matching ``-r `` (before ``-i pipe:0``) to the + // ``fps`` field; otherwise ffmpeg defaults image2pipe to 25 fps and the + // output is time-distorted whenever ``fps`` differs. + let args: string[]; + if (this._userFfmpegOptions !== undefined) + args = [...this._userFfmpegOptions.split(/\s+/).filter(Boolean), this._outputFile]; + else + args = `-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -r ${this._fps} -i pipe:0 -y -an -r ${this._fps} -c:v vp8 -qmin 0 -qmax 50 -crf 8 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0 ${this._outputFile}`.split(' '); const { launchedProcess, gracefullyClose } = await launchProcess({ command: this._ffmpegPath, @@ -204,7 +232,7 @@ class FfmpegVideoRecorder { if (!this._firstFrameTimestamp) this._firstFrameTimestamp = timestamp; - const frameNumber = Math.floor((timestamp - this._firstFrameTimestamp) * fps); + const frameNumber = Math.floor((timestamp - this._firstFrameTimestamp) * this._fps); if (this._lastFrame) { const repeatCount = frameNumber - this._lastFrame.frameNumber; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 185bb6dce242a..2fd54fe581f82 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -10499,6 +10499,34 @@ export interface Browser { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** @@ -15947,6 +15975,34 @@ export interface BrowserType { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** @@ -22518,6 +22574,34 @@ export interface Electron { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** @@ -23206,6 +23290,34 @@ export interface AndroidDevice { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** @@ -24386,6 +24498,34 @@ export interface BrowserContextOptions { */ fontSize?: number; }; + + /** + * Path to a system ffmpeg binary to use instead of the one bundled with Playwright. The bundled binary ships with VP8 + * support only — provide a system ffmpeg to enable VP9, H264, AV1 and other encoders. + */ + ffmpegExecutable?: string; + + /** + * Full ffmpeg command line (everything between the executable and the output file). When set, replaces Playwright's + * default encoder pipeline entirely. You are responsible for reading raw frames from stdin (`-f image2pipe -c:v mjpeg + * -i pipe:0`), for the `-vf` filter that resizes them to `size`, and for setting `-r ` before `-i pipe:0` to + * match the `fps` field — without it ffmpeg defaults image2pipe to 25 fps and your video will play at the wrong speed + * when `fps` differs. + */ + ffmpegOptions?: string; + + /** + * Frame rate used by the internal frame-repetition algorithm that fills gaps between browser screencast events. When + * `ffmpegOptions` is not set, it is also passed to ffmpeg as `-r`. When you provide `ffmpegOptions`, set `-r ` + * yourself to match it. Defaults to `25`. + */ + fps?: number; + + /** + * Extension of the output file, without leading `.`. Defaults to `webm`. Use `mp4` or another container supported by + * your ffmpeg binary to record in a different format. + */ + outputExtension?: string; }; /** diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index b820f116907eb..572056abae3c7 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -408,12 +408,16 @@ const playwrightFixtures: Fixtures boolean; @@ -781,3 +792,57 @@ it('should saveAs video', async ({ browser }, testInfo) => { await page.video().saveAs(saveAsPath); expect(fs.existsSync(saveAsPath)).toBeTruthy(); }); + +it.describe('recordVideo ffmpegOptions', () => { + it.slow(); + it.skip(({ mode }) => mode !== 'default', 'video.path() is not available in remote mode'); + + it('should accept a custom fps', async ({ browser, browserName }, testInfo) => { + const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 }; + const context = await browser.newContext({ + recordVideo: { dir: testInfo.outputPath(''), size, fps: 10 }, + viewport: size, + }); + const page = await context.newPage(); + await ensureSomeFrames(page); + await context.close(); + const videoFile = await page.video().path(); + const videoPlayer = new VideoPlayer(videoFile); + expect(videoPlayer.videoWidth).toBe(size.width); + expect(videoPlayer.videoHeight).toBe(size.height); + }); + + it('should accept a raw ffmpegOptions string', async ({ browser, browserName }, testInfo) => { + const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 }; + const { width: w, height: h } = size; + const ffmpegOptions = `-loglevel error -f image2pipe -avioflags direct -fpsprobesize 0 -probesize 32 -analyzeduration 0 -c:v mjpeg -r 25 -i pipe:0 -y -an -c:v vp8 -qmin 0 -qmax 50 -crf 40 -deadline realtime -speed 8 -b:v 1M -threads 1 -vf pad=${w}:${h}:0:0:gray,crop=${w}:${h}:0:0`; + const context = await browser.newContext({ + recordVideo: { dir: testInfo.outputPath(''), size, ffmpegOptions }, + viewport: size, + }); + const page = await context.newPage(); + await ensureSomeFrames(page); + await context.close(); + const videoFile = await page.video().path(); + const videoPlayer = new VideoPlayer(videoFile); + expect(videoPlayer.videoWidth).toBe(size.width); + expect(videoPlayer.videoHeight).toBe(size.height); + }); + + it('should accept a custom ffmpegExecutable', async ({ browser, browserName }, testInfo) => { + const ffmpegExecutable = findSystemFfmpeg(); + it.skip(!ffmpegExecutable, 'system ffmpeg not available'); + const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 }; + const context = await browser.newContext({ + recordVideo: { dir: testInfo.outputPath(''), size, ffmpegExecutable }, + viewport: size, + }); + const page = await context.newPage(); + await ensureSomeFrames(page); + await context.close(); + const videoFile = await page.video().path(); + const videoPlayer = new VideoPlayer(videoFile); + expect(videoPlayer.videoWidth).toBe(size.width); + expect(videoPlayer.videoHeight).toBe(size.height); + }); +}); diff --git a/tests/playwright-test/playwright.spec.ts b/tests/playwright-test/playwright.spec.ts index 89339d93f4959..88c765af30122 100644 --- a/tests/playwright-test/playwright.spec.ts +++ b/tests/playwright-test/playwright.spec.ts @@ -552,6 +552,33 @@ 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', size: { width: 220, height: 110 }, fps: 10 } }, + name: 'chromium', + preserveOutput: 'always', + }; + `, + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('pass', async ({ page }) => { + await page.setContent('
PASS
'); + await page.waitForTimeout(2000); + test.expect(1 + 1).toBe(2); + }); + `, + }, { 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.videoWidth).toBe(220); + expect(videoPlayer.videoHeight).toBe(110); +}); + 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 09c36aa081bf8..b6b5a6af9b658 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -263,7 +263,7 @@ export interface PlaywrightWorkerOptions { connectOptions: ConnectOptions | undefined; screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean, screenshots?: 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 }, 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, show?: { actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number }, test?: { level?: 'file' | 'title' | 'step', position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number } }, ffmpegExecutable?: string, ffmpegOptions?: string, fps?: number, outputExtension?: string }; } export type ScreenshotMode = 'off' | 'on' | 'only-on-failure' | 'on-first-failure';