Improve FAQ & video rendering, tighten homepage and CSS

This commit is contained in:
Andras Schmelczer 2026-05-04 22:07:30 +01:00
parent 05a1f316e1
commit c69bb0d614
48 changed files with 4689 additions and 1077 deletions

128
video/src/motion.ts Normal file
View 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 };
}