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:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<typeof setTimeout> | null = null
|
||||
|
||||
const startWithDefaults = withRecordingDefaults<StartRecordingWithDefaultsOptions, RecordingState>({
|
||||
relayPort,
|
||||
defaultPage,
|
||||
@@ -176,28 +211,74 @@ export function createRecordingApi(options: CreateRecordingApiOptions): {
|
||||
|
||||
const start = async (opts?: StartRecordingWithDefaultsOptions): Promise<RecordingState> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
clearMaxDurationTimer()
|
||||
const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts })
|
||||
await cancelWithDefaults(opts)
|
||||
onFinish()
|
||||
await ghostCursorController.disableForRecording({ page: targetPage })
|
||||
await restoreViewport(targetPage)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -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!
|
||||
|
||||
Reference in New Issue
Block a user