diff --git a/marketing/product-film/.gitignore b/marketing/product-film/.gitignore index 4bb5f78..2d756b2 100644 --- a/marketing/product-film/.gitignore +++ b/marketing/product-film/.gitignore @@ -1,4 +1,5 @@ node_modules/ out/ public/captures/ +public/clips/ .remotion/ diff --git a/marketing/product-film/package-lock.json b/marketing/product-film/package-lock.json index 1c557b7..d0cea6c 100644 --- a/marketing/product-film/package-lock.json +++ b/marketing/product-film/package-lock.json @@ -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", diff --git a/marketing/product-film/package.json b/marketing/product-film/package.json index bef8a9c..ee9eb7b 100644 --- a/marketing/product-film/package.json +++ b/marketing/product-film/package.json @@ -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" diff --git a/marketing/product-film/scripts/capture-video.mjs b/marketing/product-film/scripts/capture-video.mjs new file mode 100644 index 0000000..fce0571 --- /dev/null +++ b/marketing/product-film/scripts/capture-video.mjs @@ -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); +}); diff --git a/marketing/product-film/scripts/check-clips.mjs b/marketing/product-film/scripts/check-clips.mjs new file mode 100644 index 0000000..46a86e4 --- /dev/null +++ b/marketing/product-film/scripts/check-clips.mjs @@ -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.`); diff --git a/marketing/product-film/scripts/lib/cursor.js b/marketing/product-film/scripts/lib/cursor.js new file mode 100644 index 0000000..6575dfd --- /dev/null +++ b/marketing/product-film/scripts/lib/cursor.js @@ -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 = + ""; + 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; + }, + }; +} diff --git a/marketing/product-film/scripts/lib/cursor.test.js b/marketing/product-film/scripts/lib/cursor.test.js new file mode 100644 index 0000000..8546418 --- /dev/null +++ b/marketing/product-film/scripts/lib/cursor.test.js @@ -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); + }); +}); diff --git a/marketing/product-film/scripts/lib/record.js b/marketing/product-film/scripts/lib/record.js new file mode 100644 index 0000000..0f80a22 --- /dev/null +++ b/marketing/product-film/scripts/lib/record.js @@ -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 }; diff --git a/marketing/product-film/scripts/lib/record.test.js b/marketing/product-film/scripts/lib/record.test.js new file mode 100644 index 0000000..f88c720 --- /dev/null +++ b/marketing/product-film/scripts/lib/record.test.js @@ -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(); + }); +}); diff --git a/marketing/product-film/scripts/lib/spa.js b/marketing/product-film/scripts/lib/spa.js new file mode 100644 index 0000000..1d6edba --- /dev/null +++ b/marketing/product-film/scripts/lib/spa.js @@ -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); +} diff --git a/marketing/product-film/scripts/scenes/storm-intel.mjs b/marketing/product-film/scripts/scenes/storm-intel.mjs new file mode 100644 index 0000000..c1e8776 --- /dev/null +++ b/marketing/product-film/scripts/scenes/storm-intel.mjs @@ -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); +}; diff --git a/marketing/product-film/src/LiveFilm.tsx b/marketing/product-film/src/LiveFilm.tsx new file mode 100644 index 0000000..e17dae1 --- /dev/null +++ b/marketing/product-film/src/LiveFilm.tsx @@ -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 ( + + + + + + + + + + {rest.map((clip) => ( + + + + ))} + + + ); +}; diff --git a/marketing/product-film/src/Root.tsx b/marketing/product-film/src/Root.tsx index 48b76be..4d172c7 100644 --- a/marketing/product-film/src/Root.tsx +++ b/marketing/product-film/src/Root.tsx @@ -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 ( - + <> + + + ); }; diff --git a/marketing/product-film/src/clipStage.test.ts b/marketing/product-film/src/clipStage.test.ts new file mode 100644 index 0000000..cb69001 --- /dev/null +++ b/marketing/product-film/src/clipStage.test.ts @@ -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); + }); +}); diff --git a/marketing/product-film/src/clipStage.ts b/marketing/product-film/src/clipStage.ts new file mode 100644 index 0000000..6f099fe --- /dev/null +++ b/marketing/product-film/src/clipStage.ts @@ -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; +} diff --git a/marketing/product-film/src/clips.manifest.json b/marketing/product-film/src/clips.manifest.json new file mode 100644 index 0000000..85a2ba2 --- /dev/null +++ b/marketing/product-film/src/clips.manifest.json @@ -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." }] + } + ] +} diff --git a/marketing/product-film/src/clips.test.ts b/marketing/product-film/src/clips.test.ts new file mode 100644 index 0000000..1e0f6f5 --- /dev/null +++ b/marketing/product-film/src/clips.test.ts @@ -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"); + }); +}); diff --git a/marketing/product-film/src/clips.ts b/marketing/product-film/src/clips.ts new file mode 100644 index 0000000..178a607 --- /dev/null +++ b/marketing/product-film/src/clips.ts @@ -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(); + 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); +} diff --git a/marketing/product-film/src/components/AudioTrack.tsx b/marketing/product-film/src/components/AudioTrack.tsx index 19a9926..0ee40dc 100644 --- a/marketing/product-film/src/components/AudioTrack.tsx +++ b/marketing/product-film/src/components/AudioTrack.tsx @@ -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" } ); diff --git a/marketing/product-film/src/components/ClipStage.tsx b/marketing/product-film/src/components/ClipStage.tsx new file mode 100644 index 0000000..e63491e --- /dev/null +++ b/marketing/product-film/src/components/ClipStage.tsx @@ -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 ( + + + + + {caption ? ( + + ) : null} + + ); +};