perfect-postcode/video/src/record.ts

220 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 25s; 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);
});