131 lines
4.3 KiB
TypeScript
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);
|
|
});
|