import { chromium } from "playwright"; import { readFile, writeFile, mkdir, rm } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { login, spaNavigate } from "./lib/spa.js"; import { makeCursor, CURSOR_INIT_SCRIPT } from "./lib/cursor.js"; import { newestWebm, webmToMp4, probeSeconds, secondsToFrames } from "./lib/record.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, ".."); const BASE_URL = process.env.FILM_BASE_URL || "http://localhost:5173"; const FPS = 30; const VIEWPORT = { width: 1920, height: 1080 }; const onlyId = process.argv.includes("--scene") ? process.argv[process.argv.indexOf("--scene") + 1] : null; async function loadScene(id) { const mod = await import(`./scenes/${id}.mjs`).catch(() => null); return mod?.default ?? null; } async function main() { const manifestPath = join(ROOT, "src/clips.manifest.json"); const manifest = JSON.parse(await readFile(manifestPath, "utf8")); const clips = onlyId ? manifest.clips.filter((c) => c.id === onlyId) : manifest.clips; if (!clips.length) throw new Error(`no clip matches --scene ${onlyId}`); const outDir = join(ROOT, "public/clips"); await mkdir(outDir, { recursive: true }); const browser = await chromium.launch(); // Order: public scenes first (no login), then group by role to log in once each. const order = [...clips].sort((a, b) => roleRank(a.role) - roleRank(b.role)); const failures = []; for (const clip of order) { const scene = await loadScene(clip.id); if (!scene) { console.warn(`! no scene file for ${clip.id} — skipping`); continue; } // Fresh per-clip context so each recording is its own .webm. const tmpVideoDir = join(outDir, `_rec_${clip.id}`); let context = null; try { await rm(tmpVideoDir, { recursive: true, force: true }); context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1, // recordVideo records CSS px; keep 1 for exact 1080p recordVideo: { dir: tmpVideoDir, size: VIEWPORT }, }); const tCtx = Date.now(); // recording starts at context creation; measure the lead-in await context.addInitScript("try{localStorage.setItem('theme','dark')}catch(e){}"); await context.addInitScript(CURSOR_INIT_SCRIPT); const page = await context.newPage(); const cursor = makeCursor(page); if (clip.role !== "public") { await login(page, BASE_URL, clip.role); await spaNavigate(page, clip.route); } else { await page.goto(`${BASE_URL}${clip.route}`, { waitUntil: "networkidle" }); } await page.waitForTimeout(800); const leadInSec = Math.max(0, (Date.now() - tCtx) / 1000 - 0.3); // trim login+nav, keep 0.3s pad console.log(`▶ recording ${clip.id} (${clip.role}) ... (trimming ${leadInSec.toFixed(1)}s lead-in)`); await scene(page, cursor, { baseUrl: BASE_URL, spaNavigate: (route) => spaNavigate(page, route), login: (role) => login(page, BASE_URL, role), role: clip.role, waitSettle: (ms = 1500) => page.waitForTimeout(ms), }); await context.close(); // flush the .webm context = null; const webm = await newestWebm(tmpVideoDir); const mp4 = join(outDir, `${clip.id}.mp4`); await webmToMp4(webm, mp4, FPS, leadInSec); await rm(tmpVideoDir, { recursive: true, force: true }); const seconds = await probeSeconds(mp4); clip.durationFrames = secondsToFrames(seconds, FPS); console.log(`✓ ${clip.id} → ${mp4} (${seconds.toFixed(2)}s = ${clip.durationFrames}f)`); } catch (e) { // One bad scene must not abort the whole run or lose the manifest. failures.push({ id: clip.id, error: e.message }); console.error(`✗ ${clip.id} FAILED: ${e.message}`); try { if (context) await context.close(); } catch {} await rm(tmpVideoDir, { recursive: true, force: true }).catch(() => {}); } } await browser.close(); // Persist updated durations (for every clip that succeeded). await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); console.log(`\nDone. ${clips.length - failures.length}/${clips.length} clip(s) captured; manifest updated.`); if (failures.length) console.log(`Failed: ${failures.map((f) => f.id).join(", ")}`); } function roleRank(role) { return { public: 0, owner: 1, agentCody: 2, subCarlos: 3 }[role] ?? 9; } main().catch((e) => { console.error(e); process.exit(1); });