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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user