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:
Satyam Rastogi
2026-05-30 20:33:02 +05:30
parent 48f6d8d094
commit 8602c70eea
20 changed files with 1022 additions and 10 deletions
@@ -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);
});
@@ -0,0 +1,21 @@
import { readFile, access } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..");
const manifest = JSON.parse(await readFile(join(ROOT, "src/clips.manifest.json"), "utf8"));
const missing = [];
for (const c of manifest.clips) {
try {
await access(join(ROOT, "public", c.file));
} catch {
missing.push(c.file);
}
}
if (missing.length) {
console.error(`Missing ${missing.length} clip file(s):\n ${missing.join("\n ")}`);
process.exit(1);
}
console.log(`All ${manifest.clips.length} clip files present.`);
@@ -0,0 +1,91 @@
// Eased cursor motion + an injected on-screen pointer (recorded video has no OS cursor).
export const easeInOutCubic = (t) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
export function easeSteps(from, to, steps) {
const pts = [];
for (let i = 1; i <= steps; i++) {
const t = easeInOutCubic(i / steps);
pts.push({
x: Math.round(from.x + (to.x - from.x) * t),
y: Math.round(from.y + (to.y - from.y) * t),
});
}
return pts;
}
// Page-side script: appends a pointer element and exposes window.__cursor*.
// pointer-events:none so it never blocks real clicks. Injected via addInitScript.
export const CURSOR_INIT_SCRIPT = `
(() => {
if (window.__cursorEl) return;
const el = document.createElement('div');
el.id = '__film_cursor';
Object.assign(el.style, {
position: 'fixed', left: '0', top: '0', width: '22px', height: '22px',
marginLeft: '-4px', marginTop: '-4px', zIndex: '2147483647',
pointerEvents: 'none', transition: 'transform 0.04s linear',
background: 'transparent',
});
el.innerHTML =
"<svg width='22' height='22' viewBox='0 0 22 22'><path d='M2 2 L2 17 L6 13 L9 20 L12 19 L9 12 L15 12 Z' fill='white' stroke='#0B5FFF' stroke-width='1.5'/></svg>";
document.documentElement.appendChild(el);
const ring = document.createElement('div');
Object.assign(ring.style, {
position: 'fixed', left: '0', top: '0', width: '34px', height: '34px',
marginLeft: '-17px', marginTop: '-17px', borderRadius: '50%',
border: '2px solid rgba(11,95,255,0.9)', zIndex: '2147483646',
pointerEvents: 'none', opacity: '0', transform: 'scale(0.4)',
transition: 'opacity 0.3s, transform 0.3s',
});
document.documentElement.appendChild(ring);
window.__cursorEl = el; window.__cursorRing = ring;
window.__cursorMove = (x, y) => {
el.style.transform = 'translate(' + x + 'px,' + y + 'px)';
ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)';
};
window.__cursorPulse = (x, y) => {
ring.style.transition = 'none';
ring.style.opacity = '0.9';
ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)';
requestAnimationFrame(() => {
ring.style.transition = 'opacity 0.4s, transform 0.4s';
ring.style.opacity = '0';
ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(1.4)';
});
};
})();
`;
// Node-side driver bound to a Playwright page. Moves the on-screen cursor in eased
// steps, then fires the REAL mouse event at the same point so the app responds.
export function makeCursor(page) {
let pos = { x: 960, y: 540 };
const apply = async (x, y) => {
await page.evaluate(([px, py]) => window.__cursorMove?.(px, py), [x, y]);
await page.mouse.move(x, y);
};
return {
async moveTo(x, y, ms = 600) {
const steps = Math.max(8, Math.round(ms / 16));
for (const p of easeSteps(pos, { x, y }, steps)) {
await apply(p.x, p.y);
await page.waitForTimeout(ms / steps);
}
pos = { x, y };
},
async click(x, y, ms = 600) {
if (x != null) await this.moveTo(x, y, ms);
await page.evaluate(([px, py]) => window.__cursorPulse?.(px, py), [pos.x, pos.y]);
await page.mouse.click(pos.x, pos.y);
},
async clickLocator(locator, ms = 600) {
const box = await locator.boundingBox();
if (!box) throw new Error("clickLocator: element not visible");
await this.click(box.x + box.width / 2, box.y + box.height / 2, ms);
},
get position() {
return pos;
},
};
}
@@ -0,0 +1,17 @@
import { describe, it, expect } from "vitest";
import { easeInOutCubic, easeSteps } from "./cursor.js";
describe("cursor easing", () => {
it("eases 0→1 monotonically with fixed endpoints", () => {
expect(easeInOutCubic(0)).toBe(0);
expect(easeInOutCubic(1)).toBe(1);
expect(easeInOutCubic(0.25)).toBeLessThan(0.25); // slow start
expect(easeInOutCubic(0.75)).toBeGreaterThan(0.75); // fast then settle
});
it("produces N integer steps ending exactly at target", () => {
const pts = easeSteps({ x: 0, y: 0 }, { x: 100, y: 40 }, 10);
expect(pts).toHaveLength(10);
expect(pts[pts.length - 1]).toEqual({ x: 100, y: 40 });
expect(pts.every((p) => Number.isInteger(p.x) && Number.isInteger(p.y))).toBe(true);
});
});
@@ -0,0 +1,57 @@
import { spawn } from "node:child_process";
import { mkdir, rm, readdir, rename } from "node:fs/promises";
import { join, dirname } from "node:path";
import ffmpegPath from "ffmpeg-static";
export const secondsToFrames = (seconds, fps) => Math.max(1, Math.round(seconds * fps));
export function parseFfprobeSeconds(stdout) {
const n = parseFloat(String(stdout).trim());
if (!Number.isFinite(n) || n <= 0) throw new Error(`bad ffprobe duration: ${stdout}`);
return n;
}
function run(bin, args) {
return new Promise((resolve, reject) => {
const p = spawn(bin, args);
let out = "", err = "";
p.stdout.on("data", (d) => (out += d));
p.stderr.on("data", (d) => (err += d));
p.on("close", (code) => (code === 0 ? resolve(out) : reject(new Error(err || `exit ${code}`))));
});
}
// ffmpeg-static bundles ffmpeg; ffprobe is not included, so we read duration from
// ffmpeg's own output (-i prints "Duration: HH:MM:SS.ss"). Robust + dependency-free.
export async function probeSeconds(file) {
let stderr = "";
try {
await run(ffmpegPath, ["-i", file]);
} catch (e) {
stderr = e.message;
}
const m = stderr.match(/Duration:\s*(\d+):(\d+):(\d+\.\d+)/);
if (!m) throw new Error(`could not read duration of ${file}`);
return (+m[1]) * 3600 + (+m[2]) * 60 + parseFloat(m[3]);
}
// Convert a recorded .webm to a constant-30fps .mp4 (h.264, yuv420p for broad support).
export async function webmToMp4(webm, mp4, fps = 30) {
await mkdir(dirname(mp4), { recursive: true });
await run(ffmpegPath, [
"-y", "-i", webm,
"-r", String(fps), "-vsync", "cfr",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium",
"-an", mp4,
]);
}
// Playwright writes recordVideo files with random names; grab the newest .webm in dir.
export async function newestWebm(dir) {
const files = (await readdir(dir)).filter((f) => f.endsWith(".webm"));
if (!files.length) throw new Error(`no .webm produced in ${dir}`);
files.sort();
return join(dir, files[files.length - 1]);
}
export { rm, rename };
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { secondsToFrames, parseFfprobeSeconds } from "./record.js";
describe("record helpers", () => {
it("converts seconds to frames at 30fps (min 1)", () => {
expect(secondsToFrames(2, 30)).toBe(60);
expect(secondsToFrames(1.017, 30)).toBe(31);
expect(secondsToFrames(0, 30)).toBe(1);
});
it("parses ffprobe duration output", () => {
expect(parseFfprobeSeconds("55.033000\n")).toBeCloseTo(55.033, 3);
});
it("throws on bad ffprobe output", () => {
expect(() => parseFfprobeSeconds("N/A")).toThrow();
});
});
+27
View File
@@ -0,0 +1,27 @@
// Shared auth + in-SPA navigation. Auth is in-memory only, so we NEVER page.goto
// between authed screens — we push history + dispatch popstate (React Router listens).
export const ROLE_LOGIN = {
owner: { tab: "Owner", id: "justin" },
agentCody: { tab: "Employee", id: "LUP-1040" },
subCarlos: { tab: "Sub-Con", id: "carlos" },
};
export async function login(page, baseUrl, role) {
const creds = ROLE_LOGIN[role];
if (!creds) throw new Error(`unknown role ${role}`);
await page.goto(`${baseUrl}/login`, { waitUntil: "networkidle" });
await page.getByRole("button", { name: creds.tab, exact: true }).first().click();
await page.fill("#identifier", creds.id);
await page.fill("#password", "password");
await page.getByRole("button", { name: "Sign In" }).click();
await page.waitForFunction(() => !location.pathname.startsWith("/login"));
await page.waitForLoadState("networkidle");
}
export async function spaNavigate(page, route) {
await page.evaluate((r) => {
window.history.pushState({}, "", r);
window.dispatchEvent(new PopStateEvent("popstate"));
}, route);
await page.waitForFunction((r) => location.pathname === r, route);
}
@@ -0,0 +1,30 @@
// Storm Intel: drop a pin, wait for the REAL storm-history fetch, reveal the panel.
export default async (page, cursor, ctx) => {
// Map tiles stream in — let them settle before interacting.
await page.waitForTimeout(2500);
// Enable pin-drop mode if there's a toggle (button text "Pin" / aria), else skip.
const pinToggle = page.getByRole("button", { name: /pin/i }).first();
if (await pinToggle.count()) {
await cursor.clickLocator(pinToggle);
await page.waitForTimeout(400);
}
// Click a point on the map (center-ish). The map container fills the main area.
const map = page.locator(".leaflet-container").first();
const box = await map.boundingBox();
const target = { x: box.x + box.width * 0.5, y: box.y + box.height * 0.5 };
// Drive the real storm-history fetch and wait for it, so the panel fill is on camera.
const waitFetch = page
.waitForResponse((r) => /\/api\/storm-history/.test(r.url()), { timeout: 15000 })
.catch(() => null); // tolerate cached/no-fetch; the pin still renders
await cursor.click(target.x, target.y, 800);
await waitFetch;
await page.waitForTimeout(2500); // hold on the populated panel
// Pan the cursor to the populated history panel for emphasis.
const panel = page.getByText(/storm history/i).first();
if (await panel.count()) await cursor.clickLocator(panel, 700);
await page.waitForTimeout(1500);
};