Improve FAQ & video rendering, tighten homepage and CSS
This commit is contained in:
parent
05a1f316e1
commit
c69bb0d614
48 changed files with 4689 additions and 1077 deletions
128
video/src/motion.ts
Normal file
128
video/src/motion.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type { Page } from 'playwright';
|
||||
import { RECORD_SCALE } from './config.js';
|
||||
|
||||
// All timing primitives multiply by RECORD_SCALE. Scenes call them with
|
||||
// "human-time" durations; the actual wall-clock pause is N× longer so the
|
||||
// renderer has more time per visual frame. ffmpeg later speeds the output
|
||||
// back up, so the *visible* animation speed in the final video is unchanged.
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise<void>((r) => setTimeout(r, ms * RECORD_SCALE));
|
||||
|
||||
// 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;
|
||||
|
||||
// Slight overshoot then settle — gives clicks a tactile feel when paired with ripple.
|
||||
export const easeOutBack = (t: number): number => {
|
||||
const c1 = 1.70158;
|
||||
const c3 = c1 + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
|
||||
};
|
||||
|
||||
interface MoveOptions {
|
||||
durationMs?: number;
|
||||
ease?: (t: number) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function smoothMove(
|
||||
page: Page,
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
{ durationMs = 600, ease = easeInOut }: 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;
|
||||
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.
|
||||
*/
|
||||
export async function fakeType(
|
||||
page: Page,
|
||||
selector: string,
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = 1100
|
||||
): 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;
|
||||
|
||||
// smoothMove already applies RECORD_SCALE internally; pass human-time durations.
|
||||
await smoothMove(page, fromCursor, { x: thumbCx, y: thumbCy }, { durationMs: 500 });
|
||||
await page.mouse.down();
|
||||
await smoothMove(
|
||||
page,
|
||||
{ x: thumbCx, y: thumbCy },
|
||||
{ x: targetX, y: thumbCy },
|
||||
{ durationMs }
|
||||
);
|
||||
await page.mouse.up();
|
||||
return { x: targetX, y: thumbCy };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue