diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index 1ca674f..b5dd656 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -1148,8 +1148,10 @@ export class PlaywrightExecutor { ? '' : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call reset to reconnect.]' + // timeout stacks are internal noise (Promise.race / setTimeout); only show the message + const errorText = isTimeoutError ? error.message : errorStack return { - text: `${logsText}${warningText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`, + text: `${logsText}${warningText}\nError executing code: ${errorText}${resetHint}`, images: [], isError: true, } diff --git a/playwriter/src/ffmpeg.ts b/playwriter/src/ffmpeg.ts index 2045d38..f2cba9e 100644 --- a/playwriter/src/ffmpeg.ts +++ b/playwriter/src/ffmpeg.ts @@ -61,6 +61,24 @@ export interface VideoInfo { frameRate: number } +function parseFrameRate(value: string | undefined): number | null { + if (!value) { + return null + } + + const [numRaw, denRaw] = value.split('/').map(Number) + if (!Number.isFinite(numRaw) || !Number.isFinite(denRaw) || denRaw === 0) { + return null + } + + const frameRate = numRaw / denRaw + if (!Number.isFinite(frameRate) || frameRate <= 0) { + return null + } + + return frameRate +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -72,7 +90,7 @@ export async function probeVideo(filePath: string): Promise { args: [ '-v', 'error', '-select_streams', 'v:0', - '-show_entries', 'stream=width,height,r_frame_rate', + '-show_entries', 'stream=width,height,r_frame_rate,avg_frame_rate', '-of', 'json', filePath, ], @@ -84,13 +102,31 @@ export async function probeVideo(filePath: string): Promise { throw new Error(`No video stream found in ${filePath}`) } - // r_frame_rate is a fraction like "30/1" or "30000/1001" - const [num, den] = (stream.r_frame_rate as string).split('/').map(Number) + // Prefer avg_frame_rate for VFR recordings. r_frame_rate can report + // high timebase-like values (e.g. 30000/1) that are not usable output FPS. + const avgFrameRate = parseFrameRate(stream.avg_frame_rate as string | undefined) + const rawFrameRate = parseFrameRate(stream.r_frame_rate as string | undefined) + const selectedFrameRate = (() => { + if (avgFrameRate && avgFrameRate <= 120) { + return avgFrameRate + } + if (rawFrameRate && rawFrameRate <= 120) { + return rawFrameRate + } + if (avgFrameRate) { + return avgFrameRate + } + if (rawFrameRate) { + return rawFrameRate + } + return 30 + })() + const normalizedFrameRate = Math.min(120, Math.max(1, Math.round(selectedFrameRate))) return { width: stream.width as number, height: stream.height as number, - frameRate: Math.round(num / den), + frameRate: normalizedFrameRate, } } @@ -239,7 +275,11 @@ function buildSegmentFilter({ ? 'setpts=PTS-STARTPTS' : `setpts=(PTS-STARTPTS)/${segment.speed}` - return `[0:v]${trim},${setpts},fps=${frameRate},scale=${width}:${height}[v${index}]` + // Cap output FPS to the probed source FPS. This prevents sped-up sections + // from producing excessive frame rates when timestamps are compressed. + const fps = `fps=fps=${frameRate}:round=down` + + return `[0:v]${trim},${setpts},${fps},scale=${width}:${height}[v${index}]` } // --------------------------------------------------------------------------- @@ -373,7 +413,13 @@ export async function speedUpSections( ) const filterComplex = filterParts.join('; ') - const args = ['-i', inputFile, '-filter_complex', filterComplex, '-map', '[v_out]', outputFile] + const args = [ + '-i', inputFile, + '-filter_complex', filterComplex, + '-map', '[v_out]', + '-r', String(frameRate), + outputFile, + ] console.log('Running FFmpeg speedup:', args.join(' ')) @@ -434,11 +480,10 @@ export function computeIdleSections({ const totalDuration = totalDurationMs / 1000 if (executionTimestamps.length === 0) { - // Entire video is idle - if (totalDuration <= 0) { - return [] - } - return [{ start: 0, end: totalDuration, speed }] + // No execute() boundaries were captured. This commonly happens when + // recording starts and stops inside a single execute() call. + // In this case we cannot infer idle gaps safely, so keep original speed. + return [] } // Apply buffer: expand each execution range by bufferSeconds on each side, diff --git a/playwriter/src/ghost-cursor-client.ts b/playwriter/src/ghost-cursor-client.ts index 2612a8c..831de1c 100644 --- a/playwriter/src/ghost-cursor-client.ts +++ b/playwriter/src/ghost-cursor-client.ts @@ -316,8 +316,9 @@ function enable(options?: GhostCursorClientOptions): void { runtime.y = Math.round(window.innerHeight / 2) runtime.scale = 1 runtime.hasPosition = true - applyTransform() } + + applyTransform() } function disable(): void { diff --git a/playwriter/src/mcp.ts b/playwriter/src/mcp.ts index aae092f..867e417 100644 --- a/playwriter/src/mcp.ts +++ b/playwriter/src/mcp.ts @@ -229,8 +229,10 @@ server.tool( ? '' : '\n\n[HINT: If this is an internal Playwright error, page/browser closed, or connection issue, call the `reset` tool to reconnect. Do NOT reset for other non-connection non-internal errors.]' + // timeout stacks are internal noise (Promise.race / setTimeout); only show the message + const errorText = isTimeoutError ? error.message : errorStack return { - content: [{ type: 'text', text: `Error executing code: ${error.message}\n${errorStack}${resetHint}` }], + content: [{ type: 'text', text: `Error executing code: ${errorText}${resetHint}` }], isError: true, } } diff --git a/playwriter/src/on-mouse-action.test.ts b/playwriter/src/on-mouse-action.test.ts index c74cdbb..15fd10f 100644 --- a/playwriter/src/on-mouse-action.test.ts +++ b/playwriter/src/on-mouse-action.test.ts @@ -145,7 +145,7 @@ describe('onMouseAction callback', () => { }) expect(cursorState.exists).toBe(true) - const translateMatch = cursorState.transform.match(/translate3d\(([-\d.]+)px, ([-\d.]+)px, 0px\)/) + const translateMatch = cursorState.transform.match(/translate3d\(([-\d.]+)px, ([-\d.]+)px, 0(?:px)?\)/) expect(translateMatch).toBeTruthy() const translateX = Number(translateMatch![1]) const translateY = Number(translateMatch![2]) diff --git a/playwriter/src/recording-ghost-cursor.ts b/playwriter/src/recording-ghost-cursor.ts index ef3adad..0f4760e 100644 --- a/playwriter/src/recording-ghost-cursor.ts +++ b/playwriter/src/recording-ghost-cursor.ts @@ -17,6 +17,7 @@ interface RecordingTargetOptions { export class RecordingGhostCursorController { private readonly previousMouseActionByPage = new WeakMap() + private readonly cursorApplyQueueByPage = new WeakMap>() private readonly logger: RecordingGhostCursorLogger constructor(options: { logger: RecordingGhostCursorLogger }) { @@ -58,9 +59,15 @@ export class RecordingGhostCursorController { const previousMouseAction = this.previousMouseActionByPage.get(page) page.onMouseAction = async (event) => { - void applyGhostCursorMouseAction({ page, event }).catch((error) => { - this.logger.error('[playwriter] Failed to apply ghost cursor action', error) - }) + const pendingCursorApply = this.cursorApplyQueueByPage.get(page) || Promise.resolve() + const nextCursorApply = pendingCursorApply + .then(async () => { + await applyGhostCursorMouseAction({ page, event }) + }) + .catch((error) => { + this.logger.error('[playwriter] Failed to apply ghost cursor action', error) + }) + this.cursorApplyQueueByPage.set(page, nextCursorApply) if (!previousMouseAction) { return @@ -79,6 +86,7 @@ export class RecordingGhostCursorController { const { page } = options page.onMouseAction = this.previousMouseActionByPage.get(page) ?? null this.previousMouseActionByPage.delete(page) + this.cursorApplyQueueByPage.delete(page) try { await disableGhostCursor({ page }) diff --git a/playwriter/src/screen-recording.ts b/playwriter/src/screen-recording.ts index 3004fd9..bdb5698 100644 --- a/playwriter/src/screen-recording.ts +++ b/playwriter/src/screen-recording.ts @@ -186,23 +186,18 @@ export function createRecordingApi(options: CreateRecordingApiOptions): { opts?: StopRecordingWithDefaultsOptions, ): Promise<{ path: string; duration: number; size: number; executionTimestamps: ExecutionTimestamp[] }> => { const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) - try { - const result = await stopWithDefaults(opts) - return { ...result, executionTimestamps: [...getExecutionTimestamps()] } - } finally { - onFinish() - await ghostCursorController.disableForRecording({ page: targetPage }) - } + const result = await stopWithDefaults(opts) + const executionTimestamps = [...getExecutionTimestamps()] + onFinish() + await ghostCursorController.disableForRecording({ page: targetPage }) + return { ...result, executionTimestamps } } const cancel = async (opts?: CancelRecordingWithDefaultsOptions): Promise => { const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) - try { - await cancelWithDefaults(opts) - } finally { - onFinish() - await ghostCursorController.disableForRecording({ page: targetPage }) - } + await cancelWithDefaults(opts) + onFinish() + await ghostCursorController.disableForRecording({ page: targetPage }) } return {