1326 lines
53 KiB
Markdown
1326 lines
53 KiB
Markdown
# 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 `<OffthreadVideo>` 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), `<OffthreadVideo>`; 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: `<OffthreadVideo>` + caption overlay + zoom-punch |
|
||
| `marketing/product-film/src/LiveFilm.tsx` | `<Series>` 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/<id>.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<string>();
|
||
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/<id>.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 =
|
||
"<svg width='22' height='22' viewBox='0 0 22 22'><path d='M2 2 L2 17 L6 13 L9 20 L12 19 L9 12 L15 12 Z' fill='white' stroke='%230B5FFF' stroke-width='1.5'/></svg>";
|
||
document.documentElement.appendChild(el);
|
||
const ring = document.createElement('div');
|
||
Object.assign(ring.style, {
|
||
position: 'fixed', left: '0', top: '0', width: '34px', height: '34px',
|
||
marginLeft: '-17px', marginTop: '-17px', borderRadius: '50%',
|
||
border: '2px solid rgba(11,95,255,0.9)', zIndex: '2147483646',
|
||
pointerEvents: 'none', opacity: '0', transform: 'scale(0.4)',
|
||
transition: 'opacity 0.3s, transform 0.3s',
|
||
});
|
||
document.documentElement.appendChild(ring);
|
||
window.__cursorEl = el; window.__cursorRing = ring;
|
||
window.__cursorMove = (x, y) => {
|
||
el.style.transform = 'translate(' + x + 'px,' + y + 'px)';
|
||
ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)';
|
||
};
|
||
window.__cursorPulse = (x, y) => {
|
||
ring.style.transition = 'none';
|
||
ring.style.opacity = '0.9';
|
||
ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)';
|
||
requestAnimationFrame(() => {
|
||
ring.style.transition = 'opacity 0.4s, transform 0.4s';
|
||
ring.style.opacity = '0';
|
||
ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(1.4)';
|
||
});
|
||
};
|
||
})();
|
||
`;
|
||
|
||
// Node-side driver bound to a Playwright page. Moves the on-screen cursor in eased
|
||
// steps, then fires the REAL mouse event at the same point so the app responds.
|
||
export function makeCursor(page) {
|
||
let pos = { x: 960, y: 540 };
|
||
const apply = async (x, y) => {
|
||
await page.evaluate(([px, py]) => window.__cursorMove?.(px, py), [x, y]);
|
||
await page.mouse.move(x, y);
|
||
};
|
||
return {
|
||
async moveTo(x, y, ms = 600) {
|
||
const steps = Math.max(8, Math.round(ms / 16));
|
||
for (const p of easeSteps(pos, { x, y }, steps)) {
|
||
await apply(p.x, p.y);
|
||
await page.waitForTimeout(ms / steps);
|
||
}
|
||
pos = { x, y };
|
||
},
|
||
async click(x, y, ms = 600) {
|
||
if (x != null) await this.moveTo(x, y, ms);
|
||
await page.evaluate(([px, py]) => window.__cursorPulse?.(px, py), [pos.x, pos.y]);
|
||
await page.mouse.click(pos.x, pos.y);
|
||
},
|
||
async clickLocator(locator, ms = 600) {
|
||
const box = await locator.boundingBox();
|
||
if (!box) throw new Error("clickLocator: element not visible");
|
||
await this.click(box.x + box.width / 2, box.y + box.height / 2, ms);
|
||
},
|
||
get position() {
|
||
return pos;
|
||
},
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **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/<id>.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 <id>` → clip produced, duration written.
|
||
4. **Scrub** the mp4 to confirm the cause→effect reveal reads clearly.
|
||
5. **Commit** `scripts/scenes/<id>.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 `<think>` 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 (
|
||
<AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>
|
||
<AbsoluteFill style={{ transform: `scale(${zoom})`, transformOrigin: "center center" }}>
|
||
<OffthreadVideo src={staticFile(clip.file)} />
|
||
</AbsoluteFill>
|
||
{caption ? <Caption text={caption.text} /> : null}
|
||
</AbsoluteFill>
|
||
);
|
||
};
|
||
```
|
||
|
||
> 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 <Audio src={staticFile("audio/music.mp3")} volume={volume} />;
|
||
};
|
||
```
|
||
(Existing `Film.tsx` usage of `<AudioTrack />` keeps working via the default.)
|
||
|
||
- [ ] **Step 2: Implement `LiveFilm.tsx` (clips + music bed)**
|
||
|
||
Create `marketing/product-film/src/LiveFilm.tsx`. The native `Promise` title card is
|
||
interleaved right after the `landing-hero` clip; all other clips (incl. `breadth-flash`
|
||
second-to-last and `crane-close` last) follow in manifest order:
|
||
```tsx
|
||
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;
|
||
// All captured clips + the one native (Promise) sequence.
|
||
export const LIVE_TOTAL = totalLiveFrames(m) + DURATIONS.promise;
|
||
|
||
export const LiveFilm: React.FC = () => {
|
||
const [hero, ...rest] = m.clips; // landing-hero is first in the manifest
|
||
return (
|
||
<AbsoluteFill>
|
||
<AudioTrack totalFrames={LIVE_TOTAL} />
|
||
<Series>
|
||
<Series.Sequence durationInFrames={hero.durationFrames}>
|
||
<ClipStage clip={hero} />
|
||
</Series.Sequence>
|
||
<Series.Sequence durationInFrames={DURATIONS.promise}>
|
||
<PromiseSeq />
|
||
</Series.Sequence>
|
||
{rest.map((clip) => (
|
||
<Series.Sequence key={clip.id} durationInFrames={clip.durationFrames}>
|
||
<ClipStage clip={clip} />
|
||
</Series.Sequence>
|
||
))}
|
||
</Series>
|
||
</AbsoluteFill>
|
||
);
|
||
};
|
||
```
|
||
|
||
> `Promise` is aliased to `PromiseSeq` to avoid shadowing the global `Promise`. The crane
|
||
> closer is the captured `crane-close` clip (last in the manifest); if you later prefer the
|
||
> *native* Remotion crane (`CraneClose.tsx`), swap the final entry to render that instead.
|
||
> Music is a drop-in: set `HAS_MUSIC = true` after placing `public/audio/music.mp3`.
|
||
|
||
- [ ] **Step 3: Register as the default composition in `Root.tsx`**
|
||
|
||
Replace `marketing/product-film/src/Root.tsx` with:
|
||
```tsx
|
||
import { Composition } from "remotion";
|
||
import { Film } from "./Film";
|
||
import { LiveFilm, LIVE_TOTAL } from "./LiveFilm";
|
||
import { TOTAL_FRAMES, FPS, WIDTH, HEIGHT } from "./timing";
|
||
|
||
export const Root: React.FC = () => {
|
||
return (
|
||
<>
|
||
<Composition
|
||
id="LynkedUpProLiveFilm"
|
||
component={LiveFilm}
|
||
durationInFrames={LIVE_TOTAL}
|
||
fps={FPS}
|
||
width={WIDTH}
|
||
height={HEIGHT}
|
||
/>
|
||
<Composition
|
||
id="LynkedUpProFilm"
|
||
component={Film}
|
||
durationInFrames={TOTAL_FRAMES}
|
||
fps={FPS}
|
||
width={WIDTH}
|
||
height={HEIGHT}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Verify both compositions load**
|
||
|
||
Run: `npx remotion studio` — confirm `LynkedUpProLiveFilm` appears and scrubs (clips show once captured). Stop studio.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add marketing/product-film/src/components/AudioTrack.tsx marketing/product-film/src/LiveFilm.tsx marketing/product-film/src/Root.tsx
|
||
git commit -m "feat(film): LiveFilm composition (clips + music bed) registered as default"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Pre-render smoke check (all clips present)
|
||
|
||
**Files:**
|
||
- Create: `marketing/product-film/scripts/check-clips.mjs`
|
||
|
||
- [ ] **Step 1: Implement the check**
|
||
|
||
Create `marketing/product-film/scripts/check-clips.mjs`:
|
||
```js
|
||
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.`);
|
||
```
|
||
|
||
- [ ] **Step 2: Wire it into render**
|
||
|
||
Edit `marketing/product-film/package.json` `render:live` to run the check first:
|
||
```json
|
||
"render:live": "node scripts/check-clips.mjs && remotion render LynkedUpProLiveFilm out/lynkedup-pro-live-film.mp4"
|
||
```
|
||
|
||
- [ ] **Step 3: Verify it fails loudly when clips are missing**
|
||
|
||
Run: `node scripts/check-clips.mjs`
|
||
Expected: lists any not-yet-captured clips and exits non-zero (until all 20 are captured).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add marketing/product-film/scripts/check-clips.mjs marketing/product-film/package.json
|
||
git commit -m "feat(film): pre-render smoke check for missing clips"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: VO script + gitignore clips/output
|
||
|
||
**Files:**
|
||
- Create: `marketing/product-film/VO-SCRIPT.md`
|
||
- Modify: `marketing/product-film/.gitignore`
|
||
|
||
- [ ] **Step 1: Ignore heavy capture artifacts**
|
||
|
||
Append to `marketing/product-film/.gitignore`:
|
||
```
|
||
public/clips/
|
||
out/
|
||
```
|
||
|
||
- [ ] **Step 2: Write the VO script skeleton**
|
||
|
||
Create `marketing/product-film/VO-SCRIPT.md` with one section per clip (in manifest order), each containing: the on-screen caption(s), the narration line(s) timed to the interaction, and (for `ai-assistant`) the locked demo question. Use the spec's act structure as headings. Each section is 2–4 lines of voiceable narration matching the captions in `clips.manifest.json`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add marketing/product-film/.gitignore marketing/product-film/VO-SCRIPT.md
|
||
git commit -m "docs(film): VO script + ignore clips/output artifacts"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 20: Capture all scenes & final render
|
||
|
||
- [ ] **Step 1: Capture every scene**
|
||
|
||
Prereq: CRM dev server running. Run (in `marketing/product-film/`): `npm run capture:video`
|
||
Expected: all 20 clips produced in `public/clips/`, manifest durations updated, tests still pass (`npm run test -- clips`).
|
||
|
||
- [ ] **Step 2: Smoke check**
|
||
|
||
Run: `node scripts/check-clips.mjs`
|
||
Expected: `All 20 clip files present.`
|
||
|
||
- [ ] **Step 3: Preview & render**
|
||
|
||
Run: `npx remotion studio` — scrub the full `LynkedUpProLiveFilm`, confirm pacing/captions/bookends. Then:
|
||
Run: `npm run render:live`
|
||
Expected: `out/lynkedup-pro-live-film.mp4` produced (~15–16 min, 1080p30).
|
||
|
||
- [ ] **Step 4: Commit the finalized manifest**
|
||
|
||
```bash
|
||
git add marketing/product-film/src/clips.manifest.json
|
||
git commit -m "feat(film): finalize live-film clip durations after full capture"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-review notes (for the executor)
|
||
|
||
- **Selectors are pinned from the live DOM, not invented** — Tasks 8–14 require opening each route and reading real button/role/text before writing the scene. This is intentional: the running app is the source of truth.
|
||
- **TDD applies to pure logic** (manifest validation, cursor easing, duration math, caption/zoom mapping) — those tasks have failing-test-first steps. Browser scenes are integration-verified by producing and scrubbing the clip; that is the correct verification level for video capture.
|
||
- **The AI workstream (spec §9) is already implemented** — Task 14 only verifies the live answer and locks the demo question.
|
||
- **No CRM source changes** beyond the already-done `Chatbot.jsx` edit.
|