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
220
video/src/record.ts
Normal file
220
video/src/record.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue