feat: auto-resize viewport to 16:9 and auto-stop recording after 15 min

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).
This commit is contained in:
Tommy D. Rossi
2026-02-28 22:47:16 +01:00
parent 9ad8b22f93
commit 565b19abf7
3 changed files with 196 additions and 0 deletions
+111
View File
@@ -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)
}
})
})
+81
View File
@@ -42,6 +42,30 @@ export function getChromeRestartCommand(): string {
return `pkill chrome; sleep 1; google-chrome ${flags}` 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. * Check if an error is related to missing activeTab permission for recording.
*/ */
@@ -70,6 +94,12 @@ export interface StartRecordingOptions {
outputPath: string outputPath: string
/** Relay server port (default: 19988) */ /** Relay server port (default: 19988) */
relayPort?: number 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 { export interface StopRecordingOptions {
@@ -152,6 +182,11 @@ export function createRecordingApi(options: CreateRecordingApiOptions): {
} { } {
const { context, defaultPage, relayPort, ghostCursorController, onStart, onFinish, getExecutionTimestamps } = options 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<typeof setTimeout> | null = null
const startWithDefaults = withRecordingDefaults<StartRecordingWithDefaultsOptions, RecordingState>({ const startWithDefaults = withRecordingDefaults<StartRecordingWithDefaultsOptions, RecordingState>({
relayPort, relayPort,
defaultPage, defaultPage,
@@ -176,28 +211,74 @@ export function createRecordingApi(options: CreateRecordingApiOptions): {
const start = async (opts?: StartRecordingWithDefaultsOptions): Promise<RecordingState> => { const start = async (opts?: StartRecordingWithDefaultsOptions): Promise<RecordingState> => {
const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) 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) const result = await startWithDefaults(opts)
onStart() onStart()
await ghostCursorController.enableForRecording({ page: targetPage }) 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 return result
} }
const clearMaxDurationTimer = (): void => {
if (maxDurationTimer) {
clearTimeout(maxDurationTimer)
maxDurationTimer = null
}
}
const restoreViewport = async (targetPage: Page): Promise<void> => {
if (!preRecordingViewport) {
return
}
const saved = preRecordingViewport
preRecordingViewport = null
await targetPage.setViewportSize(saved)
}
const stop = async ( const stop = async (
opts?: StopRecordingWithDefaultsOptions, opts?: StopRecordingWithDefaultsOptions,
): Promise<{ path: string; duration: number; size: number; executionTimestamps: ExecutionTimestamp[] }> => { ): Promise<{ path: string; duration: number; size: number; executionTimestamps: ExecutionTimestamp[] }> => {
clearMaxDurationTimer()
const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts })
const result = await stopWithDefaults(opts) const result = await stopWithDefaults(opts)
const executionTimestamps = [...getExecutionTimestamps()] const executionTimestamps = [...getExecutionTimestamps()]
onFinish() onFinish()
await ghostCursorController.disableForRecording({ page: targetPage }) await ghostCursorController.disableForRecording({ page: targetPage })
await restoreViewport(targetPage)
return { ...result, executionTimestamps } return { ...result, executionTimestamps }
} }
const cancel = async (opts?: CancelRecordingWithDefaultsOptions): Promise<void> => { const cancel = async (opts?: CancelRecordingWithDefaultsOptions): Promise<void> => {
clearMaxDurationTimer()
const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts })
await cancelWithDefaults(opts) await cancelWithDefaults(opts)
onFinish() onFinish()
await ghostCursorController.disableForRecording({ page: targetPage }) await ghostCursorController.disableForRecording({ page: targetPage })
await restoreViewport(targetPage)
} }
return { return {
+4
View File
@@ -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. **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 ```js
// Start recording - outputPath must be specified upfront // Start recording - outputPath must be specified upfront
await recording.start({ await recording.start({
@@ -950,6 +952,8 @@ await recording.start({
frameRate: 30, // default: 30 frameRate: 30, // default: 30
audio: false, // default: false (tab audio) audio: false, // default: false (tab audio)
videoBitsPerSecond: 2500000, // 2.5 Mbps 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! // Navigate around - recording continues!