From 48f6d8d0947d06ad718a2243ac371da1af486f4c Mon Sep 17 00:00:00 2001 From: Satyam Rastogi Date: Sat, 30 May 2026 20:33:02 +0530 Subject: [PATCH] docs(film): live product film design spec + implementation plan --- .../plans/2026-05-30-live-product-film.md | 1325 +++++++++++++++++ .../2026-05-30-live-product-film-design.md | 295 ++++ 2 files changed, 1620 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-30-live-product-film.md create mode 100644 docs/superpowers/specs/2026-05-30-live-product-film-design.md diff --git a/docs/superpowers/plans/2026-05-30-live-product-film.md b/docs/superpowers/plans/2026-05-30-live-product-film.md new file mode 100644 index 0000000..e4becbc --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-live-product-film.md @@ -0,0 +1,1325 @@ +# 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