Add video
This commit is contained in:
parent
589de0c5ac
commit
7c36cbfdd4
18 changed files with 2292 additions and 333 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue