feat(film): live-interaction product film foundation
Capture pipeline (Playwright recordVideo + injected synthetic cursor + ffmpeg webm->mp4) and a Remotion LiveFilm composition driven by a 20-clip manifest with caption/zoom overlays and a music bed. Includes the capture orchestrator, storm-intel reference scene, pre-render smoke check, and VO script. Pure logic is unit-tested (28 passing). Remaining scene scripts are authored during live capture.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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 = "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));
|
||||
|
||||
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}`);
|
||||
await rm(tmpVideoDir, { recursive: true, force: true });
|
||||
const context = await browser.newContext({
|
||||
viewport: VIEWPORT,
|
||||
deviceScaleFactor: 1, // recordVideo records CSS px; keep 1 for exact 1080p
|
||||
recordVideo: { dir: tmpVideoDir, size: VIEWPORT },
|
||||
});
|
||||
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);
|
||||
|
||||
console.log(`▶ recording ${clip.id} (${clip.role}) ...`);
|
||||
await scene(page, cursor, {
|
||||
baseUrl: BASE_URL,
|
||||
spaNavigate,
|
||||
login: (role) => login(page, BASE_URL, role),
|
||||
role: clip.role,
|
||||
waitSettle: (ms = 1500) => page.waitForTimeout(ms),
|
||||
});
|
||||
|
||||
await context.close(); // flush the .webm
|
||||
const webm = await newestWebm(tmpVideoDir);
|
||||
const mp4 = join(outDir, `${clip.id}.mp4`);
|
||||
await webmToMp4(webm, mp4, FPS);
|
||||
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)`);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
// Persist updated durations.
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
||||
console.log(`\nDone. Captured ${clips.length} clip(s); manifest durations updated.`);
|
||||
}
|
||||
|
||||
function roleRank(role) {
|
||||
return { public: 0, owner: 1, agentCody: 2, subCarlos: 3 }[role] ?? 9;
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user