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}`
|
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 {
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
Reference in New Issue
Block a user