fix: stabilize recording cursor and demo speedup behavior

Improve recording lifecycle safety by keeping stop and cancel cleanup aligned with successful relay operations, serializing per-page ghost cursor action application, and ensuring cursor transforms refresh when style or hotspot options change.

Harden demo video generation by preferring sane probed frame rates, capping speedup output FPS to source rate, and avoiding full-video speedup when execution timestamps are unavailable.
This commit is contained in:
Tommy D. Rossi
2026-02-26 12:59:54 +01:00
parent c13c835c5f
commit 2c7e859a7b
7 changed files with 84 additions and 31 deletions
+3 -1
View File
@@ -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.]' : '\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 { return {
text: `${logsText}${warningText}\nError executing code: ${error.message}\n${errorStack}${resetHint}`, text: `${logsText}${warningText}\nError executing code: ${errorText}${resetHint}`,
images: [], images: [],
isError: true, isError: true,
} }
+56 -11
View File
@@ -61,6 +61,24 @@ export interface VideoInfo {
frameRate: number 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 // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -72,7 +90,7 @@ export async function probeVideo(filePath: string): Promise<VideoInfo> {
args: [ args: [
'-v', 'error', '-v', 'error',
'-select_streams', 'v:0', '-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', '-of', 'json',
filePath, filePath,
], ],
@@ -84,13 +102,31 @@ export async function probeVideo(filePath: string): Promise<VideoInfo> {
throw new Error(`No video stream found in ${filePath}`) throw new Error(`No video stream found in ${filePath}`)
} }
// r_frame_rate is a fraction like "30/1" or "30000/1001" // Prefer avg_frame_rate for VFR recordings. r_frame_rate can report
const [num, den] = (stream.r_frame_rate as string).split('/').map(Number) // 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 { return {
width: stream.width as number, width: stream.width as number,
height: stream.height 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'
: `setpts=(PTS-STARTPTS)/${segment.speed}` : `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 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(' ')) console.log('Running FFmpeg speedup:', args.join(' '))
@@ -434,11 +480,10 @@ export function computeIdleSections({
const totalDuration = totalDurationMs / 1000 const totalDuration = totalDurationMs / 1000
if (executionTimestamps.length === 0) { if (executionTimestamps.length === 0) {
// Entire video is idle // No execute() boundaries were captured. This commonly happens when
if (totalDuration <= 0) { // recording starts and stops inside a single execute() call.
return [] // In this case we cannot infer idle gaps safely, so keep original speed.
} return []
return [{ start: 0, end: totalDuration, speed }]
} }
// Apply buffer: expand each execution range by bufferSeconds on each side, // Apply buffer: expand each execution range by bufferSeconds on each side,
+2 -1
View File
@@ -316,8 +316,9 @@ function enable(options?: GhostCursorClientOptions): void {
runtime.y = Math.round(window.innerHeight / 2) runtime.y = Math.round(window.innerHeight / 2)
runtime.scale = 1 runtime.scale = 1
runtime.hasPosition = true runtime.hasPosition = true
applyTransform()
} }
applyTransform()
} }
function disable(): void { function disable(): void {
+3 -1
View File
@@ -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.]' : '\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 { return {
content: [{ type: 'text', text: `Error executing code: ${error.message}\n${errorStack}${resetHint}` }], content: [{ type: 'text', text: `Error executing code: ${errorText}${resetHint}` }],
isError: true, isError: true,
} }
} }
+1 -1
View File
@@ -145,7 +145,7 @@ describe('onMouseAction callback', () => {
}) })
expect(cursorState.exists).toBe(true) 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() expect(translateMatch).toBeTruthy()
const translateX = Number(translateMatch![1]) const translateX = Number(translateMatch![1])
const translateY = Number(translateMatch![2]) const translateY = Number(translateMatch![2])
+11 -3
View File
@@ -17,6 +17,7 @@ interface RecordingTargetOptions {
export class RecordingGhostCursorController { export class RecordingGhostCursorController {
private readonly previousMouseActionByPage = new WeakMap<Page, Page['onMouseAction']>() private readonly previousMouseActionByPage = new WeakMap<Page, Page['onMouseAction']>()
private readonly cursorApplyQueueByPage = new WeakMap<Page, Promise<void>>()
private readonly logger: RecordingGhostCursorLogger private readonly logger: RecordingGhostCursorLogger
constructor(options: { logger: RecordingGhostCursorLogger }) { constructor(options: { logger: RecordingGhostCursorLogger }) {
@@ -58,9 +59,15 @@ export class RecordingGhostCursorController {
const previousMouseAction = this.previousMouseActionByPage.get(page) const previousMouseAction = this.previousMouseActionByPage.get(page)
page.onMouseAction = async (event) => { page.onMouseAction = async (event) => {
void applyGhostCursorMouseAction({ page, event }).catch((error) => { const pendingCursorApply = this.cursorApplyQueueByPage.get(page) || Promise.resolve()
this.logger.error('[playwriter] Failed to apply ghost cursor action', error) 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) { if (!previousMouseAction) {
return return
@@ -79,6 +86,7 @@ export class RecordingGhostCursorController {
const { page } = options const { page } = options
page.onMouseAction = this.previousMouseActionByPage.get(page) ?? null page.onMouseAction = this.previousMouseActionByPage.get(page) ?? null
this.previousMouseActionByPage.delete(page) this.previousMouseActionByPage.delete(page)
this.cursorApplyQueueByPage.delete(page)
try { try {
await disableGhostCursor({ page }) await disableGhostCursor({ page })
+8 -13
View File
@@ -186,23 +186,18 @@ export function createRecordingApi(options: CreateRecordingApiOptions): {
opts?: StopRecordingWithDefaultsOptions, opts?: StopRecordingWithDefaultsOptions,
): Promise<{ path: string; duration: number; size: number; executionTimestamps: ExecutionTimestamp[] }> => { ): Promise<{ path: string; duration: number; size: number; executionTimestamps: ExecutionTimestamp[] }> => {
const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts })
try { const result = await stopWithDefaults(opts)
const result = await stopWithDefaults(opts) const executionTimestamps = [...getExecutionTimestamps()]
return { ...result, executionTimestamps: [...getExecutionTimestamps()] } onFinish()
} finally { await ghostCursorController.disableForRecording({ page: targetPage })
onFinish() return { ...result, executionTimestamps }
await ghostCursorController.disableForRecording({ page: targetPage })
}
} }
const cancel = async (opts?: CancelRecordingWithDefaultsOptions): Promise<void> => { const cancel = async (opts?: CancelRecordingWithDefaultsOptions): Promise<void> => {
const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts }) const targetPage = resolveRecordingTargetPage({ context, defaultPage, ghostCursorController, target: opts })
try { await cancelWithDefaults(opts)
await cancelWithDefaults(opts) onFinish()
} finally { await ghostCursorController.disableForRecording({ page: targetPage })
onFinish()
await ghostCursorController.disableForRecording({ page: targetPage })
}
} }
return { return {