perfect-postcode/video/src/motion.ts
2026-07-14 16:22:23 +01:00

190 lines
6.7 KiB
TypeScript

import type { Page } from 'playwright';
export const sleep = (ms: number) =>
new Promise<void>((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<void> {
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<void> {
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<number | null> => {
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 };
}