import type { Page } from 'playwright'; export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); // Cubic ease-in-out: slow start and end, fast middle. Reads as "natural" motion. export const easeInOut = (t: number): number => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; interface MoveOptions { durationMs?: number; ease?: (t: number) => number; realMouse?: boolean; } const RAW_RECORDING_FRAME_MS = 40; /** * Move the real mouse from its current position to (x, y) along an eased path. * The injected cursor follows via its mousemove listener. * * Real mouse moves are paced by wall-clock deadlines instead of CDP latency. * The old version relied on slow software WebGL making each mouse.move call * expensive; with hardware GPU those calls return quickly and animations * collapse into a burst unless we explicitly pace them. */ export async function smoothMove( page: Page, from: { x: number; y: number }, to: { x: number; y: number }, { durationMs = 600, ease = easeInOut, realMouse = false, }: MoveOptions = {} ): Promise { const wallDuration = durationMs; if (!realMouse) { const animated = await page.evaluate( ({ x, y, wallDuration }) => { const move = ( window as typeof window & { __demoMoveCursor?: (x: number, y: number, durationMs: number) => void; } ).__demoMoveCursor; if (!move) return false; move(x, y, wallDuration); return true; }, { x: to.x, y: to.y, wallDuration } ); if (animated) { await new Promise((resolve) => setTimeout(resolve, wallDuration)); await page.mouse.move(to.x, to.y); return; } } const steps = Math.max(2, Math.min(160, Math.round(wallDuration / RAW_RECORDING_FRAME_MS))); const start = Date.now(); for (let i = 1; i <= steps; i++) { const t = ease(i / steps); const x = from.x + (to.x - from.x) * t; const y = from.y + (to.y - from.y) * t; await page.mouse.move(x, y); const targetElapsed = (wallDuration * i) / steps; const waitMs = start + targetElapsed - Date.now(); if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs)); } } /** * "Fake" type: progressively set the textarea value, dispatching * React-compatible input events. * * Do not do one Playwright round-trip per character here. Long prompts can * turn a 4s typing budget into 9s of wall-clock time on a busy recorder. * Instead, animate through paced chunks. It still reads as typing on video, * but the runner can keep narration and visuals aligned. */ export async function fakeType( page: Page, selector: string, text: string, totalDurationMs: number ): Promise { if (text.length === 0) { if (totalDurationMs > 0) await sleep(totalDurationMs); return; } const steps = Math.min( text.length, Math.max(1, Math.min(48, Math.round(totalDurationMs / 95))) ); const startedAt = Date.now(); for (let i = 1; i <= steps; i++) { const charCount = Math.max(1, Math.round((i / steps) * text.length)); await page.evaluate( ({ selector, value }) => { const ta = document.querySelector(selector) as HTMLTextAreaElement | null; if (!ta) throw new Error('textarea not found: ' + selector); ta.focus(); // React tracks the textarea value by hooking the descriptor; we have to // call the prototype setter directly so React sees the change. const proto = Object.getPrototypeOf(ta); const setValue = Object.getOwnPropertyDescriptor(proto, 'value')?.set; if (!setValue) throw new Error('no value setter on textarea'); setValue.call(ta, value); ta.dispatchEvent(new Event('input', { bubbles: true })); }, { selector, value: text.slice(0, charCount) } ); if (i < steps) { const targetElapsed = (totalDurationMs * i) / steps; const waitMs = startedAt + targetElapsed - Date.now(); if (waitMs > 0) await sleep(waitMs); } } } /** * Drag the right-hand thumb of a Radix slider to a target track fraction. * Returns the final cursor position so callers can chain a smoothMove afterwards. */ export async function smoothDragSliderThumb( page: Page, thumbSelector: string, trackSelector: string, fromCursor: { x: number; y: number }, toFraction: number, durationMs = 520, snapToValue?: number ): Promise<{ x: number; y: number }> { const thumbBox = await page.locator(thumbSelector).boundingBox(); const trackBox = await page.locator(trackSelector).boundingBox(); if (!thumbBox || !trackBox) throw new Error('slider not found'); const thumbCx = thumbBox.x + thumbBox.width / 2; const thumbCy = thumbBox.y + thumbBox.height / 2; const targetX = trackBox.x + trackBox.width * toFraction; await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 220 }); await page.mouse.down(); // The user explicitly prefers a longer render over stepped motion, so use // enough real pointer updates for the thumb itself to read as continuous. const dragMs = snapToValue == null ? durationMs : Math.max(200, durationMs - 400); await smoothMove( page, { x: thumbCx, y: thumbCy }, { x: targetX, y: thumbCy }, { durationMs: dragMs, realMouse: true } ); // Converge on the exact value WHILE the pointer is still down. A pixel // drop lands a few steps off (thumb inset + snapping): the "20 minutes" // drag settled on 15 and the UI contradicted the narration. Keyboard // nudging after mouseup doesn't work here: the app's slider only accepts // value changes inside a pointer session (onPointerDown starts the drag // state, onPointerUp commits), so the correction must be pointer-driven. let finalX = targetX; if (snapToValue != null) { const thumb = page.locator(thumbSelector); const readAttr = async (name: string): Promise => { const raw = await thumb.getAttribute(name).catch(() => null); const value = raw == null ? NaN : Number(raw); return Number.isFinite(value) ? value : null; }; const min = await readAttr('aria-valuemin'); const max = await readAttr('aria-valuemax'); const pxPerUnit = min != null && max != null && max > min ? trackBox.width / (max - min) : null; if (pxPerUnit != null) { for (let i = 0; i < 24; i++) { const now = await readAttr('aria-valuenow'); if (now == null || now === snapToValue) break; let dx = (snapToValue - now) * pxPerUnit; dx = Math.max(-60, Math.min(60, dx)); if (Math.abs(dx) < 1) dx = Math.sign(snapToValue - now); finalX += dx; await page.mouse.move(finalX, thumbCy, { steps: 3 }); await sleep(50); } } } await page.mouse.up(); return { x: finalX, y: thumbCy }; }