perfect-postcode/video/src/record.ts
2026-06-22 22:04:20 +01:00

131 lines
4.3 KiB
TypeScript

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<void> {
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<string, number>();
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<void> {
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);
});