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:
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
out/
|
||||
public/captures/
|
||||
public/clips/
|
||||
.remotion/
|
||||
|
||||
Generated
+172
@@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.286",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"remotion": "4.0.286"
|
||||
@@ -32,6 +33,21 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@derhuerst/http-basic": {
|
||||
"version": "8.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caseless": "^0.12.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"http-response-object": "^3.0.1",
|
||||
"parse-cache-control": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
|
||||
@@ -1471,6 +1487,18 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -1658,6 +1686,12 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
@@ -1700,6 +1734,21 @@
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1843,6 +1892,15 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
@@ -2078,6 +2136,22 @@
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.2.0.tgz",
|
||||
"integrity": "sha512-WrM7kLW+do9HLr+H6tk7LzQ7kPqbAgLjdzNE32+u3Ff11gXt9Kkkd2nusGFrlWMIe+XaA97t+I8JS7sZIrvRgA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@derhuerst/http-basic": "^8.2.0",
|
||||
"env-paths": "^2.2.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"progress": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-monkey": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz",
|
||||
@@ -2132,6 +2206,34 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^10.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object/node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/human-signals": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||
@@ -2153,6 +2255,12 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||
@@ -2453,6 +2561,11 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-cache-control": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -2630,6 +2743,15 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -2694,6 +2816,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/recast": {
|
||||
"version": "0.23.9",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.9.tgz",
|
||||
@@ -2790,6 +2926,26 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||
@@ -2920,6 +3076,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-final-newline": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
|
||||
@@ -3170,6 +3335,12 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
|
||||
@@ -3241,6 +3412,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
"scripts": {
|
||||
"dev": "remotion studio",
|
||||
"render": "remotion render LynkedUpProFilm out/lynkedup-pro-film.mp4",
|
||||
"render:live": "node scripts/check-clips.mjs && remotion render LynkedUpProLiveFilm out/lynkedup-pro-live-film.mp4",
|
||||
"still": "remotion still LynkedUpProFilm out/still.png",
|
||||
"capture": "node scripts/capture.mjs",
|
||||
"capture:video": "node scripts/capture-video.mjs",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.286",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"remotion": "4.0.286"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Series, AbsoluteFill } from "remotion";
|
||||
import manifest from "./clips.manifest.json";
|
||||
import { totalLiveFrames, type ClipsManifest } from "./clips";
|
||||
import { DURATIONS } from "./timing";
|
||||
import { ClipStage } from "./components/ClipStage";
|
||||
import { AudioTrack } from "./components/AudioTrack";
|
||||
import { Promise as PromiseSeq } from "./sequences/Promise";
|
||||
|
||||
const m = manifest as ClipsManifest;
|
||||
export const LIVE_TOTAL = totalLiveFrames(m) + DURATIONS.promise;
|
||||
|
||||
export const LiveFilm: React.FC = () => {
|
||||
const [hero, ...rest] = m.clips; // landing-hero is first in the manifest
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<AudioTrack totalFrames={LIVE_TOTAL} />
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={hero.durationFrames}>
|
||||
<ClipStage clip={hero} />
|
||||
</Series.Sequence>
|
||||
<Series.Sequence durationInFrames={DURATIONS.promise}>
|
||||
<PromiseSeq />
|
||||
</Series.Sequence>
|
||||
{rest.map((clip) => (
|
||||
<Series.Sequence key={clip.id} durationInFrames={clip.durationFrames}>
|
||||
<ClipStage clip={clip} />
|
||||
</Series.Sequence>
|
||||
))}
|
||||
</Series>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,27 @@
|
||||
import { Composition } from "remotion";
|
||||
import { Film } from "./Film";
|
||||
import { LiveFilm, LIVE_TOTAL } from "./LiveFilm";
|
||||
import { TOTAL_FRAMES, FPS, WIDTH, HEIGHT } from "./timing";
|
||||
|
||||
export const Root: React.FC = () => {
|
||||
return (
|
||||
<Composition
|
||||
id="LynkedUpProFilm"
|
||||
component={Film}
|
||||
durationInFrames={TOTAL_FRAMES}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
<>
|
||||
<Composition
|
||||
id="LynkedUpProLiveFilm"
|
||||
component={LiveFilm}
|
||||
durationInFrames={LIVE_TOTAL}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
<Composition
|
||||
id="LynkedUpProFilm"
|
||||
component={Film}
|
||||
durationInFrames={TOTAL_FRAMES}
|
||||
fps={FPS}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { captionAt, zoomAt } from "./clipStage";
|
||||
|
||||
const caps = [
|
||||
{ fromFrame: 0, toFrame: 30, text: "A" },
|
||||
{ fromFrame: 30, toFrame: 60, text: "B" },
|
||||
];
|
||||
|
||||
describe("clipStage helpers", () => {
|
||||
it("returns the active caption for a frame (inclusive start, exclusive end)", () => {
|
||||
expect(captionAt(caps, 0)?.text).toBe("A");
|
||||
expect(captionAt(caps, 29)?.text).toBe("A");
|
||||
expect(captionAt(caps, 30)?.text).toBe("B");
|
||||
expect(captionAt(caps, 60)).toBeNull();
|
||||
});
|
||||
it("ramps zoom toward the nearest key moment and back to 1 away from it", () => {
|
||||
const km = [{ frame: 100, zoom: 1.1 }];
|
||||
expect(zoomAt(km, 100)).toBeCloseTo(1.1, 5);
|
||||
expect(zoomAt(km, 0)).toBeCloseTo(1.0, 2);
|
||||
expect(zoomAt([], 50)).toBe(1);
|
||||
expect(zoomAt(km, 70)).toBeGreaterThan(1); // ramping in
|
||||
expect(zoomAt(km, 70)).toBeLessThan(1.1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CaptionCue, KeyMoment } from "./clips";
|
||||
|
||||
export function captionAt(captions: CaptionCue[], frame: number): CaptionCue | null {
|
||||
for (const c of captions) {
|
||||
if (frame >= c.fromFrame && frame < c.toFrame) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Triangular ramp into the nearest key moment over a 60-frame window on each side.
|
||||
const RAMP = 60;
|
||||
export function zoomAt(keyMoments: KeyMoment[] | undefined, frame: number): number {
|
||||
if (!keyMoments || keyMoments.length === 0) return 1;
|
||||
let best = 1;
|
||||
for (const km of keyMoments) {
|
||||
const dist = Math.abs(frame - km.frame);
|
||||
if (dist > RAMP) continue;
|
||||
const t = 1 - dist / RAMP; // 1 at the moment, 0 at the edge
|
||||
const z = 1 + (km.zoom - 1) * t;
|
||||
if (z > best) best = z;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
{
|
||||
"fps": 30,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"clips": [
|
||||
{
|
||||
"id": "landing-hero",
|
||||
"file": "clips/landing-hero.mp4",
|
||||
"role": "public",
|
||||
"route": "/",
|
||||
"act": "Open",
|
||||
"durationFrames": 360,
|
||||
"keyMoments": [{ "frame": 110, "zoom": 1.07 }],
|
||||
"captions": [{ "fromFrame": 45, "toFrame": 300, "text": "The operating system for modern roofing." }]
|
||||
},
|
||||
{
|
||||
"id": "storm-intel",
|
||||
"file": "clips/storm-intel.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/storm-intel",
|
||||
"act": "Win the work",
|
||||
"pillarTitle": "Storm Intelligence",
|
||||
"durationFrames": 1650,
|
||||
"keyMoments": [{ "frame": 495, "zoom": 1.07 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Drop a pin and see every hail event in the territory." }]
|
||||
},
|
||||
{
|
||||
"id": "territory-map",
|
||||
"file": "clips/territory-map.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/maps",
|
||||
"act": "Win the work",
|
||||
"pillarTitle": "Territory Command",
|
||||
"durationFrames": 1500,
|
||||
"keyMoments": [{ "frame": 450, "zoom": 1.06 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Map your turf, work the streets that pay." }]
|
||||
},
|
||||
{
|
||||
"id": "lead-create",
|
||||
"file": "clips/lead-create.mp4",
|
||||
"role": "agentCody",
|
||||
"route": "/emp/fa/leads/new",
|
||||
"act": "Win the work",
|
||||
"pillarTitle": "Lead Creation",
|
||||
"durationFrames": 1800,
|
||||
"keyMoments": [{ "frame": 540, "zoom": 1.06 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Capture a lead from the doorstep in seconds." }]
|
||||
},
|
||||
{
|
||||
"id": "lead-verify",
|
||||
"file": "clips/lead-verify.mp4",
|
||||
"role": "owner",
|
||||
"route": "/lead-verification",
|
||||
"act": "Win the work",
|
||||
"pillarTitle": "Lead Verification",
|
||||
"durationFrames": 900,
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 420, "text": "Verify the address before you roll a truck." }]
|
||||
},
|
||||
{
|
||||
"id": "dispatch",
|
||||
"file": "clips/dispatch.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/dispatch",
|
||||
"act": "Run the operation",
|
||||
"pillarTitle": "LynkDispatch",
|
||||
"durationFrames": 1650,
|
||||
"keyMoments": [{ "frame": 495, "zoom": 1.07 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Dispatch the right crew to the right roof." }]
|
||||
},
|
||||
{
|
||||
"id": "kanban",
|
||||
"file": "clips/kanban.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/kanban",
|
||||
"act": "Run the operation",
|
||||
"pillarTitle": "Pipeline in Motion",
|
||||
"durationFrames": 1500,
|
||||
"keyMoments": [{ "frame": 450, "zoom": 1.06 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Drag every job from lead to paid." }]
|
||||
},
|
||||
{
|
||||
"id": "sub-tasks",
|
||||
"file": "clips/sub-tasks.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/subcontractor-tasks",
|
||||
"act": "Run the operation",
|
||||
"pillarTitle": "Subcontractor Tasks",
|
||||
"durationFrames": 1050,
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 480, "text": "Hand off the punch list to your subs." }]
|
||||
},
|
||||
{
|
||||
"id": "pro-canvas",
|
||||
"file": "clips/pro-canvas.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/pro-canvas",
|
||||
"act": "Estimate & sell",
|
||||
"pillarTitle": "Pro-Canvas",
|
||||
"durationFrames": 1800,
|
||||
"keyMoments": [{ "frame": 540, "zoom": 1.07 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Build the proposal your homeowner signs on the spot." }]
|
||||
},
|
||||
{
|
||||
"id": "estimates",
|
||||
"file": "clips/estimates.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/estimates",
|
||||
"act": "Estimate & sell",
|
||||
"pillarTitle": "Estimates",
|
||||
"durationFrames": 1050,
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 480, "text": "Price the job with margins that hold." }]
|
||||
},
|
||||
{
|
||||
"id": "owner-snapshot",
|
||||
"file": "clips/owner-snapshot.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/snapshot",
|
||||
"act": "See the business",
|
||||
"pillarTitle": "Owner Snapshot",
|
||||
"durationFrames": 1650,
|
||||
"keyMoments": [{ "frame": 495, "zoom": 1.06 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Your whole company on one screen." }]
|
||||
},
|
||||
{
|
||||
"id": "operator-dashboard",
|
||||
"file": "clips/operator-dashboard.mp4",
|
||||
"role": "agentCody",
|
||||
"route": "/emp/fa/dashboard",
|
||||
"act": "See the business",
|
||||
"pillarTitle": "Operator Dashboard",
|
||||
"durationFrames": 900,
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 420, "text": "Every rep knows their numbers by 8am." }]
|
||||
},
|
||||
{
|
||||
"id": "people-skills",
|
||||
"file": "clips/people-skills.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/people",
|
||||
"act": "Manage the team",
|
||||
"pillarTitle": "People & Skills",
|
||||
"durationFrames": 1500,
|
||||
"keyMoments": [{ "frame": 450, "zoom": 1.06 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Track crews, skills and certifications in one place." }]
|
||||
},
|
||||
{
|
||||
"id": "project-detail",
|
||||
"file": "clips/project-detail.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/projects",
|
||||
"act": "Manage the team",
|
||||
"pillarTitle": "Project Details",
|
||||
"durationFrames": 1650,
|
||||
"keyMoments": [{ "frame": 495, "zoom": 1.06 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Every photo, doc and update on the job." }]
|
||||
},
|
||||
{
|
||||
"id": "documents",
|
||||
"file": "clips/documents.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/documents",
|
||||
"act": "Manage the team",
|
||||
"pillarTitle": "Documents",
|
||||
"durationFrames": 750,
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 420, "text": "Contracts and warranties, signed and stored." }]
|
||||
},
|
||||
{
|
||||
"id": "org-access",
|
||||
"file": "clips/org-access.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/settings",
|
||||
"act": "Control & trust",
|
||||
"pillarTitle": "Access Control",
|
||||
"durationFrames": 1650,
|
||||
"keyMoments": [{ "frame": 495, "zoom": 1.06 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 540, "text": "Give every role exactly the access they need." }]
|
||||
},
|
||||
{
|
||||
"id": "role-perspective",
|
||||
"file": "clips/role-perspective.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/snapshot",
|
||||
"act": "Control & trust",
|
||||
"pillarTitle": "Role Perspective",
|
||||
"durationFrames": 1050,
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 480, "text": "See the app the way your team sees it." }]
|
||||
},
|
||||
{
|
||||
"id": "ai-assistant",
|
||||
"file": "clips/ai-assistant.mp4",
|
||||
"role": "owner",
|
||||
"route": "/chat-assistant",
|
||||
"act": "Intelligence",
|
||||
"pillarTitle": "AI Assistant",
|
||||
"durationFrames": 1050,
|
||||
"keyMoments": [{ "frame": 315, "zoom": 1.07 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 480, "text": "Ask anything about your business, get an answer." }]
|
||||
},
|
||||
{
|
||||
"id": "breadth-flash",
|
||||
"file": "clips/breadth-flash.mp4",
|
||||
"role": "owner",
|
||||
"route": "/owner/snapshot",
|
||||
"act": "Close",
|
||||
"durationFrames": 540,
|
||||
"captions": [{ "fromFrame": 30, "toFrame": 480, "text": "One platform. Every part of the job." }]
|
||||
},
|
||||
{
|
||||
"id": "crane-close",
|
||||
"file": "clips/crane-close.mp4",
|
||||
"role": "public",
|
||||
"route": "/",
|
||||
"act": "Close",
|
||||
"durationFrames": 660,
|
||||
"keyMoments": [{ "frame": 200, "zoom": 1.08 }],
|
||||
"captions": [{ "fromFrame": 60, "toFrame": 600, "text": "LynkedUp Pro. Build the roof, run the company." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
validateClipsManifest,
|
||||
totalLiveFrames,
|
||||
type ClipsManifest,
|
||||
} from "./clips";
|
||||
|
||||
const base: ClipsManifest = {
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
clips: [
|
||||
{
|
||||
id: "storm-intel",
|
||||
file: "clips/storm-intel.mp4",
|
||||
role: "owner",
|
||||
route: "/owner/storm-intel",
|
||||
act: "Win the work",
|
||||
pillarTitle: "Storm Intelligence",
|
||||
durationFrames: 1650,
|
||||
captions: [{ fromFrame: 60, toFrame: 240, text: "Drop a pin anywhere." }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("clips manifest validator", () => {
|
||||
it("accepts a valid manifest", () => {
|
||||
expect(validateClipsManifest(base)).toEqual([]);
|
||||
});
|
||||
it("rejects wrong fps/dimensions", () => {
|
||||
const errs = validateClipsManifest({ ...base, fps: 24, width: 1280 });
|
||||
expect(errs).toContain("fps must be 30");
|
||||
expect(errs).toContain("dimensions must be 1920x1080");
|
||||
});
|
||||
it("rejects duplicate ids", () => {
|
||||
const errs = validateClipsManifest({ ...base, clips: [base.clips[0], base.clips[0]] });
|
||||
expect(errs.some((e) => e.includes("duplicate id"))).toBe(true);
|
||||
});
|
||||
it("rejects bad file path and route", () => {
|
||||
const errs = validateClipsManifest({
|
||||
...base,
|
||||
clips: [{ ...base.clips[0], file: "storm.webm", route: "owner/x" }],
|
||||
});
|
||||
expect(errs.some((e) => e.includes("file must be clips/*.mp4"))).toBe(true);
|
||||
expect(errs.some((e) => e.includes('route must start with "/"'))).toBe(true);
|
||||
});
|
||||
it("rejects caption outside clip duration", () => {
|
||||
const errs = validateClipsManifest({
|
||||
...base,
|
||||
clips: [{ ...base.clips[0], captions: [{ fromFrame: 10, toFrame: 99999, text: "x" }] }],
|
||||
});
|
||||
expect(errs.some((e) => e.includes("caption out of range"))).toBe(true);
|
||||
});
|
||||
it("sums total frames", () => {
|
||||
expect(totalLiveFrames(base)).toBe(1650);
|
||||
});
|
||||
it("rejects an empty clips array", () => {
|
||||
expect(validateClipsManifest({ ...base, clips: [] })).toContain("manifest must have at least one clip");
|
||||
});
|
||||
it("rejects a clip missing its act", () => {
|
||||
const errs = validateClipsManifest({ ...base, clips: [{ ...base.clips[0], act: "" }] });
|
||||
expect(errs.some((e) => e.includes("missing act"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
import manifest from "./clips.manifest.json";
|
||||
|
||||
describe("seeded clips.manifest.json", () => {
|
||||
it("passes validation", () => {
|
||||
expect(validateClipsManifest(manifest as ClipsManifest)).toEqual([]);
|
||||
});
|
||||
it("has exactly 20 video clips with unique ids", () => {
|
||||
const m = manifest as ClipsManifest;
|
||||
expect(m.clips.length).toBe(20);
|
||||
expect(new Set(m.clips.map((c) => c.id)).size).toBe(20);
|
||||
});
|
||||
it("opens with landing-hero and ends with crane-close", () => {
|
||||
const m = manifest as ClipsManifest;
|
||||
expect(m.clips[0].id).toBe("landing-hero");
|
||||
expect(m.clips[m.clips.length - 1].id).toBe("crane-close");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
export type ClipRole = "owner" | "agentCody" | "subCarlos" | "public";
|
||||
|
||||
export interface CaptionCue {
|
||||
fromFrame: number;
|
||||
toFrame: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface KeyMoment {
|
||||
frame: number;
|
||||
zoom: number; // 1.0 = no zoom; e.g. 1.08 = subtle punch-in
|
||||
}
|
||||
|
||||
export interface Clip {
|
||||
id: string;
|
||||
file: string; // relative to public/, e.g. "clips/storm-intel.mp4"
|
||||
role: ClipRole;
|
||||
route: string;
|
||||
act: string;
|
||||
pillarTitle?: string;
|
||||
durationFrames: number; // seeded estimate; overwritten by ffprobe at capture
|
||||
keyMoments?: KeyMoment[];
|
||||
captions: CaptionCue[];
|
||||
}
|
||||
|
||||
export interface ClipsManifest {
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
clips: Clip[];
|
||||
}
|
||||
|
||||
const VALID_ROLES: ClipRole[] = ["owner", "agentCody", "subCarlos", "public"];
|
||||
|
||||
export function validateClipsManifest(m: ClipsManifest): string[] {
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
if (m.clips.length === 0) errors.push("manifest must have at least one clip");
|
||||
if (m.fps !== 30) errors.push("fps must be 30");
|
||||
if (m.width !== 1920 || m.height !== 1080) errors.push("dimensions must be 1920x1080");
|
||||
for (const c of m.clips) {
|
||||
if (!c.id) errors.push("clip missing id");
|
||||
if (seen.has(c.id)) errors.push(`duplicate id: ${c.id}`);
|
||||
seen.add(c.id);
|
||||
if (!c.file?.startsWith("clips/") || !c.file.endsWith(".mp4"))
|
||||
errors.push(`${c.id}: file must be clips/*.mp4`);
|
||||
if (!c.route?.startsWith("/")) errors.push(`${c.id}: route must start with "/"`);
|
||||
if (!c.act) errors.push(`${c.id}: missing act`);
|
||||
if (!VALID_ROLES.includes(c.role)) errors.push(`${c.id}: unknown role "${c.role}"`);
|
||||
if (!Number.isInteger(c.durationFrames) || c.durationFrames <= 0)
|
||||
errors.push(`${c.id}: durationFrames must be a positive integer`);
|
||||
for (const cap of c.captions ?? []) {
|
||||
// toFrame is exclusive: a caption visible through the final rendered frame has
|
||||
// toFrame === durationFrames (valid). Reject only ranges that exceed the clip,
|
||||
// are non-positive, or are empty/inverted.
|
||||
if (cap.fromFrame < 0 || cap.toFrame <= 0 || cap.toFrame > c.durationFrames || cap.fromFrame >= cap.toFrame)
|
||||
errors.push(`${c.id}: caption out of range [${cap.fromFrame},${cap.toFrame}] within 0..${c.durationFrames}`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function totalLiveFrames(m: ClipsManifest): number {
|
||||
return m.clips.reduce((acc, c) => acc + c.durationFrames, 0);
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import { TOTAL_FRAMES } from "../timing";
|
||||
// Flip to true after dropping a royalty-free track at public/audio/music.mp3.
|
||||
export const HAS_MUSIC = false;
|
||||
|
||||
export const AudioTrack: React.FC = () => {
|
||||
export const AudioTrack: React.FC<{ totalFrames?: number }> = ({
|
||||
totalFrames = TOTAL_FRAMES,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
if (!HAS_MUSIC) return null;
|
||||
// fade in at start, fade out over the last 2s
|
||||
const volume = interpolate(
|
||||
frame,
|
||||
[0, 30, TOTAL_FRAMES - 60, TOTAL_FRAMES],
|
||||
[0, 30, totalFrames - 60, totalFrames],
|
||||
[0, 0.7, 0.7, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { OffthreadVideo, staticFile, useCurrentFrame, AbsoluteFill } from "remotion";
|
||||
import type { Clip } from "../clips";
|
||||
import { captionAt, zoomAt } from "../clipStage";
|
||||
import { Caption } from "./Caption";
|
||||
|
||||
export const ClipStage: React.FC<{ clip: Clip }> = ({ clip }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const zoom = zoomAt(clip.keyMoments, frame);
|
||||
const caption = captionAt(clip.captions, frame);
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>
|
||||
<AbsoluteFill style={{ transform: `scale(${zoom})`, transformOrigin: "center center" }}>
|
||||
<OffthreadVideo src={staticFile(clip.file)} />
|
||||
</AbsoluteFill>
|
||||
{caption ? (
|
||||
<Caption
|
||||
text={caption.text}
|
||||
startFrame={caption.fromFrame}
|
||||
durationInFrames={caption.toFrame - caption.fromFrame}
|
||||
/>
|
||||
) : null}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user