From 565b19abf7cb3bf8412c232cc6f812cadf6aaaaf Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Sat, 28 Feb 2026 22:47:16 +0100 Subject: [PATCH] feat: auto-resize viewport to 16:9 and auto-stop recording after 15 min MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording now automatically fits the viewport to a target aspect ratio (default 16:9) before starting — shrinks to fit, never increases dimensions. Original viewport is restored on stop/cancel. Added maxDurationMs option (default 15 min) that auto-stops recording to prevent accidentally filling disk. Set to 0 or Infinity to disable. Both options are documented in skill.md with examples. fitToAspectRatio is a pure utility function covered by inline snapshot tests for common screen sizes (1080p, MacBook 16:10, 4:3, ultra-wide, square) and custom ratios (4:3, 1:1, 9:16). --- playwriter/src/screen-recording.test.ts | 111 ++++++++++++++++++++++++ playwriter/src/screen-recording.ts | 81 +++++++++++++++++ playwriter/src/skill.md | 4 + 3 files changed, 196 insertions(+) create mode 100644 playwriter/src/screen-recording.test.ts diff --git a/playwriter/src/screen-recording.test.ts b/playwriter/src/screen-recording.test.ts new file mode 100644 index 0000000..58edb78 --- /dev/null +++ b/playwriter/src/screen-recording.test.ts @@ -0,0 +1,111 @@ +/** + * Unit tests for fitToAspectRatio — verifies viewport shrink-to-fit + * for common screen sizes and aspect ratios. + */ +import { describe, test, expect } from 'vitest' +import { fitToAspectRatio } from './screen-recording.js' + +describe('fitToAspectRatio', () => { + test('common sizes → 16:9', () => { + const ratio = { width: 16, height: 9 } + + // Already 16:9 — no change + expect(fitToAspectRatio({ width: 1920, height: 1080 }, ratio)).toMatchInlineSnapshot(` + { + "height": 1080, + "width": 1920, + } + `) + expect(fitToAspectRatio({ width: 1280, height: 720 }, ratio)).toMatchInlineSnapshot(` + { + "height": 720, + "width": 1280, + } + `) + + // 16:10 (MacBook default) — too tall, shrink height + expect(fitToAspectRatio({ width: 1440, height: 900 }, ratio)).toMatchInlineSnapshot(` + { + "height": 810, + "width": 1440, + } + `) + expect(fitToAspectRatio({ width: 1680, height: 1050 }, ratio)).toMatchInlineSnapshot(` + { + "height": 945, + "width": 1680, + } + `) + + // 4:3 — too tall, shrink height + expect(fitToAspectRatio({ width: 1024, height: 768 }, ratio)).toMatchInlineSnapshot(` + { + "height": 576, + "width": 1024, + } + `) + + // Ultra-wide 21:9 — too wide, shrink width + expect(fitToAspectRatio({ width: 2560, height: 1080 }, ratio)).toMatchInlineSnapshot(` + { + "height": 1080, + "width": 1920, + } + `) + expect(fitToAspectRatio({ width: 3440, height: 1440 }, ratio)).toMatchInlineSnapshot(` + { + "height": 1440, + "width": 2560, + } + `) + + // Square — too tall, shrink height + expect(fitToAspectRatio({ width: 1000, height: 1000 }, ratio)).toMatchInlineSnapshot(` + { + "height": 563, + "width": 1000, + } + `) + }) + + test('custom aspect ratios', () => { + // 4:3 + expect(fitToAspectRatio({ width: 1920, height: 1080 }, { width: 4, height: 3 })).toMatchInlineSnapshot(` + { + "height": 1080, + "width": 1440, + } + `) + + // 1:1 + expect(fitToAspectRatio({ width: 1920, height: 1080 }, { width: 1, height: 1 })).toMatchInlineSnapshot(` + { + "height": 1080, + "width": 1080, + } + `) + + // 9:16 vertical + expect(fitToAspectRatio({ width: 1920, height: 1080 }, { width: 9, height: 16 })).toMatchInlineSnapshot(` + { + "height": 1080, + "width": 608, + } + `) + }) + + test('never increases dimensions', () => { + const ratio = { width: 16, height: 9 } + const sizes = [ + { width: 800, height: 600 }, + { width: 1440, height: 900 }, + { width: 2560, height: 1080 }, + { width: 1000, height: 1000 }, + ] + for (const size of sizes) { + const result = fitToAspectRatio(size, ratio) + expect(result.width).toBeLessThanOrEqual(size.width) + expect(result.height).toBeLessThanOrEqual(size.height) + } + }) +}) diff --git a/playwriter/src/screen-recording.ts b/playwriter/src/screen-recording.ts index bdb5698..f21e68f 100644 --- a/playwriter/src/screen-recording.ts +++ b/playwriter/src/screen-recording.ts @@ -42,6 +42,30 @@ export function getChromeRestartCommand(): string { return `pkill chrome; sleep 1; google-chrome ${flags}` } +const DEFAULT_ASPECT_RATIO = { width: 16, height: 9 } + +/** Default max recording duration: 15 minutes in milliseconds */ +const DEFAULT_MAX_DURATION_MS = 15 * 60 * 1000 + +/** + * Compute the largest viewport that fits inside `current` at the target aspect ratio. + * Never increases width or height beyond current values — only shrinks the + * dimension that's "too large" relative to the target ratio. + */ +export function fitToAspectRatio( + current: { width: number; height: number }, + ratio: { width: number; height: number } = DEFAULT_ASPECT_RATIO, +): { width: number; height: number } { + const targetRatio = ratio.width / ratio.height + const currentRatio = current.width / current.height + if (currentRatio > targetRatio) { + // Too wide — keep height, shrink width + return { width: Math.round(current.height * targetRatio), height: current.height } + } + // Too tall (or already exact) — keep width, shrink height + return { width: current.width, height: Math.round(current.width / targetRatio) } +} + /** * Check if an error is related to missing activeTab permission for recording. */ @@ -70,6 +94,12 @@ export interface StartRecordingOptions { outputPath: string /** Relay server port (default: 19988) */ relayPort?: number + /** Aspect ratio to fit viewport to before recording (default: { width: 16, height: 9 }). + * Set to null to skip viewport resizing. */ + aspectRatio?: { width: number; height: number } | null + /** Max recording duration in ms (default: 15 min = 900000). Auto-stops recording + * when exceeded to prevent accidentally filling disk. Set to 0 or Infinity to disable. */ + maxDurationMs?: number } export interface StopRecordingOptions { @@ -152,6 +182,11 @@ export function createRecordingApi(options: CreateRecordingApiOptions): { } { const { context, defaultPage, relayPort, ghostCursorController, onStart, onFinish, getExecutionTimestamps } = options + // Stores the original viewport before aspect-ratio resize so we can restore on stop/cancel + let preRecordingViewport: { width: number; height: number } | null = null + // Auto-stop timer to prevent unbounded recordings + let maxDurationTimer: ReturnType | null = null + const startWithDefaults = withRecordingDefaults({ relayPort, defaultPage, @@ -176,28 +211,74 @@ export function createRecordingApi(options: CreateRecordingApiOptions): { const start = async (opts?: StartRecordingWithDefaultsOptions): Promise => { const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) + + // Resize viewport to target aspect ratio (default 16:9) before recording. + // Only shrinks — never increases width or height beyond current values. + const aspectRatio = opts?.aspectRatio === undefined ? DEFAULT_ASPECT_RATIO : opts.aspectRatio + if (aspectRatio) { + const current = targetPage.viewportSize() + if (current) { + const fitted = fitToAspectRatio(current, aspectRatio) + if (fitted.width !== current.width || fitted.height !== current.height) { + preRecordingViewport = current + await targetPage.setViewportSize(fitted) + } + } + } + const result = await startWithDefaults(opts) onStart() await ghostCursorController.enableForRecording({ page: targetPage }) + + // Schedule auto-stop to prevent unbounded recordings filling disk. + // Default 15 min. Set maxDurationMs to 0 or Infinity to disable. + const maxMs = opts?.maxDurationMs ?? DEFAULT_MAX_DURATION_MS + if (maxMs > 0 && maxMs < Infinity) { + maxDurationTimer = setTimeout(() => { + maxDurationTimer = null + stop(opts ? { page: opts.page, sessionId: opts.sessionId } : undefined).catch(() => {}) + }, maxMs) + } + return result } + const clearMaxDurationTimer = (): void => { + if (maxDurationTimer) { + clearTimeout(maxDurationTimer) + maxDurationTimer = null + } + } + + const restoreViewport = async (targetPage: Page): Promise => { + if (!preRecordingViewport) { + return + } + const saved = preRecordingViewport + preRecordingViewport = null + await targetPage.setViewportSize(saved) + } + const stop = async ( opts?: StopRecordingWithDefaultsOptions, ): Promise<{ path: string; duration: number; size: number; executionTimestamps: ExecutionTimestamp[] }> => { + clearMaxDurationTimer() const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) const result = await stopWithDefaults(opts) const executionTimestamps = [...getExecutionTimestamps()] onFinish() await ghostCursorController.disableForRecording({ page: targetPage }) + await restoreViewport(targetPage) return { ...result, executionTimestamps } } const cancel = async (opts?: CancelRecordingWithDefaultsOptions): Promise => { + clearMaxDurationTimer() const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) await cancelWithDefaults(opts) onFinish() await ghostCursorController.disableForRecording({ page: targetPage }) + await restoreViewport(targetPage) } return { diff --git a/playwriter/src/skill.md b/playwriter/src/skill.md index 086b8bc..a6af2c7 100644 --- a/playwriter/src/skill.md +++ b/playwriter/src/skill.md @@ -942,6 +942,8 @@ For demos where cursor movement should be visible and human-like, drive the page **Note**: Recording requires the user to have clicked the Playwriter extension icon on the tab. This grants `activeTab` permission needed for `chrome.tabCapture`. Recording works on tabs where the icon was clicked - if you need to record a new tab, ask the user to click the icon on it first. +Recording auto-resizes the viewport to 16:9 aspect ratio before starting (shrink-to-fit, never increases dimensions). Override with `aspectRatio` or set `null` to skip. Recording also auto-stops after 15 minutes by default to prevent filling disk. For longer recordings, pass a higher `maxDurationMs` (e.g. `maxDurationMs: 60 * 60 * 1000` for 1 hour) or `0` to disable. + ```js // Start recording - outputPath must be specified upfront await recording.start({ @@ -950,6 +952,8 @@ await recording.start({ frameRate: 30, // default: 30 audio: false, // default: false (tab audio) videoBitsPerSecond: 2500000, // 2.5 Mbps + aspectRatio: { width: 16, height: 9 }, // default: 16:9, set null to skip + maxDurationMs: 15 * 60 * 1000, // default: 15 min, set 0 to disable }) // Navigate around - recording continues!