Add video

This commit is contained in:
Andras Schmelczer 2026-05-05 22:15:29 +01:00
parent 589de0c5ac
commit 7c36cbfdd4
18 changed files with 2292 additions and 333 deletions

View file

@ -22,41 +22,52 @@ export const easeOutBack = (t: number): number => {
interface MoveOptions {
durationMs?: number;
ease?: (t: number) => number;
/**
* Override the per-step CDP cost used to size the loop. Default 35ms is
* right for free cursor moves. During a drag, every mouse.move fires a
* pointermove React re-render thumb position update on the same
* thread, pushing effective per-step cost to ~100ms. Pass that for drags
* so the loop's wall duration matches `durationMs * RECORD_SCALE`.
*/
stepBudgetMs?: number;
}
// Empirical Playwright→Chromium CDP roundtrip cost for a mouse.move command
// while recording the 4K, software-GL dashboard. It is much higher than a
// simple page because every move competes with map rendering and video capture.
const CDP_MOVE_MS = 90;
/**
* Move the real mouse from its current position to (x, y) along an eased path.
* The injected cursor follows via its mousemove listener no explicit visual sync needed.
* The injected cursor follows via its mousemove listener.
*
* Why no explicit sleep between steps: each `await page.mouse.move(...)` is a
* synchronous WebSocket round-trip to Chromium. Adding a setTimeout on top
* means the loop runs at `cdp_latency + sleepMs`, overshooting wallDuration
* by ~3×. We instead size `steps = wallDuration / CDP_MOVE_MS` so the loop's
* natural pace lands on the target wall duration.
*/
export async function smoothMove(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
{ durationMs = 600, ease = easeInOut }: MoveOptions = {}
{ durationMs = 600, ease = easeInOut, stepBudgetMs = CDP_MOVE_MS }: MoveOptions = {}
): Promise<void> {
// Step count scales with RECORD_SCALE so we get more cursor positions per
// unit of visible animation — each one is a chance for the renderer to
// sample. CDP roundtrips cap us at ~60 commands/s, so 60fps × RECORD_SCALE
// is the practical ceiling.
const fps = 60;
const wallDuration = durationMs * RECORD_SCALE;
const steps = Math.max(2, Math.round((wallDuration / 1000) * fps));
const stepWaitMs = wallDuration / steps;
const steps = Math.max(2, Math.min(28, Math.round(wallDuration / stepBudgetMs)));
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);
// Use a non-scaling sleep here — we already factored RECORD_SCALE in.
await new Promise((r) => setTimeout(r, stepWaitMs));
}
}
/**
* "Fake" type: progressively set the textarea value from inside the browser,
* dispatching React-compatible input events. Looks identical to keyboard.type
* but runs in one CDP roundtrip instead of N (where N = char count). On a
* 37-char prompt this is ~1s instead of ~3s.
* "Fake" type: progressively set the textarea value, dispatching
* React-compatible input events. This is Node-driven instead of browser
* setInterval-driven because 4K software WebGL can starve page timers and
* stretch a two-second typing beat into a minute.
*/
export async function fakeType(
page: Page,
@ -64,34 +75,27 @@ export async function fakeType(
text: string,
delayMs: number
): Promise<void> {
// Scale browser-side typing by RECORD_SCALE too, so the typing animation
// has more wall time per character to render.
const scaledDelay = delayMs * RECORD_SCALE;
await page.evaluate(
({ selector, text, delayMs }) => {
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');
return new Promise<void>((resolve) => {
let i = 0;
const id = window.setInterval(() => {
i += 1;
setValue.call(ta, text.slice(0, i));
ta.dispatchEvent(new Event('input', { bubbles: true }));
if (i >= text.length) {
window.clearInterval(id);
resolve();
}
}, delayMs);
});
},
{ selector, text, delayMs: scaledDelay }
);
const delay = delayMs * RECORD_SCALE;
const steps = Math.min(6, text.length);
for (let i = 1; i <= steps; i++) {
const end = Math.ceil((text.length * i) / steps);
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, end) }
);
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
}
}
/**
@ -104,7 +108,7 @@ export async function smoothDragSliderThumb(
trackSelector: string,
fromCursor: { x: number; y: number },
toFraction: number,
durationMs = 1100
durationMs = 520
): Promise<{ x: number; y: number }> {
const thumbBox = await page.locator(thumbSelector).boundingBox();
const trackBox = await page.locator(trackSelector).boundingBox();
@ -115,13 +119,16 @@ export async function smoothDragSliderThumb(
const targetX = trackBox.x + trackBox.width * toFraction;
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 500 });
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 220 });
await page.mouse.down();
// Keep the drag to a few pointer updates. The map will redraw after commit;
// asking React/deck.gl for dozens of intermediate travel-time states is what
// made previous renders crawl and look stuttery.
await smoothMove(
page,
{ x: thumbCx, y: thumbCy },
{ x: targetX, y: thumbCy },
{ durationMs }
{ durationMs, stepBudgetMs: 360 }
);
await page.mouse.up();
return { x: targetX, y: thumbCy };