import { chromium } from 'playwright'; import { execSync } from 'node:child_process'; import { existsSync, mkdirSync, renameSync, readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { APP_URL, AUTH_STATE_PATH, COLD_OPEN_VIEW, DASHBOARD_PATH, MAX_DURATION_S, OUTPUT_DIR, OUTPUT_FPS, RECORD_SCALE, STUBBED_FILTERS, VIEWPORT, } from './config.js'; import { installCursor } from './dom.js'; import { sceneAiPrompt, sceneColdOpen, sceneOutro, scenePropertyReveal, sceneSliderControl, type SceneCtx, } from './scenes.js'; import { sleep } from './motion.js'; /** * Stub the AI endpoint. The real backend calls Gemini and takes 2–5s; for a * 15-second video we want sub-second response so the map reacts crisply with * the typed prompt still on screen. Returning canned filters also makes every * recording bit-identical. */ async function stubAiFilters(page: import('playwright').Page) { await page.route('**/api/ai-filters', async (route) => { // Small delay so the loading indicator is visible (looks like real AI work). await new Promise((r) => setTimeout(r, 400)); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ filters: STUBBED_FILTERS, travel_time_filters: [], notes: '', match_count: 1247, }), }); }); } async function main() { if (!existsSync(AUTH_STATE_PATH)) { console.error( `No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first to log in once.` ); process.exit(1); } if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true }); const browser = await chromium.launch({ headless: true, args: [ '--disable-blink-features=AutomationControlled', // Headless Chromium otherwise loses the WebGL context mid-render when // deck.gl pushes large buffers; SwiftShader is software GL but stable. '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist', // Lift Chromium's animation/raster rate caps so RECORD_SCALE actually // gets us extra frames per second of wall time. Without these, Chromium // throttles offscreen rendering and the slow-down is wasted. '--disable-frame-rate-limit', '--disable-gpu-vsync', '--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling', '--disable-renderer-backgrounding', '--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', ], }); const context = await browser.newContext({ storageState: AUTH_STATE_PATH, viewport: VIEWPORT, deviceScaleFactor: 2, recordVideo: { dir: OUTPUT_DIR, size: VIEWPORT }, }); // Vite's dev server pushes HMR updates over a "vite-hmr" WebSocket. If a // module isn't accept-marked the client triggers a FULL page reload — which // mid-recording resets the React tree and re-shows "Connecting to server…". // Disable the client-side HMR socket entirely. await context.addInitScript(() => { // Block the vite-hmr WebSocket so HMR push messages never arrive. const RealWS = window.WebSocket; window.WebSocket = new Proxy(RealWS, { construct(target, args) { const proto = (args[1] as string | string[] | undefined) ?? ''; const protoStr = Array.isArray(proto) ? proto.join(',') : proto; if (protoStr.includes('vite-hmr')) { return Object.assign(Object.create(RealWS.prototype), { readyState: RealWS.CONNECTING, send() {}, close() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent: () => true, }); } return Reflect.construct(target, args); }, }); // Belt-and-braces: even if an HMR push slips through (e.g. via a different // transport in a later Vite version), neutralize the full-reload fallback. const noop = () => {}; Object.defineProperty(window.location, 'reload', { value: noop, configurable: true }); }); const page = await context.newPage(); // recordVideo starts the moment the page is created. We want the final clip // to begin at the cold-open scene, not include the navigation/settle phase. // Track when the recording started and when the scenes start, so we can // ffmpeg-trim post-hoc. const recordStartMs = Date.now(); page.on('console', (m) => { if (m.type() === 'error' || m.type() === 'warning') { console.log(`[browser ${m.type()}] ${m.text()}`); } }); page.on('response', (r) => { const u = r.url(); if (r.status() === 401 || u.includes('ai-filters')) { console.log(`[net] ${r.status()} ${r.request().method()} ${u}`); } }); page.on('request', (r) => { const u = r.url(); if (u.includes('ai-filters')) console.log(`[req] ${r.method()} ${u}`); }); await stubAiFilters(page); const url = `${APP_URL}${DASHBOARD_PATH}${COLD_OPEN_VIEW}`; await page.goto(url, { waitUntil: 'networkidle' }); // Settle: deck.gl tiles, postcode aggregations, sidebar mount. await sleep(800); await installCursor(page); // Park cursor near top-left so its first move (to the AI box) is visible. const ctx: SceneCtx = { page, cursor: { x: 80, y: 90 } }; await page.mouse.move(ctx.cursor.x, ctx.cursor.y); const sceneStartMs = Date.now(); await sceneColdOpen(ctx); await sceneAiPrompt(ctx); await sceneSliderControl(ctx); await scenePropertyReveal(ctx); await sceneOutro(ctx); const sceneEndMs = Date.now(); await page.close(); await context.close(); await browser.close(); // Playwright names recordings by guid; rename the most recent one. const files = readdirSync(OUTPUT_DIR) .filter((f) => f.endsWith('.webm') && f.startsWith('page@')) .map((f) => ({ f, t: statSync(join(OUTPUT_DIR, f)).mtimeMs })) .sort((a, b) => b.t - a.t); if (!files[0]) { console.error('no recorded webm found'); process.exit(1); } const rawPath = join(OUTPUT_DIR, files[0].f); const trimmedPath = join(OUTPUT_DIR, 'recording.webm'); const sceneSpan = (sceneEndMs - sceneStartMs) / 1000; // The trim window is in *recording wall time*, which is RECORD_SCALE× the // visible duration. After ffmpeg setpts speeds it back up, the final clip // will be exactly MAX_DURATION_S seconds. const wallCap = MAX_DURATION_S * RECORD_SCALE; const trimEnd = (sceneEndMs - recordStartMs) / 1000; const wallDuration = Math.min(sceneSpan, wallCap); const trimStart = trimEnd - wallDuration; const finalDuration = wallDuration / RECORD_SCALE; if (sceneSpan > wallCap) { console.log( `Scene wall time was ${sceneSpan.toFixed(2)}s (cap ${wallCap.toFixed(2)}s at scale ${RECORD_SCALE}); trimming to last ${MAX_DURATION_S}s (anchored to outro).` ); } // Trim AND speed up AND interpolate to OUTPUT_FPS in one pass. // - -ss + -t: trim window in raw recording's wall time. // - setpts=PTS/RECORD_SCALE: speed up the clip back to "human time". // - minterpolate at fps=OUTPUT_FPS: synthesize intermediate frames so the // sped-up output runs smoothly at 60fps even if raw was 25fps. // - libvpx-vp9 with -deadline good gives a tight WebM that the encode step // can re-mux to MP4 quickly. execSync( `ffmpeg -y -ss ${trimStart.toFixed(3)} -i "${rawPath}" -t ${wallDuration.toFixed(3)} ` + `-vf "setpts=PTS/${RECORD_SCALE},minterpolate=fps=${OUTPUT_FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1" ` + `-r ${OUTPUT_FPS} ` + `-c:v libvpx-vp9 -b:v 6M -deadline good -cpu-used 2 ` + `"${trimmedPath}"`, { stdio: 'inherit' } ); // Drop the untrimmed file once we've extracted the scenes. try { statSync(rawPath) && renameSync(rawPath, rawPath + '.untrimmed'); } catch { /* ignore */ } console.log(`Wrote ${trimmedPath} (${finalDuration.toFixed(2)}s @ ${OUTPUT_FPS}fps, scale=${RECORD_SCALE})`); console.log('Run "npm run encode" to produce output/recording.mp4'); } main().catch((err) => { console.error(err); process.exit(1); });