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,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);
}