From ab45a04a2ea470ec6f19427e0166cbd82d02f14f Mon Sep 17 00:00:00 2001
From: Pavel Feldman <pavel.feldman@gmail.com>
Date: Sat, 28 Mar 2026 14:58:31 -0700
Subject: [PATCH] chore: screencast clarity

---
 docs/src/api/class-screencast.md              |  19 +-
 examples/todomvc/playwright.config.ts         |  10 +-
 packages/playwright-client/types/types.d.ts   |   7 +-
 .../playwright-core/src/client/screencast.ts  |   2 +-
 .../playwright-core/src/protocol/validator.ts |   3 +-
 .../src/server/bidi/bidiPage.ts               |   4 +-
 .../playwright-core/src/server/browser.ts     |  15 -
 .../src/server/browserContext.ts              |  25 +-
 .../src/server/chromium/crBrowser.ts          |   5 -
 .../src/server/chromium/crPage.ts             |  21 +-
 .../src/server/dispatchers/pageDispatcher.ts  |  39 ++-
 .../src/server/electron/electron.ts           |   1 -
 .../src/server/firefox/ffBrowser.ts           |  12 -
 .../src/server/firefox/ffPage.ts              |  20 +-
 packages/playwright-core/src/server/page.ts   |   7 +-
 .../playwright-core/src/server/screencast.ts  | 268 +++++-------------
 .../src/server/trace/recorder/tracing.ts      |  91 ++++--
 .../src/server/videoRecorder.ts               |  91 +++++-
 .../src/server/webkit/wkBrowser.ts            |   1 -
 .../src/server/webkit/wkPage.ts               |  26 +-
 .../tools/dashboard/dashboardController.ts    |   4 +-
 packages/playwright-core/types/types.d.ts     |   7 +-
 packages/protocol/src/channels.d.ts           |   6 +-
 packages/protocol/src/protocol.yml            |   3 +-
 tests/library/screencast.spec.ts              |  12 +-
 tests/library/video.spec.ts                   |  36 +--
 utils/generate_types/overrides.d.ts           |   3 +-
 27 files changed, 345 insertions(+), 393 deletions(-)

diff --git a/docs/src/api/class-screencast.md b/docs/src/api/class-screencast.md
index 8ba066e6a87b5..a6eed91cdb3f5 100644
--- a/docs/src/api/class-screencast.md
+++ b/docs/src/api/class-screencast.md
@@ -16,7 +16,7 @@ Starts capturing screencast frames.
 ```js
 await page.screencast.start(({ data })  => {
   console.log(`frame size: ${data.length}`);
-}, { preferredSize: { width: 800, height: 600 } });
+}, { size: { width: 800, height: 600 } });
 // ... perform actions ...
 await page.screencast.stop();
 ```
@@ -29,18 +29,23 @@ await page.screencast.stop();
 
 Callback that receives JPEG-encoded frame data.
 
-### option: Screencast.start.preferredSize
+### option: Screencast.start.size
 * since: v1.59
 * langs: js
-- `preferredSize` ?<[Object]>
+- `size` ?<[Object]>
   - `width` <[int]> Max frame width in pixels.
   - `height` <[int]> Max frame height in pixels.
 
-Specifies the preferred maximum dimensions of screencast frames. The actual frame is scaled to preserve the page’s aspect ratio and may be smaller than these bounds.
-
+Specifies the dimensions of screencast frames. The actual frame is scaled to preserve the page’s aspect ratio and may be smaller than these bounds.
 If a screencast is already active (e.g. started by tracing or video recording), the existing configuration takes precedence and the frame size may exceed these bounds or this option may be ignored.
+If not specified the size will be equal to page viewport scaled down to fit into 800×800.
+
+### option: Screencast.start.quality
+* since: v1.59
+* langs: js
+- `quality` <[int]>
 
-Defaults to 800×800.
+The quality of the image, between 0-100.
 
 ## async method: Screencast.stop
 * since: v1.59
@@ -66,7 +71,7 @@ Path where the video should be saved when the recording is stopped.
   - `width` <[int]> Video frame width.
   - `height` <[int]> Video frame height.
 
-Optional dimensions of the recorded video. If not specified the size will be equal to page viewport scaled down to fit into 800x800. Actual picture of the page will be scaled down if necessary to fit the specified size.
+Optional dimensions of the recorded video. If not specified the size will be equal to page viewport scaled down to fit into 800×800. Actual picture of the page will be scaled down if necessary to fit the specified size.
 
 ### option: Screencast.startRecording.annotate
 * since: v1.59
diff --git a/examples/todomvc/playwright.config.ts b/examples/todomvc/playwright.config.ts
index cb9e3e0ebffb4..403a0e5ce6a4a 100644
--- a/examples/todomvc/playwright.config.ts
+++ b/examples/todomvc/playwright.config.ts
@@ -46,14 +46,8 @@ export default defineConfig({
     // baseURL: 'http://localhost:3000',
 
     /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
-    trace: 'on-first-retry',
-    video: {
-      mode: 'on',
-      annotate: {
-        action: { position: 'top-left'},
-        test: { position: 'top-right' },
-      }
-    },
+    trace: 'on',
+    video: 'on',
   },
 
   /* Configure projects for major browsers */
diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts
index 2f12814e21c51..987521a540352 100644
--- a/packages/playwright-client/types/types.d.ts
+++ b/packages/playwright-client/types/types.d.ts
@@ -16192,7 +16192,7 @@ export interface Screencast {
    * ```js
    * await page.screencast.start(({ data })  => {
    *   console.log(`frame size: ${data.length}`);
-   * }, { preferredSize: { width: 800, height: 600 } });
+   * }, { size: { width: 800, height: 600 } });
    * // ... perform actions ...
    * await page.screencast.stop();
    * ```
@@ -16201,10 +16201,11 @@ export interface Screencast {
    * @param options
    */
   start(onFrame: ((frame: { data: Buffer }) => Promise<any>|any), options?: {
-    preferredSize?: {
+    size?: {
       width: number;
       height: number;
     };
+    quality?: number;
     annotate?: {
       duration?: number;
       position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right';
@@ -16282,7 +16283,7 @@ export interface Screencast {
 
     /**
      * Optional dimensions of the recorded video. If not specified the size will be equal to page viewport scaled down to
-     * fit into 800x800. Actual picture of the page will be scaled down if necessary to fit the specified size.
+     * fit into 800×800. Actual picture of the page will be scaled down if necessary to fit the specified size.
      */
     size?: {
       /**
diff --git a/packages/playwright-core/src/client/screencast.ts b/packages/playwright-core/src/client/screencast.ts
index 18b49cc92b000..0888483e3ebba 100644
--- a/packages/playwright-core/src/client/screencast.ts
+++ b/packages/playwright-core/src/client/screencast.ts
@@ -34,7 +34,7 @@ export class Screencast implements api.Screencast {
     });
   }
 
-  async start(onFrame: (frame: { data: Buffer }) => Promise<any>|any, options: { preferredSize?: { width: number, height: number } } = {}): Promise<DisposableStub> {
+  async start(onFrame: (frame: { data: Buffer }) => Promise<any>|any, options: { size?: { width: number, height: number }, quality?: number } = {}): Promise<DisposableStub> {
     if (this._onFrame)
       throw new Error('Screencast is already started');
     this._onFrame = onFrame;
diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts
index 032aa64018674..39d345b576671 100644
--- a/packages/playwright-core/src/protocol/validator.ts
+++ b/packages/playwright-core/src/protocol/validator.ts
@@ -1570,10 +1570,11 @@ scheme.PageOverlaySetVisibleParams = tObject({
 });
 scheme.PageOverlaySetVisibleResult = tOptional(tObject({}));
 scheme.PageStartScreencastParams = tObject({
-  preferredSize: tOptional(tObject({
+  size: tOptional(tObject({
     width: tInt,
     height: tInt,
   })),
+  quality: tOptional(tInt),
 });
 scheme.PageStartScreencastResult = tOptional(tObject({}));
 scheme.PageStopScreencastParams = tOptional(tObject({}));
diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts
index 017259d6b0e4a..4eeea70e7f095 100644
--- a/packages/playwright-core/src/server/bidi/bidiPage.ts
+++ b/packages/playwright-core/src/server/bidi/bidiPage.ts
@@ -561,10 +561,10 @@ export class BidiPage implements PageDelegate {
     });
   }
 
-  async startScreencast(options: { width: number, height: number, quality: number }): Promise<void> {
+  startScreencast(options: { width: number, height: number, quality: number }) {
   }
 
-  async stopScreencast(): Promise<void> {
+  stopScreencast() {
   }
 
   rafCountForStablePosition(): number {
diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts
index 645eafc715a8e..830a21e4930e3 100644
--- a/packages/playwright-core/src/server/browser.ts
+++ b/packages/playwright-core/src/server/browser.ts
@@ -16,7 +16,6 @@
 
 import fs from 'fs';
 
-import { Artifact } from './artifact';
 import { BrowserContext, validateBrowserContextOptions } from './browserContext';
 import { Download } from './download';
 import { SdkObject } from './instrumentation';
@@ -75,7 +74,6 @@ export abstract class Browser extends SdkObject {
   private _downloads = new Map<string, Download>();
   _defaultContext: BrowserContext | null = null;
   private _startedClosing = false;
-  readonly _idToVideo = new Map<string, { context: BrowserContext, artifact: Artifact }>();
   private _contextForReuse: { context: BrowserContext, hash: string } | undefined;
   _closeReason: string | undefined;
   _isCollocatedWithServer: boolean = true;
@@ -160,19 +158,6 @@ export abstract class Browser extends SdkObject {
     this._downloads.delete(uuid);
   }
 
-  _videoStarted(page: Page, videoId: string, path: string) {
-    const artifact = new Artifact(page.browserContext, path);
-    page.video = artifact;
-    this._idToVideo.set(videoId, { context: page.browserContext, artifact });
-    return artifact;
-  }
-
-  _takeVideo(videoId: string): Artifact | undefined {
-    const video = this._idToVideo.get(videoId);
-    this._idToVideo.delete(videoId);
-    return video?.artifact;
-  }
-
   async startServer(title: string, options: channels.BrowserStartServerOptions): Promise<{ pipeName: string }> {
     return await this._server.start(title, options);
   }
diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts
index 3b2bdd76c0624..7878baf3b2477 100644
--- a/packages/playwright-core/src/server/browserContext.ts
+++ b/packages/playwright-core/src/server/browserContext.ts
@@ -532,12 +532,7 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
       await this.tracing.flush();
 
       // Cleanup.
-      const promises: Promise<void>[] = [];
-      for (const { context, artifact } of this._browser._idToVideo.values()) {
-        // Wait for the videos to finish.
-        if (context === this)
-          promises.push(artifact.finishedPromise());
-      }
+      const promises: Promise<void>[] = this.pages().map(page => page.screencast.handlePageOrContextClose());
 
       if (this._customCloseHandler) {
         await this._customCloseHandler();
@@ -738,29 +733,11 @@ export function validateBrowserContextOptions(options: types.BrowserContextOptio
     options.acceptDownloads = 'internal-browser-default';
   if (!options.viewport && !options.noDefaultViewport)
     options.viewport = { width: 1280, height: 720 };
-  if (options.recordVideo)
-    options.recordVideo.size = validateVideoSize(options.recordVideo.size, options.viewport);
   if (options.proxy)
     options.proxy = normalizeProxySettings(options.proxy);
   verifyGeolocation(options.geolocation);
 }
 
-export function validateVideoSize(size: types.Size | undefined, viewport: types.Size | undefined): types.Size {
-  if (!size) {
-    viewport ??= { width: 800, height: 600 };
-    const scale = Math.min(1, 800 / Math.max(viewport.width, viewport.height));
-    size = {
-      width: Math.floor(viewport.width * scale),
-      height: Math.floor(viewport.height * scale)
-    };
-  }
-  // Make sure both dimensions are odd, this is required for vp8
-  return {
-    width: size.width & ~1,
-    height: size.height & ~1
-  };
-}
-
 export function verifyGeolocation(geolocation?: types.Geolocation): asserts geolocation is types.Geolocation {
   if (!geolocation)
     return;
diff --git a/packages/playwright-core/src/server/chromium/crBrowser.ts b/packages/playwright-core/src/server/chromium/crBrowser.ts
index e5a1df65cee14..fc8f7806847fd 100644
--- a/packages/playwright-core/src/server/chromium/crBrowser.ts
+++ b/packages/playwright-core/src/server/chromium/crBrowser.ts
@@ -554,7 +554,6 @@ export class CRBrowserContext extends BrowserContext<CREventsMap> {
     await this.dialogManager.closeBeforeUnloadDialogs();
 
     if (!this._browserContextId) {
-      await this.stopVideoRecording();
       // Closing persistent context should close the browser.
       await this._browser.close({ reason });
       return;
@@ -574,10 +573,6 @@ export class CRBrowserContext extends BrowserContext<CREventsMap> {
     }
   }
 
-  async stopVideoRecording() {
-    await Promise.all(this._crPages().map(crPage => crPage._page.screencast.stopVideoRecording()));
-  }
-
   onClosePersistent() {
   }
 
diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts
index 9c94e27ce6b69..f93ac03f1ae99 100644
--- a/packages/playwright-core/src/server/chromium/crPage.ts
+++ b/packages/playwright-core/src/server/chromium/crPage.ts
@@ -36,6 +36,7 @@ import { exceptionToError, releaseObject, toConsoleMessageLocation } from './crP
 import { platformToFontFamilies } from './defaultFontFamilies';
 import { TargetClosedError } from '../errors';
 import { isSessionClosedError } from '../protocolError';
+import { startAutomaticVideoRecording } from '../videoRecorder';
 
 import type { CRSession } from './crConnection';
 import type { Protocol } from './protocol';
@@ -287,17 +288,17 @@ export class CRPage implements PageDelegate {
     return this._sessionForHandle(handle)._scrollRectIntoViewIfNeeded(handle, rect);
   }
 
-  async startScreencast(options: { width: number; height: number; quality: number; }): Promise<void> {
-    await this._mainFrameSession._client.send('Page.startScreencast', {
+  startScreencast(options: { width: number; height: number; quality: number; }) {
+    this._mainFrameSession._client.send('Page.startScreencast', {
       format: 'jpeg',
       quality: options.quality,
       maxWidth: options.width,
       maxHeight: options.height,
-    });
+    }).catch(() => {});
   }
 
-  async stopScreencast() {
-    await this._mainFrameSession._client._sendMayFail('Page.stopScreencast');
+  stopScreencast() {
+    this._mainFrameSession._client._sendMayFail('Page.stopScreencast').catch(() => {});
   }
 
   rafCountForStablePosition(): number {
@@ -444,9 +445,8 @@ class FrameSession {
       this._windowId = windowId;
     }
 
-    let videoOptions: types.VideoOptions | undefined;
     if (this._isMainFrame() && hasUIWindow && !this._page.isStorageStatePage)
-      videoOptions = this._crPage._page.screencast.launchAutomaticVideoRecorder();
+      startAutomaticVideoRecording(this._crPage._page);
 
     let lifecycleEventsEnabled: Promise<any>;
     if (!this._isMainFrame())
@@ -535,8 +535,6 @@ class FrameSession {
       promises.push(this._updateFileChooserInterception(true));
       for (const initScript of this._crPage._page.allInitScripts())
         promises.push(this._evaluateOnNewDocument(initScript, 'main', true /* runImmediately */));
-      if (videoOptions)
-        promises.push(this._crPage._page.screencast.startVideoRecording(videoOptions));
     }
     promises.push(this._client.send('Runtime.runIfWaitingForDebugger'));
     promises.push(this._firstNonInitialNavigationCommittedPromise);
@@ -883,15 +881,14 @@ class FrameSession {
   }
 
   _onScreencastFrame(payload: Protocol.Page.screencastFramePayload) {
-    this._page.screencast.throttleFrameAck(() => {
-      this._client._sendMayFail('Page.screencastFrameAck', { sessionId: payload.sessionId });
-    });
     const buffer = Buffer.from(payload.data, 'base64');
     this._page.screencast.onScreencastFrame({
       buffer,
       frameSwapWallTime: payload.metadata.timestamp ? payload.metadata.timestamp * 1000 : Date.now(),
       viewportWidth: payload.metadata.deviceWidth,
       viewportHeight: payload.metadata.deviceHeight,
+    }, () => {
+      this._client._sendMayFail('Page.screencastFrameAck', { sessionId: payload.sessionId });
     });
   }
 
diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts
index b319e774f92d6..2cbdadee6112b 100644
--- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts
+++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts
@@ -30,6 +30,7 @@ import { SdkObject } from '../instrumentation';
 import { deserializeURLMatch, urlMatches } from '../../utils/isomorphic/urlMatch';
 import { Recorder } from '../recorder';
 import { disposeAll } from '../disposable';
+import { VideoRecorder } from '../videoRecorder';
 
 import type { Artifact } from '../artifact';
 import type { BrowserContext } from '../browserContext';
@@ -46,7 +47,7 @@ import type * as channels from '@protocol/channels';
 import type { Progress } from '@protocol/progress';
 import type { URLMatch } from '../../utils/isomorphic/urlMatch';
 import type { ScreencastFrame } from '../types';
-import type { ScreencastListener } from '../screencast';
+import type { ScreencastClient } from '../screencast';
 
 export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, BrowserContextDispatcher> implements channels.PageChannel {
   _type_EventTarget = true;
@@ -61,7 +62,8 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
   private _locatorHandlers = new Set<number>();
   private _jsCoverageActive = false;
   private _cssCoverageActive = false;
-  private _screencastListener: ScreencastListener | null = null;
+  private _screencastClient: ScreencastClient | null = null;
+  private _videoRecorder: VideoRecorder | null = null;
 
   static from(parentScope: BrowserContextDispatcher, page: Page): PageDispatcher {
     return PageDispatcher.fromNullable(parentScope, page)!;
@@ -373,13 +375,18 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
   }
 
   async startScreencast(params: channels.PageStartScreencastParams, progress?: Progress): Promise<channels.PageStartScreencastResult> {
-    if (this._screencastListener)
+    if (this._screencastClient)
       throw new Error('Screencast is already running');
-    const size = params.preferredSize || { width: 800, height: 800 };
-    this._screencastListener = (frame: ScreencastFrame) => {
-      this._dispatchEvent('screencastFrame', { data: frame.buffer });
+
+    this._screencastClient = {
+      onFrame: (frame: ScreencastFrame) => {
+        this._dispatchEvent('screencastFrame', { data: frame.buffer });
+      },
+      dispose: () => {},
+      size: params.size,
+      quality: params.quality,
     };
-    await this._page.screencast.startScreencast(this._screencastListener, { quality: 90, width: size.width, height: size.height });
+    this._page.screencast.addClient(this._screencastClient);
   }
 
   async stopScreencast(params: channels.PageStopScreencastParams, progress?: Progress): Promise<channels.PageStopScreencastResult> {
@@ -387,19 +394,25 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
   }
 
   private async _stopScreencast() {
-    const listener = this._screencastListener;
-    this._screencastListener = null;
-    if (listener)
-      await this._page.screencast.stopScreencast(listener);
+    const client = this._screencastClient;
+    this._screencastClient = null;
+    if (client)
+      this._page.screencast.removeClient(client);
   }
 
   async videoStart(params: channels.PageVideoStartParams, progress: Progress): Promise<channels.PageVideoStartResult> {
-    const artifact = await this._page.screencast.startExplicitVideoRecording(params);
+    if (this._videoRecorder)
+      throw new Error('Video is already being recorded');
+    this._videoRecorder = new VideoRecorder(this._page.screencast);
+    const artifact = this._videoRecorder.start(params);
     return { artifact: createVideoDispatcher(this.parentScope(), artifact) };
   }
 
   async videoStop(params: channels.PageVideoStopParams, progress: Progress): Promise<channels.PageVideoStopResult> {
-    await this._page.screencast.stopExplicitVideoRecording();
+    if (!this._videoRecorder)
+      throw new Error('Video is not being recorded');
+    await this._videoRecorder.stop();
+    this._videoRecorder = null;
   }
 
   async startJSCoverage(params: channels.PageStartJSCoverageParams, progress: Progress): Promise<void> {
diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts
index 173ca898d07ea..6afbb1f6404e0 100644
--- a/packages/playwright-core/src/server/electron/electron.ts
+++ b/packages/playwright-core/src/server/electron/electron.ts
@@ -86,7 +86,6 @@ export class ElectronApplication extends SdkObject {
     this._nodeSession.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event));
     const appClosePromise = new Promise(f => this.once(ElectronApplication.Events.Close, f));
     this._browserContext.setCustomCloseHandler(async () => {
-      await this._browserContext.stopVideoRecording();
       const electronHandle = await this._nodeElectronHandlePromise;
       await electronHandle.evaluate(({ app }) => app.quit()).catch(() => {});
       this._nodeConnection.close();
diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts
index 4eea8265309fe..c171c0713c064 100644
--- a/packages/playwright-core/src/server/firefox/ffBrowser.ts
+++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts
@@ -213,16 +213,6 @@ export class FFBrowserContext extends BrowserContext {
     if (this._options.offline)
       promises.push(this.doUpdateOffline());
     promises.push(this.doUpdateDefaultEmulatedMedia());
-    if (this._options.recordVideo) {
-      promises.push(this._browser.session.send('Browser.setScreencastOptions', {
-        // validateBrowserContextOptions ensures correct video size.
-        options: {
-          ...this._options.recordVideo!.size!,
-          quality: 90,
-        },
-        browserContextId: this._browserContextId
-      }));
-    }
     const proxy = this._options.proxyOverride || this._options.proxy;
     if (proxy) {
       promises.push(this._browser.session.send('Browser.setContextProxy', {
@@ -419,8 +409,6 @@ export class FFBrowserContext extends BrowserContext {
 
   async doClose(reason: string | undefined) {
     if (!this._browserContextId) {
-      if (this._options.recordVideo)
-        await Promise.all(this._ffPages().map(ffPage => ffPage._page.screencast.stopVideoRecording()));
       // Closing persistent context should close the browser.
       await this._browser.close({ reason });
     } else {
diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts
index 05749a349c4e4..5eb75453f6a7d 100644
--- a/packages/playwright-core/src/server/firefox/ffPage.ts
+++ b/packages/playwright-core/src/server/firefox/ffPage.ts
@@ -26,6 +26,7 @@ import { RawKeyboardImpl, RawMouseImpl, RawTouchscreenImpl } from './ffInput';
 import { FFNetworkManager } from './ffNetworkManager';
 import { splitErrorMessage } from '../../utils/isomorphic/stackTrace';
 import { TargetClosedError } from '../errors';
+import { startAutomaticVideoRecording } from '../videoRecorder';
 
 import type { Progress } from '../progress';
 import type { FFBrowserContext } from './ffBrowser';
@@ -97,9 +98,8 @@ export class FFPage implements PageDelegate {
     ];
 
     const promises: Promise<any>[] = [];
-    const videoOptions = this._page.isStorageStatePage ? undefined : this._page.screencast.launchAutomaticVideoRecorder();
-    if (videoOptions)
-      promises.push(this._page.screencast.startVideoRecording(videoOptions));
+    if (!this._page.isStorageStatePage)
+      startAutomaticVideoRecording(this._page);
     promises.push(new Promise(f => this._session.once('Page.ready', f)));
     Promise.all(promises).then(() => this._reportAsNew(), error => this._reportAsNew(error));
 
@@ -476,25 +476,23 @@ export class FFPage implements PageDelegate {
     });
   }
 
-  async startScreencast(options: { width: number, height: number, quality: number }): Promise<void> {
-    await this._session.send('Page.startScreencast', { width: options.width, height: options.height, quality: options.quality });
+  startScreencast(options: { width: number, height: number, quality: number }) {
+    this._session.sendMayFail('Page.startScreencast', { width: options.width, height: options.height, quality: options.quality });
   }
 
-  async stopScreencast(): Promise<void> {
-    await this._session.sendMayFail('Page.stopScreencast');
+  stopScreencast() {
+    this._session.sendMayFail('Page.stopScreencast');
   }
 
   private _onScreencastFrame(event: Protocol.Page.screencastFramePayload) {
-    this._page.screencast.throttleFrameAck(() => {
-      this._session.sendMayFail('Page.screencastFrameAck');
-    });
-
     const buffer = Buffer.from(event.data, 'base64');
     this._page.screencast.onScreencastFrame({
       buffer,
       frameSwapWallTime: event.timestamp * 1000, // timestamp is in seconds, we need to convert to milliseconds.
       viewportWidth: event.deviceWidth,
       viewportHeight: event.deviceHeight,
+    }, () => {
+      this._session.sendMayFail('Page.screencastFrameAck');
     });
   }
 
diff --git a/packages/playwright-core/src/server/page.ts b/packages/playwright-core/src/server/page.ts
index 6de44b59d6a9f..e617f4d93819c 100644
--- a/packages/playwright-core/src/server/page.ts
+++ b/packages/playwright-core/src/server/page.ts
@@ -36,8 +36,8 @@ import { parseEvaluationResultValue } from '../utils/isomorphic/utilityScriptSer
 import { compressCallLog } from './callLog';
 import * as rawBindingsControllerSource from '../generated/bindingsControllerSource';
 import { Overlay } from './overlay';
-import { Screencast } from './screencast';
 import { NonRecoverableDOMError } from './dom';
+import { Screencast } from './screencast';
 
 import type { Artifact } from './artifact';
 import type { BrowserContextEventMap } from './browserContext';
@@ -85,8 +85,8 @@ export interface PageDelegate {
   getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
   getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle>;
   scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<'error:notvisible' | 'error:notconnected' | 'done'>;
-  startScreencast(options: { width: number, height: number, quality: number }): Promise<void>;
-  stopScreencast(): Promise<void>;
+  startScreencast(options: { width: number, height: number, quality: number }): void;
+  stopScreencast(): void;
 
   pdf?: (options: channels.PagePdfParams) => Promise<Buffer>;
   coverage?: () => any;
@@ -801,6 +801,7 @@ export class Page extends SdkObject<PageEventMap> {
       // while we are trying to close the page.
       await this.delegate.closePage(runBeforeUnload).catch(e => debugLogger.log('error', e));
     }
+    await this.screencast.handlePageOrContextClose();
     if (!runBeforeUnload)
       await this.closedPromise;
   }
diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts
index bed64c7ed45c6..13f06a1aa16fd 100644
--- a/packages/playwright-core/src/server/screencast.ts
+++ b/packages/playwright-core/src/server/screencast.ts
@@ -14,162 +14,114 @@
  * limitations under the License.
  */
 
-import path from 'path';
-import { assert, createGuid, renderTitleForCall } from '../utils';
+import { renderTitleForCall } from '../utils';
 import { debugLogger } from '../utils';
-import { VideoRecorder } from './videoRecorder';
 import { Page } from './page';
-import { registry } from './registry';
-import { validateVideoSize } from './browserContext';
 
 import type * as types from './types';
 import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
 
-export type ScreencastListener = (frame: types.ScreencastFrame) => void;
-export type ScreencastOptions = { width: number, height: number, quality: number, annotate?: types.AnnotateOptions };
+export type ScreencastClient = {
+  onFrame: (frame: types.ScreencastFrame) => Promise<void> | void;
+  gracefulClose?: () => Promise<void> | void;
+  dispose: () => void;
+  size?: types.Size;
+  quality?: number;
+  annotate?: types.AnnotateOptions;
+};
 
 export class Screencast implements InstrumentationListener {
-  private _page: Page;
-  private _videoRecorder: VideoRecorder | null = null;
-  private _videoId: string | null = null;
-  private _clients = new Map<ScreencastListener, ScreencastOptions>();
-  // Aiming at 25 fps by default - each frame is 40ms, but we give some slack with 35ms.
-  // When throttling for tracing, 200ms between frames, except for 10 frames around the action.
-  private _frameThrottler: FrameThrottler | undefined;
-  private _videoFrameListener: ScreencastListener | null = null;
+  readonly page: Page;
+  private _clients = new Set<ScreencastClient>();
   private _annotate: types.AnnotateOptions | undefined;
+  private _size: types.Size | undefined;
 
   constructor(page: Page) {
-    this._page = page;
-    this._page.instrumentation.addListener(this, page.browserContext);
+    this.page = page;
+    this.page.instrumentation.addListener(this, page.browserContext);
   }
 
-  dispose() {
-    this._frameThrottler?.dispose();
-    this._frameThrottler = undefined;
-    this._page.instrumentation.removeListener(this);
-  }
-
-  startForTracing(listener: ScreencastListener) {
-    this.startScreencast(listener, { width: 800, height: 800, quality: 90 }).catch(e => debugLogger.log('error', e));
-    this._frameThrottler = new FrameThrottler(10, 35, 200);
+  async handlePageOrContextClose() {
+    for (const clients of this._clients) {
+      if (clients.gracefulClose)
+        await clients.gracefulClose();
+    }
+    this._clients.clear();
   }
 
-  stopForTracing(listener: ScreencastListener) {
-    this.stopScreencast(listener).catch(e => debugLogger.log('error', e));
-    this.dispose();
+  dispose() {
+    for (const client of this._clients)
+      client.dispose();
+    this._clients.clear();
+    this.page.instrumentation.removeListener(this);
   }
 
-  throttleFrameAck(ack: () => void) {
-    if (!this._frameThrottler)
-      ack();
-    else
-      this._frameThrottler.ack(ack);
+  addClient(client: ScreencastClient): { size: types.Size } {
+    this._clients.add(client);
+    if (!this._annotate && client.annotate)
+      this._annotate = client.annotate;
+    if (this._clients.size === 1)
+      this._startScreencast(client.size, client.quality);
+    return { size: this._size! };
   }
 
-  temporarilyDisableThrottling() {
-    this._frameThrottler?.recharge();
+  removeClient(client: ScreencastClient) {
+    if (!this._clients.has(client))
+      return;
+    this._clients.delete(client);
+    if (!this._clients.size)
+      this._stopScreencast();
+    this._annotate = Array.from(this._clients).find(c => c.annotate)?.annotate;
   }
 
-  // Note: it is important to start video recorder before sending Screencast.startScreencast,
-  // and it is equally important to send Screencast.startScreencast before sending Target.resume.
-  launchAutomaticVideoRecorder(): types.VideoOptions | undefined {
-    const recordVideo = this._page.browserContext._options.recordVideo;
-    if (!recordVideo)
-      return;
-    // validateBrowserContextOptions ensures correct video size.
-    const dir = recordVideo.dir ?? this._page.browserContext._browser.options.artifactsDir;
-    const videoOptions = this._launchVideoRecorder(dir, recordVideo.size!, this._page.guid);
-    if (recordVideo.annotate)
-      videoOptions.annotate = recordVideo.annotate;
-    return videoOptions;
+  size(): types.Size | undefined {
+    return this._size;
   }
 
-  private _launchVideoRecorder(dir: string, size: { width: number, height: number }, videoId?: string): types.VideoOptions {
-    assert(!this._videoId);
-    // Do this first, it likes to throw.
-    const ffmpegPath = registry.findExecutable('ffmpeg')!.executablePathOrDie(this._page.browserContext._browser.sdkLanguage());
+  private _startScreencast(size: types.Size | undefined, quality: number | undefined) {
+    this._size = size;
+    if (!this._size) {
+      const viewport = this.page.browserContext._options.viewport || { width: 800, height: 600 };
+      const scale = Math.min(1, 800 / Math.max(viewport.width, viewport.height));
+      this._size = {
+        width: Math.floor(viewport.width * scale),
+        height: Math.floor(viewport.height * scale)
+      };
+    }
 
-    this._videoId = videoId || createGuid();
-    const outputFile = path.join(dir, this._videoId + '.webm');
-    const videoOptions = {
-      ...size,
-      outputFile,
+    // Make sure both dimensions are odd, this is required for vp8
+    this._size = {
+      width: this._size.width & ~1,
+      height: this._size.height & ~1
     };
 
-    this._videoRecorder = new VideoRecorder(ffmpegPath, videoOptions);
-    this._videoFrameListener = frame => this._videoRecorder!.writeFrame(frame.buffer, frame.frameSwapWallTime / 1000);
-    this._page.waitForInitializedOrError().then(p => {
-      if (p instanceof Error)
-        this.stopVideoRecording().catch(() => {});
+    this.page.delegate.startScreencast({
+      width: this._size.width,
+      height: this._size.height,
+      quality: quality ?? 90,
     });
-    return videoOptions;
   }
 
-  async startVideoRecording(options: types.VideoOptions) {
-    const videoId = this._videoId;
-    assert(videoId);
-    this._page.once(Page.Events.Close, () => this.stopVideoRecording().catch(() => {}));
-    await this.startScreencast(this._videoFrameListener!, {
-      quality: 90,
-      width: options.width,
-      height: options.height,
-      annotate: options.annotate,
-    });
-    return this._page.browserContext._browser._videoStarted(this._page, videoId, options.outputFile);
+  private _stopScreencast() {
+    this.page.delegate.stopScreencast();
   }
 
-  async stopVideoRecording(): Promise<void> {
-    if (!this._videoId)
-      return;
-    const videoFrameListener = this._videoFrameListener!;
-    this._videoFrameListener = null;
-    const videoId = this._videoId;
-    this._videoId = null;
-    const videoRecorder = this._videoRecorder!;
-    this._videoRecorder = null;
-    await this.stopScreencast(videoFrameListener);
-    await videoRecorder.stop();
-    // Keep the video artifact in the map until encoding is fully finished, if the context
-    // starts closing before the video is fully written to disk it will wait for it.
-    const video = this._page.browserContext._browser._takeVideo(videoId);
-    video?.reportFinished();
-  }
-
-  async startExplicitVideoRecording(options: { size?: types.Size, annotate?: types.AnnotateOptions } = {}) {
-    if (this._videoId)
-      throw new Error('Video is already being recorded');
-    const size = validateVideoSize(options.size, this._page.emulatedSize()?.viewport);
-    const videoOptions = this._launchVideoRecorder(this._page.browserContext._browser.options.artifactsDir, size);
-    if (options.annotate)
-      videoOptions.annotate = options.annotate;
-    return await this.startVideoRecording(videoOptions);
-  }
-
-  async stopExplicitVideoRecording() {
-    if (!this._videoId)
-      throw new Error('Video is not being recorded');
-    await this.stopVideoRecording();
-  }
-
-  async startScreencast(listener: ScreencastListener, options: ScreencastOptions) {
-    this._clients.set(listener, options);
-    if (!this._annotate && options.annotate)
-      this._annotate = options.annotate;
-    if (this._clients.size === 1)
-      await this._page.delegate.startScreencast(options);
-  }
-
-  async stopScreencast(listener: ScreencastListener) {
-    this._clients.delete(listener);
-    if (!this._clients.size)
-      await this._page.delegate.stopScreencast();
-    this._annotate = Array.from(this._clients.values()).find(options => options.annotate)?.annotate;
-  }
-
-  onScreencastFrame(frame: types.ScreencastFrame) {
-    for (const listener of this._clients.keys())
-      listener(frame);
+  onScreencastFrame(frame: types.ScreencastFrame, ack?: () => void) {
+    const asyncResults: Promise<void>[] = [];
+    for (const client of this._clients) {
+      const result = client.onFrame(frame);
+      if (result)
+        asyncResults.push(result);
+    }
+    if (ack) {
+      // Ack when any client resolves (OR logic). This ensures that even if
+      // tracing throttles its response, other clients (like video) that resolve
+      // immediately keep frames flowing.
+      if (!asyncResults.length)
+        ack();
+      else
+        Promise.race(asyncResults).then(ack);
+    }
   }
 
   async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata, parentId?: string): Promise<void> {
@@ -206,69 +158,3 @@ export class Screencast implements InstrumentationListener {
     }).catch(e => debugLogger.log('error', e));
   }
 }
-
-class FrameThrottler {
-  private _acks: (() => void)[] = [];
-  private _defaultInterval: number;
-  private _throttlingInterval: number;
-  private _nonThrottledFrames: number;
-  private _budget: number;
-  private _throttlingEnabled = false;
-  private _timeoutId: NodeJS.Timeout | undefined;
-
-  constructor(nonThrottledFrames: number, defaultInterval: number, throttlingInterval: number) {
-    this._nonThrottledFrames = nonThrottledFrames;
-    this._budget = nonThrottledFrames;
-    this._defaultInterval = defaultInterval;
-    this._throttlingInterval = throttlingInterval;
-    this._tick();
-  }
-
-  dispose() {
-    if (this._timeoutId) {
-      clearTimeout(this._timeoutId);
-      this._timeoutId = undefined;
-    }
-  }
-
-  setThrottlingEnabled(enabled: boolean) {
-    this._throttlingEnabled = enabled;
-  }
-
-  recharge() {
-    // Send all acks, reset budget.
-    for (const ack of this._acks)
-      ack();
-    this._acks = [];
-    this._budget = this._nonThrottledFrames;
-    if (this._timeoutId) {
-      clearTimeout(this._timeoutId);
-      this._tick();
-    }
-  }
-
-  ack(ack: () => void) {
-    if (!this._timeoutId) {
-      // Already disposed.
-      ack();
-      return;
-    }
-    this._acks.push(ack);
-  }
-
-  private _tick() {
-    const ack = this._acks.shift();
-    if (ack) {
-      --this._budget;
-      ack();
-    }
-
-    if (this._throttlingEnabled && this._budget <= 0) {
-      // Non-throttled frame budget is exceeded. Next ack will be throttled.
-      this._timeoutId = setTimeout(() => this._tick(), this._throttlingInterval);
-    } else {
-      // Either not throttling, or still under budget. Next ack will be after the default timeout.
-      this._timeoutId = setTimeout(() => this._tick(), this._defaultInterval);
-    }
-  }
-}
diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts
index aef9ebdf3e6f9..67c1d74157852 100644
--- a/packages/playwright-core/src/server/trace/recorder/tracing.ts
+++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts
@@ -22,6 +22,7 @@ import { Snapshotter } from './snapshotter';
 import { getMetainfo } from '../../../utils/isomorphic/protocolMetainfo';
 import { assert } from '../../../utils/isomorphic/assert';
 import { monotonicTime } from '../../../utils/isomorphic/time';
+import { ManualPromise } from '../../../utils/isomorphic/manualPromise';
 import { eventsHelper  } from '../../utils/eventsHelper';
 import { createGuid  } from '../../utils/crypto';
 import { getPlaywrightVersion } from '../../utils/userAgent';
@@ -50,7 +51,7 @@ import type { FrameSnapshot } from '@trace/snapshot';
 import type * as trace from '@trace/trace';
 import type { Progress } from '@protocol/progress';
 import type * as types from '../../types';
-import type { ScreencastListener } from '../../screencast';
+import type { Screencast, ScreencastClient } from '../../screencast';
 
 const version: trace.VERSION = 8;
 
@@ -81,7 +82,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
   private _snapshotter?: Snapshotter;
   private _harTracer: HarTracer;
   private _screencastListeners: RegisteredListener[] = [];
-  private _pageScreencastListeners = new Map<Page, ScreencastListener>();
+  private _pageTracingRecorders = new Map<Page, ScreencastTracingRecorder>();
   private _eventListeners: RegisteredListener[] = [];
   private _context: BrowserContext | APIRequestContext;
   // Note: state should only be touched inside API methods, but not inside trace operations.
@@ -279,15 +280,9 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
 
   private _stopScreencast() {
     eventsHelper.removeEventListeners(this._screencastListeners);
-    if (!(this._context instanceof BrowserContext))
-      return;
-    for (const page of this._context.pages()) {
-      const listener = this._pageScreencastListeners.get(page);
-      if (listener) {
-        page.screencast.stopForTracing(listener);
-        this._pageScreencastListeners.delete(page);
-      }
-    }
+    for (const recorder of this._pageTracingRecorders.values())
+      recorder.dispose();
+    this._pageTracingRecorders.clear();
   }
 
   private _allocateNewTraceFile(state: RecordingState) {
@@ -455,7 +450,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
     const event = createBeforeActionTraceEvent(metadata, parentId ?? this._currentGroupId());
     if (!event)
       return Promise.resolve();
-    sdkObject.attribution.page?.screencast.temporarilyDisableThrottling();
+    this._temporarilyDisableThrottling(sdkObject.attribution.page);
     if (this._shouldCaptureSnapshot(sdkObject, metadata, 'before'))
       event.beforeSnapshot = `before@${metadata.id}`;
     this._state?.callIds.add(metadata.id);
@@ -470,7 +465,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
     const event = createInputActionTraceEvent(metadata);
     if (!event)
       return Promise.resolve();
-    sdkObject.attribution.page?.screencast.temporarilyDisableThrottling();
+    this._temporarilyDisableThrottling(sdkObject.attribution.page);
     if (this._shouldCaptureSnapshot(sdkObject, metadata, 'input'))
       event.inputSnapshot = `input@${metadata.id}`;
     this._appendTraceEvent(event);
@@ -497,7 +492,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
     const event = createAfterActionTraceEvent(metadata);
     if (!event)
       return Promise.resolve();
-    sdkObject.attribution.page?.screencast.temporarilyDisableThrottling();
+    this._temporarilyDisableThrottling(sdkObject.attribution.page);
     if (this._shouldCaptureSnapshot(sdkObject, metadata, 'after'))
       event.afterSnapshot = `after@${metadata.id}`;
     this._appendTraceEvent(event);
@@ -608,9 +603,14 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
     this._appendTraceEvent(event);
   }
 
+  private _temporarilyDisableThrottling(page: Page | undefined) {
+    if (page)
+      this._pageTracingRecorders.get(page)?.temporarilyDisableThrottling();
+  }
+
   private _startScreencastInPage(page: Page) {
     const prefix = page.guid;
-    const listener = (params: types.ScreencastFrame) => {
+    const onFrame = (params: types.ScreencastFrame) => {
       const suffix = Date.now();
       const sha1 = `${prefix}-${suffix}.jpeg`;
       const event: trace.ScreencastFrameTraceEvent = {
@@ -626,8 +626,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps
       this._appendResource(sha1, params.buffer);
       this._appendTraceEvent(event);
     };
-    this._pageScreencastListeners.set(page, listener);
-    page.screencast.startForTracing(listener);
+    this._pageTracingRecorders.set(page, new ScreencastTracingRecorder(page.screencast, onFrame));
   }
 
   private _appendTraceEvent(event: trace.TraceEvent) {
@@ -722,3 +721,61 @@ function createAfterActionTraceEvent(metadata: CallMetadata): trace.AfterActionT
     point: metadata.point,
   };
 }
+
+// Aiming at 25 fps by default - each frame is 40ms, but we give some slack with 35ms.
+// When an action occurs, throttling is disabled for 200ms to capture the action.
+const throttledRate = 35;
+const unthrottleDuration = 200;
+
+class ScreencastTracingRecorder {
+  private _screencast: Screencast;
+  private _client: ScreencastClient;
+  private _unthrottledUntil = 0;
+  private _pendingAck: ManualPromise<void> | undefined;
+  private _timer: NodeJS.Timeout | undefined;
+
+  constructor(screencast: Screencast, onFrame: (frame: types.ScreencastFrame) => void) {
+    this._screencast = screencast;
+    this._client = {
+      onFrame: (frame: types.ScreencastFrame) => {
+        const time = monotonicTime();
+
+        if (time < this._unthrottledUntil) {
+          onFrame(frame);
+          return;
+        }
+
+        // We are throttling, but frames are coming => there is another client.
+        if (this._pendingAck)
+          return;
+
+        onFrame(frame);
+        this._pendingAck = new ManualPromise<void>();
+        this._timer = setTimeout(() => this._clearPendingAck(), throttledRate);
+        return this._pendingAck;
+      },
+      gracefulClose: () => this.dispose(),
+      dispose: () => this.dispose(),
+    };
+    this._screencast.addClient(this._client);
+  }
+
+  dispose() {
+    this._screencast.removeClient(this._client);
+    this._clearPendingAck();
+  }
+
+  temporarilyDisableThrottling() {
+    this._unthrottledUntil = monotonicTime() + unthrottleDuration;
+    this._clearPendingAck();
+  }
+
+  private _clearPendingAck() {
+    this._pendingAck?.resolve();
+    this._pendingAck = undefined;
+    if (this._timer) {
+      clearTimeout(this._timer);
+      this._timer = undefined;
+    }
+  }
+}
diff --git a/packages/playwright-core/src/server/videoRecorder.ts b/packages/playwright-core/src/server/videoRecorder.ts
index 77a150f54dc71..1ba950e01b98f 100644
--- a/packages/playwright-core/src/server/videoRecorder.ts
+++ b/packages/playwright-core/src/server/videoRecorder.ts
@@ -14,17 +14,84 @@
  * limitations under the License.
  */
 
-import { assert, debugLogger, mkdirIfNeeded, monotonicTime } from '../utils';
+import path from 'path';
+
+import { assert, createGuid, debugLogger, mkdirIfNeeded, monotonicTime } from '../utils';
 import { launchProcess } from './utils/processLauncher';
 import { jpegjs } from '../utilsBundle';
+import { Artifact } from './artifact';
+import { registry } from '.';
 
 import type * as types from './types';
 import type { ChildProcess } from 'child_process';
+import type { Screencast, ScreencastClient } from './screencast';
+import type { Page } from './page';
 
 const fps = 25;
 
 export class VideoRecorder {
-  private _options: types.VideoOptions;
+  private _screencast: Screencast;
+  private _videoRecorder: FfmpegVideoRecorder | undefined;
+  private _client: ScreencastClient | undefined;
+  private _artifact: Artifact | undefined;
+
+  constructor(screencast: Screencast) {
+    this._screencast = screencast;
+  }
+
+  start(options: { fileName?: string, size?: { width: number, height: number }, annotate?: types.AnnotateOptions }) {
+    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');
+
+    this._client = {
+      onFrame: frame => this._videoRecorder!.writeFrame(frame.buffer, frame.frameSwapWallTime / 1000),
+      gracefulClose: () => this.stop(),
+      dispose: () => this.stop().catch(e => debugLogger.log('error', `Failed to stop video recorder: ${String(e)}`)),
+      size: options.size,
+      annotate: options.annotate,
+    };
+
+    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._artifact = new Artifact(this._screencast.page.browserContext, outputFile);
+    return this._artifact;
+  }
+
+  async stop() {
+    if (!this._artifact)
+      return;
+
+    const artifact = this._artifact;
+    this._artifact = undefined;
+    const client = this._client!;
+    this._client = undefined;
+    const videoRecorder = this._videoRecorder!;
+    this._videoRecorder = undefined;
+
+    this._screencast.removeClient(client);
+    await videoRecorder.stop();
+    artifact.reportFinished();
+  }
+}
+
+// Note: it is important to start video recorder before sending Screencast.startScreencast,
+// and it is equally important to send Screencast.startScreencast before sending Target.resume.
+export function startAutomaticVideoRecording(page: Page) {
+  const recordVideo = page.browserContext._options.recordVideo;
+  if (!recordVideo)
+    return;
+  const recorder = new VideoRecorder(page.screencast);
+  const dir = recordVideo.dir ?? page.browserContext._browser.options.artifactsDir;
+  const artifact = recorder.start({ size: recordVideo.size, annotate: recordVideo.annotate, fileName: path.join(dir, page.guid + '.webm') });
+  page.video = artifact;
+}
+
+class FfmpegVideoRecorder {
+  private _size: types.Size;
   private _process: ChildProcess | null = null;
   private _gracefullyClose: (() => Promise<void>) | null = null;
   private _lastWritePromise: Promise<void> = Promise.resolve();
@@ -35,17 +102,19 @@ export class VideoRecorder {
   private _isStopped = false;
   private _ffmpegPath: string;
   private _launchPromise: Promise<Error | null>;
+  private _outputFile: string;
 
-  constructor(ffmpegPath: string, options: types.VideoOptions) {
-    this._ffmpegPath = ffmpegPath;
-    if (!options.outputFile.endsWith('.webm'))
+  constructor(ffmpegPath: string, size: types.Size, outputFile: string) {
+    if (!outputFile.endsWith('.webm'))
       throw new Error('File must have .webm extension');
-    this._options = options;
+    this._outputFile = outputFile;
+    this._ffmpegPath = ffmpegPath;
+    this._size = size;
     this._launchPromise = this._launch().catch(e => e);
   }
 
   private async _launch() {
-    await mkdirIfNeeded(this._options.outputFile);
+    await mkdirIfNeeded(this._outputFile);
     // How to tune the codec:
     // 1. Read vp8 documentation to figure out the options.
     //   https://www.webmproject.org/docs/encoder-parameters/
@@ -85,10 +154,10 @@ export class VideoRecorder {
     // "-threads 1" means using one thread. This drastically reduces stalling when
     //   cpu is overbooked. By default vp8 tries to use all available threads?
 
-    const w = this._options.width;
-    const h = this._options.height;
+    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._options.outputFile);
+    args.push(this._outputFile);
 
     const { launchedProcess, gracefullyClose } = await launchProcess({
       command: this._ffmpegPath,
@@ -164,7 +233,7 @@ export class VideoRecorder {
       return;
     if (!this._lastFrame) {
       // ffmpeg only creates a file upon some non-empty input
-      this._writeFrame(createWhiteImage(this._options.width, this._options.height), monotonicTime());
+      this._writeFrame(createWhiteImage(this._size.width, this._size.height), monotonicTime());
     }
     // Pad with at least 1s of the last frame in the end for convenience.
     // This also ensures non-empty videos with 1 frame.
diff --git a/packages/playwright-core/src/server/webkit/wkBrowser.ts b/packages/playwright-core/src/server/webkit/wkBrowser.ts
index b02830787e972..3589eb9c711be 100644
--- a/packages/playwright-core/src/server/webkit/wkBrowser.ts
+++ b/packages/playwright-core/src/server/webkit/wkBrowser.ts
@@ -365,7 +365,6 @@ export class WKBrowserContext extends BrowserContext {
 
   async doClose(reason: string | undefined) {
     if (!this._browserContextId) {
-      await Promise.all(this._wkPages().map(wkPage => wkPage._page.screencast.stopVideoRecording()));
       // Closing persistent context should close the browser.
       await this._browser.close({ reason });
     } else {
diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts
index 4a28274848ea4..a8048e5ed8047 100644
--- a/packages/playwright-core/src/server/webkit/wkPage.ts
+++ b/packages/playwright-core/src/server/webkit/wkPage.ts
@@ -36,6 +36,7 @@ import { WKProvisionalPage } from './wkProvisionalPage';
 import { WKWorkers } from './wkWorkers';
 import { translatePathToWSL } from './webkit';
 import { registry } from '../registry';
+import { startAutomaticVideoRecording } from '../videoRecorder';
 
 import type { Protocol } from './protocol';
 import type { WKBrowserContext } from './wkBrowser';
@@ -128,7 +129,7 @@ export class WKPage implements PageDelegate {
       for (const [key, value] of this._browserContext._permissions)
         promises.push(this._grantPermissions(key, value));
     }
-    promises.push(this._initializeVideoRecording());
+    startAutomaticVideoRecording(this._page);
     await Promise.all(promises);
   }
 
@@ -849,13 +850,6 @@ export class WKPage implements PageDelegate {
     return 0;
   }
 
-  private async _initializeVideoRecording() {
-    const screencast = this._page.screencast;
-    const videoOptions = this._page.isStorageStatePage ? undefined : screencast.launchAutomaticVideoRecorder();
-    if (videoOptions)
-      await screencast.startVideoRecording(videoOptions);
-  }
-
   private validateScreenshotDimension(side: number, omitDeviceScaleFactor: boolean) {
     // Cairo based implementations (Linux and Windows) have hard limit of 32767
     // (see https://github.com/microsoft/playwright/issues/16727).
@@ -930,25 +924,21 @@ export class WKPage implements PageDelegate {
     });
   }
 
-  async startScreencast(options: { width: number, height: number, quality: number }): Promise<void> {
-    const { generation } = await this._pageProxySession.send('Screencast.startScreencast', {
+  startScreencast(options: { width: number, height: number, quality: number }) {
+    this._pageProxySession.send('Screencast.startScreencast', {
       quality: options.quality,
       width: options.width,
       height: options.height,
       toolbarHeight: this._toolbarHeight(),
-    });
-    this._screencastGeneration = generation;
+    }).then(({ generation }) => this._screencastGeneration = generation).catch(() => {});
   }
 
-  async stopScreencast(): Promise<void> {
-    await this._pageProxySession.sendMayFail('Screencast.stopScreencast');
+  stopScreencast() {
+    this._pageProxySession.sendMayFail('Screencast.stopScreencast');
   }
 
   private _onScreencastFrame(event: Protocol.Screencast.screencastFramePayload) {
     const generation = this._screencastGeneration;
-    this._page.screencast.throttleFrameAck(() => {
-      this._pageProxySession.sendMayFail('Screencast.screencastFrameAck', { generation });
-    });
     const buffer = Buffer.from(event.data, 'base64');
     this._page.screencast.onScreencastFrame({
       buffer,
@@ -961,6 +951,8 @@ export class WKPage implements PageDelegate {
         : Date.now(),
       viewportWidth: event.deviceWidth,
       viewportHeight: event.deviceHeight,
+    }, () => {
+      this._pageProxySession.sendMayFail('Screencast.screencastFrameAck', { generation });
     });
   }
 
diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts
index 99ab317fc822e..1c854fc67eb04 100644
--- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts
+++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts
@@ -201,10 +201,10 @@ export class DashboardConnection implements Transport, DashboardChannel {
         }),
     );
 
-    const preferredSize = { width: 1280, height: 800 };
+    const size = { width: 1280, height: 800 };
     await page.screencast.start(
         ({ data }) => this._writeFrame(data, page.viewportSize()?.width ?? 0, page.viewportSize()?.height ?? 0),
-        { preferredSize },
+        { size },
     );
   }
 
diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts
index 2f12814e21c51..987521a540352 100644
--- a/packages/playwright-core/types/types.d.ts
+++ b/packages/playwright-core/types/types.d.ts
@@ -16192,7 +16192,7 @@ export interface Screencast {
    * ```js
    * await page.screencast.start(({ data })  => {
    *   console.log(`frame size: ${data.length}`);
-   * }, { preferredSize: { width: 800, height: 600 } });
+   * }, { size: { width: 800, height: 600 } });
    * // ... perform actions ...
    * await page.screencast.stop();
    * ```
@@ -16201,10 +16201,11 @@ export interface Screencast {
    * @param options
    */
   start(onFrame: ((frame: { data: Buffer }) => Promise<any>|any), options?: {
-    preferredSize?: {
+    size?: {
       width: number;
       height: number;
     };
+    quality?: number;
     annotate?: {
       duration?: number;
       position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right';
@@ -16282,7 +16283,7 @@ export interface Screencast {
 
     /**
      * Optional dimensions of the recorded video. If not specified the size will be equal to page viewport scaled down to
-     * fit into 800x800. Actual picture of the page will be scaled down if necessary to fit the specified size.
+     * fit into 800×800. Actual picture of the page will be scaled down if necessary to fit the specified size.
      */
     size?: {
       /**
diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts
index 4bae179f318bf..c99e774029371 100644
--- a/packages/protocol/src/channels.d.ts
+++ b/packages/protocol/src/channels.d.ts
@@ -2718,16 +2718,18 @@ export type PageOverlaySetVisibleOptions = {
 };
 export type PageOverlaySetVisibleResult = void;
 export type PageStartScreencastParams = {
-  preferredSize?: {
+  size?: {
     width: number,
     height: number,
   },
+  quality?: number,
 };
 export type PageStartScreencastOptions = {
-  preferredSize?: {
+  size?: {
     width: number,
     height: number,
   },
+  quality?: number,
 };
 export type PageStartScreencastResult = void;
 export type PageStopScreencastParams = {};
diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml
index 642b0d0e8e9c2..57149667d4d62 100644
--- a/packages/protocol/src/protocol.yml
+++ b/packages/protocol/src/protocol.yml
@@ -2141,11 +2141,12 @@ Page:
       title: Start screencast
       group: configuration
       parameters:
-        preferredSize:
+        size:
           type: object?
           properties:
             width: int
             height: int
+        quality: int?
 
     stopScreencast:
       title: Stop screencast
diff --git a/tests/library/screencast.spec.ts b/tests/library/screencast.spec.ts
index ff207a620b56e..7fd959e3ccf0b 100644
--- a/tests/library/screencast.spec.ts
+++ b/tests/library/screencast.spec.ts
@@ -26,8 +26,8 @@ test('screencast.start delivers frames via onFrame callback', async ({ browser,
   const page = await context.newPage();
 
   const frames: Buffer[] = [];
-  const preferredSize = { width: 500, height: 400 };
-  await page.screencast.start(({ data }) => frames.push(data), { preferredSize });
+  const size = { width: 500, height: 400 };
+  await page.screencast.start(({ data }) => frames.push(data), { size });
   await page.goto(server.EMPTY_PAGE);
   await page.evaluate(() => document.body.style.backgroundColor = 'red');
   await rafraf(page, 100);
@@ -64,10 +64,10 @@ test('start allows restart with different options after stop', async ({ browser,
   const context = await browser.newContext({ viewport: { width: 500, height: 400 } });
   const page = await context.newPage();
 
-  await page.screencast.start(() => {}, { preferredSize: { width: 500, height: 400 } });
+  await page.screencast.start(() => {}, { size: { width: 500, height: 400 } });
   await page.screencast.stop();
   // Different options should succeed once the previous screencast is stopped.
-  await page.screencast.start(() => {}, { preferredSize: { width: 320, height: 240 } });
+  await page.screencast.start(() => {}, { size: { width: 320, height: 240 } });
   await page.screencast.stop();
   await context.close();
 });
@@ -82,7 +82,7 @@ test('start reuses existing screencast when video recording is running', async (
   await page.screencast.startRecording(testInfo.outputPath('video.webm'), { size: videoSize });
 
   const frames: Buffer[] = [];
-  await page.screencast.start(({ data }) => frames.push(data), { preferredSize: { width: 320, height: 240 } });
+  await page.screencast.start(({ data }) => frames.push(data), { size: { width: 320, height: 240 } });
   await page.goto(server.EMPTY_PAGE);
   await rafraf(page, 100);
   await page.screencast.stop();
@@ -108,7 +108,7 @@ test('start returns a disposable that stops screencast', async ({ browser, serve
   const page = await context.newPage();
 
   const frames: Buffer[] = [];
-  await page.screencast.start(({ data }) => frames.push(data), { preferredSize: { width: 500, height: 400 } });
+  await page.screencast.start(({ data }) => frames.push(data), { size: { width: 500, height: 400 } });
   await page.goto(server.EMPTY_PAGE);
   await page.evaluate(() => document.body.style.backgroundColor = 'red');
   await rafraf(page, 100);
diff --git a/tests/library/video.spec.ts b/tests/library/video.spec.ts
index 9c854afdc4470..770c0e96b60d9 100644
--- a/tests/library/video.spec.ts
+++ b/tests/library/video.spec.ts
@@ -169,25 +169,6 @@ it.describe('screencast', () => {
     expect(error.message).toContain('"videoSize" option requires "videosPath" to be specified');
   });
 
-  it('should work with old options', async ({ browser, browserName, trace, headless, isWindows }, testInfo) => {
-    const videosPath = testInfo.outputPath('');
-    // Firefox does not have a mobile variant and has a large minimum size (500 on windows and 450 elsewhere).
-    const size = browserName === 'firefox' ? { width: 500, height: 400 } : { width: 320, height: 240 };
-    const context = await browser.newContext({
-      videosPath,
-      viewport: size,
-      videoSize: size
-    });
-    const page = await context.newPage();
-
-    await page.evaluate(() => document.body.style.backgroundColor = 'red');
-    await rafraf(page, 100);
-    await context.close();
-
-    const videoFile = await page.video().path();
-    expectRedFrames(videoFile, size);
-  });
-
   it('should not throw without recordVideo.dir', async ({ browser }) => {
     await browser.newContext({ recordVideo: {} });
   });
@@ -886,17 +867,26 @@ it.describe('screencast', () => {
     await browser.close();
   });
 
-  it('video.start should fail when recordVideo is set, but stop should work', async ({ browser }, testInfo) => {
+  it('video.start should work when recordVideo is set', async ({ browser }, testInfo) => {
+    const autoDir = testInfo.outputPath('auto');
+    const manualDir = testInfo.outputPath('manual');
     const context = await browser.newContext({
       recordVideo: {
-        dir: testInfo.outputPath(''),
+        dir: autoDir,
       },
     });
     const page = await context.newPage();
-    const error = await page.screencast.startRecording(testInfo.outputPath('video.webm')).catch(e => e);
-    expect(error.message).toContain('Video is already being recorded');
+
+    await page.screencast.startRecording(path.join(manualDir, 'video.webm'));
+    await page.evaluate(() => document.body.style.backgroundColor = 'blue');
+    await rafraf(page, 100);
     await page.screencast.stopRecording();
+    const videoFiles1 = fs.readdirSync(manualDir).filter(f => f.endsWith('.webm'));
+    expect(videoFiles1).toHaveLength(1);
+
     await context.close();
+    const videoFiles2 = fs.readdirSync(autoDir).filter(f => f.endsWith('.webm'));
+    expect(videoFiles2).toHaveLength(1);
   });
 
   it('video.start should fail when another recording is in progress', async ({ page, trace }, testInfo) => {
diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts
index dd9ee4b97cc4c..d5b9397ccfb21 100644
--- a/utils/generate_types/overrides.d.ts
+++ b/utils/generate_types/overrides.d.ts
@@ -244,10 +244,11 @@ export interface WebSocketRoute {
 
 export interface Screencast {
   start(onFrame: ((frame: { data: Buffer }) => Promise<any>|any), options?: {
-    preferredSize?: {
+    size?: {
       width: number;
       height: number;
     };
+    quality?: number;
     annotate?: {
       duration?: number;
       position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right';
