# Live Product Film Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a ~15–16 min live-interaction product film by capturing the real CRM with Playwright video + an injected synthetic cursor, then stitching the clips in Remotion with captions, music, and the swinging-crane bookend. **Architecture:** Two stages. (1) **Capture** — Playwright drives the running CRM (local dev, real APIs), an injected on-screen cursor performs scripted moves/clicks, each beat is recorded to `.webm`, converted to constant-30fps `.mp4` via `ffmpeg-static`, and its real duration is written into `src/clips.manifest.json`. (2) **Compose** — a Remotion `LiveFilm` composition reads the manifest and lays out each clip via `` with caption overlays and a zoom-punch, bookended by the existing landing/crane components. Pure logic (manifest validation, cursor easing, duration math, caption/zoom mapping) is TDD'd with vitest; browser scenes are verified by running capture and asserting the produced clip. **Tech Stack:** Remotion 4.0.286 (React 19 + TypeScript), ``; Playwright 1.49.1; `ffmpeg-static`; vitest 2.1.8. All work lives under `marketing/product-film/` except the already-done AI-chat change in `src/components/Chatbot.jsx` (spec §9). **Spec:** `docs/superpowers/specs/2026-05-30-live-product-film-design.md` --- ## Conventions used throughout - **Working dir for all `npm`/`node` commands:** `marketing/product-film/`. - **CRM dev server** must be running first from the repo root: `npm run dev` → `http://localhost:5173` (this serves the real `/api` handlers via the vite dev runner). - **Login roles** (manifest `role`): `owner` (id `justin`), `agentCody` (id `LUP-1040`), `subCarlos` (id `carlos`); password `password`. Plus `public` (no login) for landing/crane. - **Viewport / video size:** 1920×1080 (recordVideo records CSS pixels → exactly 1080p). FPS 30. - **Never `page.goto` between authed screens** — auth is in-memory; navigate in-SPA via `history.pushState` + `popstate` (helper provided in Task 7). - **Commits:** author is the repo default (Satyam Rastogi); no AI attribution in messages. --- ## File structure (what each new file owns) | File | Responsibility | |---|---| | `marketing/product-film/src/clips.ts` | Clip/manifest TS types + `validateClipsManifest()` + `totalLiveFrames()` (pure, tested) | | `marketing/product-film/src/clips.manifest.json` | The capture/compose contract: 20 clips with role/route/act/captions + ffprobe durations | | `marketing/product-film/src/clips.test.ts` | vitest: manifest validity, unique ids, 20 clips, caption ranges | | `marketing/product-film/src/clipStage.ts` | Pure helpers `captionAt()` / `zoomAt()` (tested) | | `marketing/product-film/src/clipStage.test.ts` | vitest for the above | | `marketing/product-film/src/components/ClipStage.tsx` | Renders one clip: `` + caption overlay + zoom-punch | | `marketing/product-film/src/LiveFilm.tsx` | `` over manifest clips + landing/crane bookends + act titles | | `marketing/product-film/scripts/lib/cursor.js` | Injected cursor page-script + Node easing helpers (`easeSteps` tested) | | `marketing/product-film/scripts/lib/cursor.test.js` | vitest for easing math | | `marketing/product-film/scripts/lib/record.js` | recordVideo context + webm→mp4 + ffprobe; pure `secondsToFrames`/`parseFfprobeSeconds` tested | | `marketing/product-film/scripts/lib/record.test.js` | vitest for the pure parts | | `marketing/product-film/scripts/lib/spa.js` | `login(page, role)` + `spaNavigate(page, route)` shared by orchestrator/scenes | | `marketing/product-film/scripts/scenes/.mjs` | 20 scene scripts — one per manifest clip (`export default async (page, cursor, ctx) => {}`) | | `marketing/product-film/scripts/capture-video.mjs` | Orchestrator: per-role login, run scenes, record, convert, write durations | | `marketing/product-film/VO-SCRIPT.md` | Per-segment narration for voicing later | **Modified:** `marketing/product-film/src/Root.tsx` (register `LiveFilm` as default), `marketing/product-film/package.json` (deps + scripts). --- ## Task 1: Project deps & scripts **Files:** - Modify: `marketing/product-film/package.json` - [ ] **Step 1: Add `ffmpeg-static` dependency** Run (in `marketing/product-film/`): ```bash npm install ffmpeg-static@5.2.0 ``` Expected: `ffmpeg-static` appears under `dependencies`, install succeeds. - [ ] **Step 2: Add capture/render scripts** Edit `marketing/product-film/package.json` `scripts` to add (keep existing entries): ```json { "scripts": { "dev": "remotion studio", "render": "remotion render LynkedUpProFilm out/lynkedup-pro-film.mp4", "render:live": "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" } } ``` - [ ] **Step 3: Verify scripts parse** Run: `npm run test` Expected: existing tests still pass (vitest runs `captures.test.ts`, `timing.test.ts`, `craneSwing.test.ts`). - [ ] **Step 4: Commit** ```bash git add marketing/product-film/package.json marketing/product-film/package-lock.json git commit -m "build(film): add ffmpeg-static and capture:video/render:live scripts" ``` --- ## Task 2: Clips manifest types & validator (TDD) **Files:** - Create: `marketing/product-film/src/clips.ts` - Test: `marketing/product-film/src/clips.test.ts` - [ ] **Step 1: Write the failing test** Create `marketing/product-film/src/clips.test.ts`: ```ts 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); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- clips` Expected: FAIL — `Cannot find module './clips'`. - [ ] **Step 3: Implement `clips.ts`** Create `marketing/product-film/src/clips.ts`: ```ts 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.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 (!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 ?? []) { if (cap.fromFrame < 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); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- clips` Expected: PASS (6 tests). - [ ] **Step 5: Commit** ```bash git add marketing/product-film/src/clips.ts marketing/product-film/src/clips.test.ts git commit -m "feat(film): clips manifest types + validator" ``` --- ## Task 3: Seed the clips manifest (20 clips) (TDD) **Files:** - Create: `marketing/product-film/src/clips.manifest.json` - Modify: `marketing/product-film/src/clips.test.ts` The 20 video clips, with seeded `durationFrames = seconds × 30` (overwritten by capture later): | id | role | route | act | pillarTitle | sec | |---|---|---|---|---|---| | landing-hero | public | / | Open | — | 12 | | storm-intel | owner | /owner/storm-intel | Win the work | Storm Intelligence | 55 | | territory-map | owner | /owner/maps | Win the work | Territory Command | 50 | | lead-create | agentCody | /emp/fa/leads/new | Win the work | Lead Creation | 60 | | lead-verify | owner | /lead-verification | Win the work | Lead Verification | 30 | | dispatch | owner | /owner/dispatch | Run the operation | LynkDispatch | 55 | | kanban | owner | /owner/kanban | Run the operation | Pipeline in Motion | 50 | | sub-tasks | owner | /owner/subcontractor-tasks | Run the operation | Subcontractor Tasks | 35 | | pro-canvas | owner | /owner/pro-canvas | Estimate & sell | Pro-Canvas | 60 | | estimates | owner | /owner/estimates | Estimate & sell | Estimates | 35 | | owner-snapshot | owner | /owner/snapshot | See the business | Owner Snapshot | 55 | | operator-dashboard | agentCody | /emp/fa/dashboard | See the business | Operator Dashboard | 30 | | people-skills | owner | /owner/people | Manage the team | People & Skills | 50 | | project-detail | owner | /owner/projects | Manage the team | Project Details | 55 | | documents | owner | /owner/documents | Manage the team | Documents | 25 | | org-access | owner | /owner/settings | Control & trust | Access Control | 55 | | role-perspective | owner | /owner/snapshot | Control & trust | Role Perspective | 35 | | ai-assistant | owner | /chat-assistant | Intelligence | AI Assistant | 35 | | breadth-flash | owner | /owner/snapshot | Close | — | 18 | | crane-close | public | / | Close | — | 22 | > **Note on Promise (segment 0b):** the pain→promise beat is NOT a captured clip — it > reuses the existing native `src/sequences/Promise.tsx` title card, interleaved in > `LiveFilm` after `landing-hero` (Task 17). So the manifest holds **20 video clips**; > Promise is the one native sequence. - [ ] **Step 1: Write the failing test additions** Append to `marketing/product-film/src/clips.test.ts`: ```ts 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"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- clips` Expected: FAIL — `Cannot find module './clips.manifest.json'`. - [ ] **Step 3: Create the manifest** Create `marketing/product-film/src/clips.manifest.json` with `fps:30, width:1920, height:1080` and the 20 clips from the table above. Each clip: set `durationFrames` to `sec × 30`, `file` to `clips/.mp4`, and **at least one caption** within `[0, durationFrames)`. Example head (fill all 20 from the table, in table order): ```json { "fps": 30, "width": 1920, "height": 1080, "clips": [ { "id": "landing-hero", "file": "clips/landing-hero.mp4", "role": "public", "route": "/", "act": "Open", "durationFrames": 360, "keyMoments": [{ "frame": 120, "zoom": 1.05 }], "captions": [{ "fromFrame": 30, "toFrame": 330, "text": "The operating system for modern roofing companies." }] }, { "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": 420, "zoom": 1.08 }], "captions": [ { "fromFrame": 60, "toFrame": 360, "text": "Drop a pin anywhere in your market." }, { "fromFrame": 420, "toFrame": 900, "text": "Live NOAA + IEM storm history — instantly." } ] } ] } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- clips` Expected: PASS (9 tests total). - [ ] **Step 5: Commit** ```bash git add marketing/product-film/src/clips.manifest.json marketing/product-film/src/clips.test.ts git commit -m "feat(film): seed 20-clip manifest (capture/compose contract)" ``` --- ## Task 4: Cursor easing helpers + injected page script (TDD) **Files:** - Create: `marketing/product-film/scripts/lib/cursor.js` - Test: `marketing/product-film/scripts/lib/cursor.test.js` - [ ] **Step 1: Write the failing test** Create `marketing/product-film/scripts/lib/cursor.test.js`: ```js 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); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- cursor` Expected: FAIL — cannot find `./cursor.js`. - [ ] **Step 3: Implement `cursor.js`** Create `marketing/product-film/scripts/lib/cursor.js`: ```js // 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; }, }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- cursor` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add marketing/product-film/scripts/lib/cursor.js marketing/product-film/scripts/lib/cursor.test.js git commit -m "feat(film): synthetic cursor (eased moves + injected on-screen pointer)" ``` --- ## Task 5: Recording + ffmpeg helpers (TDD pure parts) **Files:** - Create: `marketing/product-film/scripts/lib/record.js` - Test: `marketing/product-film/scripts/lib/record.test.js` - [ ] **Step 1: Write the failing test** Create `marketing/product-film/scripts/lib/record.test.js`: ```js 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(); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- record` Expected: FAIL — cannot find `./record.js`. - [ ] **Step 3: Implement `record.js`** Create `marketing/product-film/scripts/lib/record.js`: ```js 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 }; ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- record` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add marketing/product-film/scripts/lib/record.js marketing/product-film/scripts/lib/record.test.js git commit -m "feat(film): recording + ffmpeg(webm→mp4) + duration helpers" ``` --- ## Task 6: Shared login + SPA navigation lib **Files:** - Create: `marketing/product-film/scripts/lib/spa.js` This extracts the proven login/navigation logic from the existing `scripts/capture.mjs` so scenes and the orchestrator share it. - [ ] **Step 1: Implement `spa.js`** Create `marketing/product-film/scripts/lib/spa.js`: ```js // 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); } ``` - [ ] **Step 2: Verify it imports cleanly** Run: `node --input-type=module -e "import('./marketing/product-film/scripts/lib/spa.js').then(m=>console.log(Object.keys(m)))"` Expected: prints `[ 'ROLE_LOGIN', 'login', 'spaNavigate' ]`. - [ ] **Step 3: Commit** ```bash git add marketing/product-film/scripts/lib/spa.js git commit -m "feat(film): shared login + in-SPA navigation helpers" ``` --- ## Task 7: Capture orchestrator + reference scene (`storm-intel`) **Files:** - Create: `marketing/product-film/scripts/scenes/storm-intel.mjs` - Create: `marketing/product-film/scripts/capture-video.mjs` The orchestrator owns: browser/context lifecycle, dark theme, cursor injection, login-per-role ordering (with `public` first, no login), running the requested scene(s), recording each to its own context, converting to mp4, and writing real `durationFrames` back into the manifest. Scenes own only choreography. **Scene contract:** `export default async (page, cursor, ctx) => {}` where `ctx = { baseUrl, spaNavigate, login, role, waitSettle }`. The orchestrator has already logged in (for non-public roles) and SPA-navigated to `clip.route` before calling the scene; the scene performs the interaction and any extra waits. - [ ] **Step 1: Implement the reference scene `storm-intel.mjs`** Create `marketing/product-film/scripts/scenes/storm-intel.mjs`: ```js // 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); }; ``` - [ ] **Step 2: Implement the orchestrator `capture-video.mjs`** Create `marketing/product-film/scripts/capture-video.mjs`: ```js 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)); let currentRole = null; let page = null; let context = null; 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 }); 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); 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 (only the clips we captured this run). 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); }); ``` - [ ] **Step 3: Run the reference scene end-to-end** Prereq: CRM dev server running (`npm run dev` at repo root). Run (in `marketing/product-film/`): `npm run capture:video -- --scene storm-intel` Expected: console shows `✓ storm-intel → .../public/clips/storm-intel.mp4 (NN.NNs = NNNNf)`; the file exists; `src/clips.manifest.json` `storm-intel.durationFrames` updated to the real value. - [ ] **Step 4: Verify the clip is valid 1080p30 and tests still pass** Run: `npm run test -- clips` → PASS (manifest still valid after duration update). Manually scrub `public/clips/storm-intel.mp4` — confirm the cursor moves, the pin drops, and the storm-history panel populates on camera. - [ ] **Step 5: Commit** ```bash git add marketing/product-film/scripts/capture-video.mjs marketing/product-film/scripts/scenes/storm-intel.mjs marketing/product-film/src/clips.manifest.json git commit -m "feat(film): capture orchestrator + storm-intel reference scene" ``` (Note: `public/clips/*.mp4` are gitignored — see Task 19 for `.gitignore`.) --- ## Tasks 8–13: Author the remaining interaction scenes Each scene below is its own file `marketing/product-film/scripts/scenes/.mjs` exporting `async (page, cursor, ctx) => {}`. They reuse the cursor API (`moveTo`, `click`, `clickLocator`) and `ctx`. Because selectors must match the **running DOM**, every scene task has the same shape: 1. **Pin selectors:** with the dev server running, open the route in a browser and read the real button text / roles / classes for the interactions named below (use Playwright's `getByRole`/`getByText` first; fall back to a stable class/`data-*`). 2. **Write the scene** performing the listed beats with `await page.waitForResponse(...)` for any async load. 3. **Run** `npm run capture:video -- --scene ` → clip produced, duration written. 4. **Scrub** the mp4 to confirm the cause→effect reveal reads clearly. 5. **Commit** `scripts/scenes/.mjs` + the manifest duration change. > Do NOT invent selectors in the plan — pin them from the DOM in step 1. The interaction beats below are the spec each scene must satisfy; they come from the verified inventory in the design doc. ### Task 8 — Act "Win the work" remainder + open - [ ] **landing-hero** (`public`, `/`): let hero animation play; ease cursor across the hero; subtle dwell on the headline + primary CTA. No clicks needed (it's the brand open). ~12s. - [ ] **crane-close** (`public`, `/`): scroll to the end CTA and let the **swinging-crane CTA** animation run (the signature closer the user specifically asked for); ease the cursor to the CTA button and dwell while it swings. No navigation away. ~22s. - [ ] **territory-map** (`owner`, `/owner/maps`): wait for tiles; drop a pin (or draw a 3-vertex zone, double-click to close); open the assignment control (`ZoneAssignmentModal`); pick an agent; confirm. Use `waitForResponse` if a fetch fires. ~50s. - [ ] **lead-create** (`agentCody`, `/emp/fa/leads/new`): toggle Quick/Full; **type into** first name, last name, phone, address using `page.type()` (so text appears on camera); pick a lead source from the dropdown; click Save; wait for the success toast. ~60s. - [ ] **lead-verify** (`owner`, `/lead-verification`): open a lead's verification details; step through the verify/enrich action; show the verified state. ~30s. Run/scrub/commit each per the shape above. ### Task 9 — "Run the operation" - [ ] **dispatch** (`owner`, `/owner/dispatch`): click a lead card in the queue → `LeadQuickViewModal`/recommendation drawer opens; accept the AI-recommended rep; show the log/efficiency update; toggle Storm Mode. Use `waitForResponse` for hail/assignment fetches. ~55s. - [ ] **kanban** (`owner`, `/owner/kanban`): **drag** a lead card from one stage to the next using `page.mouse.down/move/up` (drive the on-screen cursor alongside); the campaign toast appears; open the campaign edit modal and show the message. ~50s. - [ ] **sub-tasks** (`owner`, `/owner/subcontractor-tasks`): open a task → change its status via the status control; show the updated state. ~35s. ### Task 10 — "Estimate & sell" - [ ] **pro-canvas** (`owner`, `/owner/pro-canvas`): open the canvas; pick a template (`TemplateSelectionModal`); add/adjust a measurement (`MeasurementsModal`); if an XP/level-up indicator fires, dwell on it. Highest scripting care — keep moves deliberate. ~60s. - [ ] **estimates** (`owner`, `/owner/estimates`): open an estimate from the list (`EstimateDetailModal`); reveal the material/cost breakdown; scroll the totals. ~35s. ### Task 11 — "See the business" - [ ] **owner-snapshot** (`owner`, `/owner/snapshot`): click a financial KPI card → `FinancialDetailsModal`; dwell on a chart (budget/pie/funnel); close; open the action center; show commission/storm attribution panels. ~55s. - [ ] **operator-dashboard** (`agentCody`, `/emp/fa/dashboard`): let the animated revenue counter run; dwell on the live weather widget (it fetches — `waitForResponse` on the weather call if present); pan to the leaderboard. ~30s. ### Task 12 — "Manage the team" - [ ] **people-skills** (`owner`, `/owner/people`): type in the search; click a person → detail panel; click a skill score input, type a new value, click Save (skill bar updates); dwell on the masked sensitive-info row. ~50s. - [ ] **project-detail** (`owner`, `/owner/projects`): click a project row → `/owner/projects/:id`; cycle a curated set of tabs by clicking each: **Overview → Budget → Milestones → Estimates → Docs → Team** (real tab ids in `OwnerProjectDetail.jsx`: `overview,budget,changeOrders,rfis,milestones,invoices,estimates,risks,inspections,docs,activity,team`). Dwell ~3s per tab. ~55s. - [ ] **documents** (`owner`, `/owner/documents`): browse the library; open a document preview; show status badges. ~25s. ### Task 13 — "Control & trust" - [ ] **org-access** (`owner`, `/owner/settings`): open the Access Control matrix; toggle one permission cell (it records in the Change Log — open the log to show the entry); open the commission rules section and reveal a role override. ~55s. - [ ] **role-perspective** (`owner`, `/owner/snapshot`): show the owner snapshot briefly; then call `ctx.login('agentCody')` + `ctx.spaNavigate('/emp/fa/dashboard')` to show the operator's narrower view; then `ctx.login('subCarlos')` + `ctx.spaNavigate('/subcontractor/dashboard')`. Caption emphasizes "same workspace, role-scoped access." ~35s. (This scene starts as `owner`; it re-logs in within the scene.) - [ ] **breadth-flash** (`owner`, `/owner/snapshot`): a fast "everything else" montage — `ctx.spaNavigate` through ~5 screens in quick succession with a short dwell each (e.g. `/owner/vendors`, `/owner/documents`, `/owner/estimates`, `/admin/leaderboard`, `/owner/projects`) while recording. One caption ("And everything else a roofing company runs on."). This is the still-b-roll replacement, captured as one continuous video clip. ~18s. --- ## Task 14: AI Assistant scene + live answer verification **Files:** - Create: `marketing/product-film/scripts/scenes/ai-assistant.mjs` - (Depends on spec §9 — model+budget already DONE in `src/components/Chatbot.jsx`.) - [ ] **Step 1: Verify the chat answers end-to-end (live)** Prereq: dev server running with a valid `VITE_GROQ_API_KEY` in `.env`. Manually (or via the scene harness): log in as `justin`, open the assistant (route `/chat-assistant` or the floating chatbot), ask the **locked demo question**: > "Which of my projects are over budget, and what's my biggest compliance risk right now?" Confirm the answer is complete, accurate against the owner data, and free of `` noise. If it stalls/empties, adjust the demo question to one the curated context fully answers (record the final question in `VO-SCRIPT.md`). - [ ] **Step 2: Write the scene** Create `marketing/product-film/scripts/scenes/ai-assistant.mjs`: ```js // AI Assistant: ask the locked demo question, wait for the streamed answer on camera. const DEMO_Q = "Which of my projects are over budget, and what's my biggest compliance risk right now?"; export default async (page, cursor, ctx) => { // Open the assistant input (route is /chat-assistant; if a floating launcher is used, // pin its selector during Step 1 and click it here instead). const input = page.getByRole("textbox").first(); await input.waitFor({ state: "visible", timeout: 8000 }); await cursor.clickLocator(input); await input.type(DEMO_Q, { delay: 35 }); // typed on camera // Send and wait for the assistant's reply to render (Groq call). const send = page.getByRole("button", { name: /send/i }).first(); if (await send.count()) await cursor.clickLocator(send); else await page.keyboard.press("Enter"); // Wait for a new assistant message to appear, then hold while it reads. await page.waitForTimeout(1500); await page.waitForFunction( () => document.body.innerText.length > 0, // replaced in Step 3 with a tighter check { timeout: 20000 } ).catch(() => null); await page.waitForTimeout(6000); // dwell on the answer }; ``` - [ ] **Step 3: Tighten the reveal wait** After pinning the assistant message selector in Step 1, replace the `waitForFunction` body with a check that the assistant message count increased (e.g. `document.querySelectorAll('[data-role="assistant"]').length` or the real message container class). Re-run. - [ ] **Step 4: Capture & verify** Run: `npm run capture:video -- --scene ai-assistant` Expected: clip shows the question typed and a coherent streamed answer; duration written to manifest. - [ ] **Step 5: Commit** ```bash git add marketing/product-film/scripts/scenes/ai-assistant.mjs marketing/product-film/src/clips.manifest.json git commit -m "feat(film): AI assistant scene + locked demo question" ``` --- ## Task 15: ClipStage pure helpers — caption & zoom mapping (TDD) **Files:** - Create: `marketing/product-film/src/clipStage.ts` - Test: `marketing/product-film/src/clipStage.test.ts` - [ ] **Step 1: Write the failing test** Create `marketing/product-film/src/clipStage.test.ts`: ```ts 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); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npm run test -- clipStage` Expected: FAIL — cannot find `./clipStage`. - [ ] **Step 3: Implement `clipStage.ts`** Create `marketing/product-film/src/clipStage.ts`: ```ts 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; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `npm run test -- clipStage` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add marketing/product-film/src/clipStage.ts marketing/product-film/src/clipStage.test.ts git commit -m "feat(film): clip caption/zoom mapping helpers" ``` --- ## Task 16: `ClipStage` component **Files:** - Create: `marketing/product-film/src/components/ClipStage.tsx` Renders a single clip with the existing `Caption` component and a zoom-punch driven by the tested helpers. Reuses `theme.ts`. - [ ] **Step 1: Implement `ClipStage.tsx`** Create `marketing/product-film/src/components/ClipStage.tsx`: ```tsx 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} ); }; ``` > If the existing `Caption` component's prop name differs from `text`, adapt this line to its real signature (check `src/components/Caption.tsx`). - [ ] **Step 2: Verify it type-checks / builds in studio** Run: `npx remotion studio` (or `npm run dev`) — confirm no TypeScript/import errors in the terminal. Stop the studio. - [ ] **Step 3: Commit** ```bash git add marketing/product-film/src/components/ClipStage.tsx git commit -m "feat(film): ClipStage (OffthreadVideo + caption + zoom-punch)" ``` --- ## Task 17: `LiveFilm` composition (with music bed) + register as default **Files:** - Modify: `marketing/product-film/src/components/AudioTrack.tsx` - Create: `marketing/product-film/src/LiveFilm.tsx` - Modify: `marketing/product-film/src/Root.tsx` - [ ] **Step 1: Parameterize `AudioTrack` total length** `AudioTrack` currently hardcodes the old film's `TOTAL_FRAMES` for its fade-out, so the new film (different length) needs it passed in. Edit `marketing/product-film/src/components/AudioTrack.tsx`: ```tsx import { Audio, staticFile, useCurrentFrame, interpolate } from "remotion"; 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<{ totalFrames?: number }> = ({ totalFrames = TOTAL_FRAMES }) => { const frame = useCurrentFrame(); if (!HAS_MUSIC) return null; // fade in at start, fade out over the last 2s of the given length const volume = interpolate( frame, [0, 30, totalFrames - 60, totalFrames], [0, 0.7, 0.7, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" } ); return