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,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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user