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