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.]'
// 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,
}
+56 -11
View File
@@ -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<VideoInfo> {
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<VideoInfo> {
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,
+2 -1
View File
@@ -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 {
+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.]'
// 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,
}
}
+1 -1
View File
@@ -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])
+11 -3
View File
@@ -17,6 +17,7 @@ interface RecordingTargetOptions {
export class RecordingGhostCursorController {
private readonly previousMouseActionByPage = new WeakMap<Page, Page['onMouseAction']>()
private readonly cursorApplyQueueByPage = new WeakMap<Page, Promise<void>>()
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 })
+8 -13
View File
@@ -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<void> => {
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 {