import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { AUTH_STATE_PATH, LEAD_IN_S, OUTPUT_DIR } from './config.js'; import { assertHardwareWebGL, launchRecordingBrowser } from './browser.js'; import { cuesToVtt, narrationLog } from './narration.js'; import { installDemoRoutes } from './routes.js'; import type { Storyboard } from './script.js'; import { storyboards } from './storyboard.js'; import { prepareTimeline, runTimeline } from './timeline.js'; import { trimRecording } from './video.js'; async function recordOne(storyboard: Storyboard): Promise { const dir = join(OUTPUT_DIR, storyboard.name); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const trimmedPath = join(dir, 'recording.webm'); const narrationPath = join(dir, 'narration.json'); if ( process.env.RESUME_RECORDINGS === '1' && existsSync(trimmedPath) && statSync(trimmedPath).size > 0 && existsSync(narrationPath) && statSync(narrationPath).size > 0 ) { console.log(`\n=== [${storyboard.name}] recording exists, skipping ===`); return; } console.log(`\n=== [${storyboard.name}] recording ===`); const { browser, context } = await launchRecordingBrowser(storyboard, { recordDir: dir, }); const page = await context.newPage(); await assertHardwareWebGL(page); const recordedVideo = page.video(); 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}`); }); // Debug instrumentation: per-second request-rate histogram by path bucket. // The desktop take intermittently freezes for ~30s after heavy filter // churn; this shows whether a request flood is what's choking the page. if (process.env.VIDEO_DEBUG_REQ_RATE === '1') { const counts = new Map(); page.on('request', (r) => { let bucket: string; try { const u = new URL(r.url()); bucket = u.pathname.split('/').slice(0, 3).join('/'); } catch { bucket = 'other'; } counts.set(bucket, (counts.get(bucket) ?? 0) + 1); }); const timer = setInterval(() => { if (counts.size === 0) return; const top = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5); console.log( `[req-rate] ${top.map(([k, v]) => `${k}=${v}`).join(' ')}` ); counts.clear(); }, 1000); timer.unref(); } await installDemoRoutes(page, storyboard); const ctx = await prepareTimeline(page, storyboard); const timeline = await runTimeline(ctx, storyboard); await page.close(); const rawPath = join(dir, 'recording.raw.webm'); await context.close(); if (recordedVideo) await recordedVideo.saveAs(rawPath); await browser.close(); if (!recordedVideo || !statSync(rawPath).size) { throw new Error(`[${storyboard.name}] no recorded webm found`); } trimRecording(rawPath, trimmedPath, storyboard, { recordStartMs, ...timeline, }); const totalDurationMs = timeline.sceneEndMs - timeline.sceneStartMs + LEAD_IN_S * 1000; const cues = narrationLog.flush( narrationPath, totalDurationMs ); console.log( `[${storyboard.name}] wrote ${cues.length} narration cues → ${join(dir, 'narration.json')}` ); const vttPath = join(dir, 'narration.vtt'); writeFileSync(vttPath, cuesToVtt(cues)); console.log( `[${storyboard.name}] wrote ${cues.length} WebVTT captions → ${vttPath}` ); } async function main(): Promise { if (!existsSync(AUTH_STATE_PATH)) { console.error(`No ${AUTH_STATE_PATH} found. Run "npm run setup-auth" first.`); process.exit(1); } if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true }); for (const sb of storyboards) { await recordOne(sb); } console.log(`\n=== recorded ${storyboards.length} storyboard(s) ===`); } main().catch((err) => { console.error(err); process.exit(1); });