Files
LynkedUpPro_CRM/docs/superpowers/plans/2026-05-30-product-film.md
T

1859 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# LynkedUp Pro Flagship 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 self-contained Remotion project at `marketing/product-film/` that renders a ~5-minute flagship marketing film of the LynkedUp Pro CRM — composed from real captured app screenshots plus animated brand beats — without modifying any CRM code.
**Architecture:** Two stages. (1) A Playwright **capture script** drives the running CRM (`npm run dev`, port 5173) in dark mode, logs in as the right role, and screenshots each screen listed in a JSON manifest into `public/captures/`. (2) A **Remotion** composition (`LynkedUpProFilm`) arranges those captures inside shared components (synthetic cursor, captions, screen frame, Ken Burns, an animated crane CTA reproducing the landing page's `craneSwing`) into 8 sequences: Cold Open → Promise → 5 value pillars → Crane CTA close. Pure logic (timing math, swing curve, manifest validation) is TDD'd with Vitest; visual components are verified by rendering a still frame; the whole film is verified by a final `remotion render`.
**Tech Stack:** Remotion 4 (React 19 + TypeScript), Vitest (unit tests for pure helpers), Playwright (capture). Isolated `package.json` — no dependency on the CRM build.
**Source spec:** `docs/superpowers/specs/2026-05-30-product-film-design.md`
**Hard constraints:**
- Do **not** edit anything under `src/` (CRM code). All new files live under `marketing/product-film/`.
- Dark-mode capture. 1920×1080, 30fps. Captions + music now; voiceover later (script lives in the spec §5, mirrored into caption text here).
- Commits: author is the repo's configured identity (Satyam Rastogi). Do **not** add any AI/Claude attribution or co-author trailer to commit messages.
---
## File Structure
```
marketing/product-film/
package.json # remotion, vitest, playwright; scripts: dev, render, still, capture, test
tsconfig.json
remotion.config.ts
.gitignore # ignores public/captures/ and out/
scripts/
capture.mjs # Playwright capture pipeline (drives the live CRM)
src/
index.ts # registerRoot(Root)
Root.tsx # <Composition id="LynkedUpProFilm" .../>
Film.tsx # <Series> of the 8 sequences + <AudioTrack/>
timing.ts # FPS, WIDTH, HEIGHT, DURATIONS, TOTAL_FRAMES (pure)
timing.test.ts
captures.ts # Manifest types + validateManifest() (pure)
captures.test.ts
captures.manifest.json # list of every screenshot to capture
theme.ts # brand tokens (colors, fonts, blueprint grid)
components/
craneSwing.ts # swingRotation() pendulum curve (pure)
craneSwing.test.ts
Caption.tsx # animated lower-third / center caption
ScreenFrame.tsx # browser-chrome frame around a capture (+ placeholder fallback)
KenBurns.tsx # slow pan/zoom wrapper
Cursor.tsx # synthetic cursor: spring move + click pulse
TypeReveal.tsx # types text into view char-by-char
PillarTitle.tsx # animated section title card
Crane.tsx # blueprint tower-crane SVG (ported from Landing.jsx)
CraneCard.tsx # suspended CTA card; reproduces craneSwing
AudioTrack.tsx # music bed (gated by HAS_MUSIC)
BlueprintBg.tsx # blueprint-grid background for animated beats
sequences/
ColdOpen.tsx
Promise.tsx
Pillar1Storms.tsx
Pillar2Dispatch.tsx
Pillar3Rails.tsx
Pillar4Control.tsx
Pillar5Profit.tsx
CraneClose.tsx
public/
audio/ # music.mp3 (dropped in by hand; licensed/royalty-free)
captures/ # screenshots produced by scripts/capture.mjs (gitignored)
out/ # rendered mp4 (gitignored)
README.md # capture → render workflow
```
**Capture manifest IDs** (referenced by sequences; produced by the capture script). `role` is one of `owner | agentCody | subCarlos`.
| id | role | route | purpose / pillar |
|---|---|---|---|
| `storm-intel` | owner | `/owner/storm-intel` | ① storm map |
| `territory-map` | owner | `/owner/maps` | ① territory |
| `pro-canvas` | owner | `/owner/pro-canvas` | ① canvassing |
| `lead-verification` | owner | `/lead-verification` | ① verify |
| `dispatch` | owner | `/owner/dispatch` | ② AI dispatch |
| `people-skills` | owner | `/owner/people` | ② skill scores / ④ people |
| `kanban` | owner | `/owner/kanban` | ② pipeline |
| `settings-access` | owner | `/owner/settings` | ④ roles + ABAC + skill catalog |
| `projects` | owner | `/owner/projects` | ③ project list |
| `estimates` | owner | `/owner/estimates` | ③ estimates |
| `sub-tasks-owner` | owner | `/owner/subcontractor-tasks` | ③ sub tasks (owner view) |
| `owner-snapshot` | owner | `/owner/snapshot` | ⑤ financials/dashboards |
| `leaderboard` | owner | `/admin/leaderboard` | ⑤ leaderboard |
| `rep-leads` | agentCody | `/emp/fa/leads` | ④ boundary: rep sees only own leads |
| `rep-dashboard` | agentCody | `/emp/fa/dashboard` | ④ rep home (no Financials nav) |
| `sub-dashboard` | subCarlos | `/subcontractor/dashboard` | ③/④ sub portal home |
| `sub-projects` | subCarlos | `/subcontractor/projects` | ④ boundary: sub sees only own jobs |
> Routes verified against `src/App.jsx`. Logins verified against `src/pages/Login.jsx` (Owner `justin`, Agent `LUP-1040`=Cody Tatum, Sub-Con `carlos`=Carlos Mendoza; all password `password`). Theme key `theme` verified against `src/context/ThemeContext.jsx`.
---
## Phase 0 — Scaffold & toolchain
### Task 1: Scaffold the Remotion project
**Files:**
- Create: `marketing/product-film/package.json`
- Create: `marketing/product-film/tsconfig.json`
- Create: `marketing/product-film/remotion.config.ts`
- Create: `marketing/product-film/.gitignore`
- Create: `marketing/product-film/src/index.ts`
- Create: `marketing/product-film/src/Root.tsx`
- Create: `marketing/product-film/src/Film.tsx` (temporary trivial body)
- [ ] **Step 1: Create `package.json`**
```json
{
"name": "lynkedup-product-film",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "remotion studio",
"render": "remotion render LynkedUpProFilm out/lynkedup-pro-film.mp4",
"still": "remotion still LynkedUpProFilm out/still.png",
"capture": "node scripts/capture.mjs",
"test": "vitest run"
},
"dependencies": {
"@remotion/cli": "4.0.286",
"react": "19.0.0",
"react-dom": "19.0.0",
"remotion": "4.0.286"
},
"devDependencies": {
"@types/react": "19.0.0",
"playwright": "1.49.1",
"typescript": "5.7.2",
"vitest": "2.1.8"
}
}
```
- [ ] **Step 2: Create `tsconfig.json`**
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"lib": ["ES2020", "DOM"]
},
"include": ["src"]
}
```
- [ ] **Step 3: Create `remotion.config.ts`**
```ts
import { Config } from "@remotion/cli/config";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
Config.setConcurrency(4);
```
- [ ] **Step 4: Create `.gitignore`**
```
node_modules/
out/
public/captures/
.remotion/
```
- [ ] **Step 5: Create `src/index.ts`**
```ts
import { registerRoot } from "remotion";
import { Root } from "./Root";
registerRoot(Root);
```
- [ ] **Step 6: Create a trivial `src/Film.tsx` (replaced in Task 11)**
```tsx
import { AbsoluteFill } from "remotion";
export const Film: React.FC = () => {
return (
<AbsoluteFill style={{ backgroundColor: "#09090b", alignItems: "center", justifyContent: "center" }}>
<h1 style={{ color: "white", fontFamily: "sans-serif" }}>LynkedUp Pro Film</h1>
</AbsoluteFill>
);
};
```
- [ ] **Step 7: Create `src/Root.tsx`**
```tsx
import { Composition } from "remotion";
import { Film } from "./Film";
export const Root: React.FC = () => {
return (
<Composition
id="LynkedUpProFilm"
component={Film}
durationInFrames={150}
fps={30}
width={1920}
height={1080}
/>
);
};
```
- [ ] **Step 8: Install deps**
Run: `cd marketing/product-film && npm install`
Expected: dependencies install with no error.
- [ ] **Step 9: Verify a still renders**
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/scaffold.png --frame=0`
Expected: `out/scaffold.png` is written (a dark frame with the title). No error.
- [ ] **Step 10: Commit**
```bash
git add marketing/product-film/package.json marketing/product-film/package-lock.json marketing/product-film/tsconfig.json marketing/product-film/remotion.config.ts marketing/product-film/.gitignore marketing/product-film/src/index.ts marketing/product-film/src/Root.tsx marketing/product-film/src/Film.tsx
git commit -m "chore(film): scaffold remotion product-film project"
```
---
### Task 2: Timing config (TDD)
**Files:**
- Create: `marketing/product-film/src/timing.ts`
- Test: `marketing/product-film/src/timing.test.ts`
- [ ] **Step 1: Write the failing test**
```ts
import { describe, it, expect } from "vitest";
import { DURATIONS, TOTAL_FRAMES, FPS, SEQUENCE_ORDER } from "./timing";
describe("timing", () => {
it("uses 30 fps", () => {
expect(FPS).toBe(30);
});
it("has all 8 sequences in order", () => {
expect(SEQUENCE_ORDER).toEqual([
"coldOpen", "promise", "pillar1", "pillar2", "pillar3", "pillar4", "pillar5", "craneClose",
]);
});
it("every duration is a positive integer number of frames", () => {
for (const key of SEQUENCE_ORDER) {
expect(DURATIONS[key]).toBeGreaterThan(0);
expect(Number.isInteger(DURATIONS[key])).toBe(true);
}
});
it("TOTAL_FRAMES equals 265 seconds at 30fps", () => {
expect(TOTAL_FRAMES).toBe(7950);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cd marketing/product-film && npx vitest run src/timing.test.ts`
Expected: FAIL — cannot find module `./timing`.
- [ ] **Step 3: Implement `timing.ts`**
```ts
export const FPS = 30;
export const WIDTH = 1920;
export const HEIGHT = 1080;
const sec = (s: number) => Math.round(s * FPS);
export const SEQUENCE_ORDER = [
"coldOpen",
"promise",
"pillar1",
"pillar2",
"pillar3",
"pillar4",
"pillar5",
"craneClose",
] as const;
export type SequenceKey = (typeof SEQUENCE_ORDER)[number];
export const DURATIONS: Record<SequenceKey, number> = {
coldOpen: sec(12),
promise: sec(8),
pillar1: sec(45),
pillar2: sec(45),
pillar3: sec(50),
pillar4: sec(45),
pillar5: sec(45),
craneClose: sec(15),
};
export const TOTAL_FRAMES = SEQUENCE_ORDER.reduce((acc, k) => acc + DURATIONS[k], 0);
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd marketing/product-film && npx vitest run src/timing.test.ts`
Expected: PASS (4 tests).
- [ ] **Step 5: Wire the real duration into `Root.tsx`**
Replace the `durationInFrames={150}` line and add imports:
```tsx
import { Composition } from "remotion";
import { Film } from "./Film";
import { TOTAL_FRAMES, FPS, WIDTH, HEIGHT } from "./timing";
export const Root: React.FC = () => {
return (
<Composition
id="LynkedUpProFilm"
component={Film}
durationInFrames={TOTAL_FRAMES}
fps={FPS}
width={WIDTH}
height={HEIGHT}
/>
);
};
```
- [ ] **Step 6: Commit**
```bash
git add marketing/product-film/src/timing.ts marketing/product-film/src/timing.test.ts marketing/product-film/src/Root.tsx
git commit -m "feat(film): timing config (8 sequences, 265s, 30fps)"
```
---
## Phase 1 — Capture pipeline
### Task 3: Capture manifest + validation (TDD)
**Files:**
- Create: `marketing/product-film/src/captures.manifest.json`
- Create: `marketing/product-film/src/captures.ts`
- Test: `marketing/product-film/src/captures.test.ts`
- [ ] **Step 1: Create the manifest**
```json
{
"viewport": { "width": 1920, "height": 1080, "deviceScaleFactor": 2 },
"theme": "dark",
"baseUrl": "http://localhost:5173",
"captures": [
{ "id": "storm-intel", "role": "owner", "route": "/owner/storm-intel", "waitMs": 2000 },
{ "id": "territory-map", "role": "owner", "route": "/owner/maps", "waitMs": 2500 },
{ "id": "pro-canvas", "role": "owner", "route": "/owner/pro-canvas", "waitMs": 1800 },
{ "id": "lead-verification", "role": "owner", "route": "/lead-verification", "waitMs": 1800 },
{ "id": "dispatch", "role": "owner", "route": "/owner/dispatch", "waitMs": 2500 },
{ "id": "people-skills", "role": "owner", "route": "/owner/people", "waitMs": 1800 },
{ "id": "kanban", "role": "owner", "route": "/owner/kanban", "waitMs": 1800 },
{ "id": "settings-access", "role": "owner", "route": "/owner/settings", "waitMs": 1800 },
{ "id": "projects", "role": "owner", "route": "/owner/projects", "waitMs": 1800 },
{ "id": "estimates", "role": "owner", "route": "/owner/estimates", "waitMs": 1800 },
{ "id": "sub-tasks-owner", "role": "owner", "route": "/owner/subcontractor-tasks", "waitMs": 1800 },
{ "id": "owner-snapshot", "role": "owner", "route": "/owner/snapshot", "waitMs": 2000 },
{ "id": "leaderboard", "role": "owner", "route": "/admin/leaderboard", "waitMs": 1800 },
{ "id": "rep-leads", "role": "agentCody", "route": "/emp/fa/leads", "waitMs": 1800 },
{ "id": "rep-dashboard", "role": "agentCody", "route": "/emp/fa/dashboard", "waitMs": 1800 },
{ "id": "sub-dashboard", "role": "subCarlos", "route": "/subcontractor/dashboard", "waitMs": 1800 },
{ "id": "sub-projects", "role": "subCarlos", "route": "/subcontractor/projects", "waitMs": 1800 }
]
}
```
- [ ] **Step 2: Write the failing test**
```ts
import { describe, it, expect } from "vitest";
import manifest from "./captures.manifest.json";
import { validateManifest, type Manifest } from "./captures";
describe("captures manifest", () => {
it("passes validation", () => {
expect(validateManifest(manifest as Manifest)).toEqual([]);
});
it("has unique ids", () => {
const ids = (manifest as Manifest).captures.map((c) => c.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("captures in dark theme", () => {
expect((manifest as Manifest).theme).toBe("dark");
});
});
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `cd marketing/product-film && npx vitest run src/captures.test.ts`
Expected: FAIL — cannot find module `./captures`.
- [ ] **Step 4: Implement `captures.ts`**
```ts
export type CaptureRole = "owner" | "agentCody" | "subCarlos";
export interface Capture {
id: string;
role: CaptureRole;
route: string;
waitMs?: number;
}
export interface Manifest {
viewport: { width: number; height: number; deviceScaleFactor: number };
theme: "dark" | "light";
baseUrl: string;
captures: Capture[];
}
const VALID_ROLES: CaptureRole[] = ["owner", "agentCody", "subCarlos"];
export function validateManifest(m: Manifest): string[] {
const errors: string[] = [];
const seen = new Set<string>();
if (!m.baseUrl?.startsWith("http")) errors.push("baseUrl must be an http(s) URL");
if (m.theme !== "dark" && m.theme !== "light") errors.push("theme must be dark|light");
for (const c of m.captures) {
if (!c.id) errors.push("capture missing id");
if (seen.has(c.id)) errors.push(`duplicate id: ${c.id}`);
seen.add(c.id);
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}"`);
}
return errors;
}
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `cd marketing/product-film && npx vitest run src/captures.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 6: Commit**
```bash
git add marketing/product-film/src/captures.manifest.json marketing/product-film/src/captures.ts marketing/product-film/src/captures.test.ts
git commit -m "feat(film): capture manifest + validation"
```
---
### Task 4: Playwright capture script
**Files:**
- Create: `marketing/product-film/scripts/capture.mjs`
- [ ] **Step 1: Write the capture script**
```js
// Drives the running CRM (npm run dev) and screenshots each manifest screen in dark mode.
// Prereq: in the CRM repo root run `npm run dev` (serves http://localhost:5173).
import { chromium } from "playwright";
import { readFile, mkdir } 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, "..");
// tab label + identifier per role (password is always "password")
const ROLE_LOGIN = {
owner: { tab: "Owner", id: "justin" },
agentCody: { tab: "Employee", id: "LUP-1040" },
subCarlos: { tab: "Sub-Con", id: "carlos" },
};
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" });
// The role tab appears before the demo chip of the same name in the DOM, so .first() targets the tab.
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.waitForLoadState("networkidle");
}
async function main() {
const manifest = JSON.parse(await readFile(join(ROOT, "src/captures.manifest.json"), "utf8"));
const outDir = join(ROOT, "public/captures");
await mkdir(outDir, { recursive: true });
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: manifest.viewport.width, height: manifest.viewport.height },
deviceScaleFactor: manifest.viewport.deviceScaleFactor,
});
// Force dark theme before any app code runs.
await context.addInitScript((theme) => {
try { localStorage.setItem("theme", theme); } catch (e) {}
}, manifest.theme);
const page = await context.newPage();
// Group by role so we log in once per role, in manifest order.
let currentRole = null;
for (const cap of manifest.captures) {
if (cap.role !== currentRole) {
await login(page, manifest.baseUrl, cap.role);
currentRole = cap.role;
}
await page.goto(`${manifest.baseUrl}${cap.route}`, { waitUntil: "networkidle" });
await page.waitForTimeout(cap.waitMs ?? 1500);
const path = join(outDir, `${cap.id}.png`);
await page.screenshot({ path }); // viewport-only → exact 16:9
console.log(`captured ${cap.id} -> ${path}`);
}
await browser.close();
console.log(`\nDone. ${manifest.captures.length} captures written to public/captures/`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
```
- [ ] **Step 2: Install the Playwright browser**
Run: `cd marketing/product-film && npx playwright install chromium`
Expected: Chromium downloads successfully.
- [ ] **Step 3: Start the CRM dev server (separate terminal, from repo root)**
Run: `npm run dev`
Expected: Vite serves on `http://localhost:5173`. Leave it running.
- [ ] **Step 4: Run the capture script**
Run: `cd marketing/product-film && npm run capture`
Expected: console logs `captured <id> -> ...` for all 17 ids; `public/captures/` contains 17 `.png` files. Spot-check `public/captures/owner-snapshot.png` and `public/captures/rep-leads.png` — both should be **dark mode** and show real seeded data (canonical names, positive financials), not a login screen.
> If any capture shows a login/redirect instead of the page, the role lacks access to that route — re-check the route↔role pairing in the manifest against `src/App.jsx` and adjust the `role` for that capture.
- [ ] **Step 5: Commit (script only — captures are gitignored)**
```bash
git add marketing/product-film/scripts/capture.mjs
git commit -m "feat(film): playwright capture pipeline (dark-mode, role-aware)"
```
---
## Phase 2 — Shared components
### Task 5: Theme tokens + Caption
**Files:**
- Create: `marketing/product-film/src/theme.ts`
- Create: `marketing/product-film/src/components/BlueprintBg.tsx`
- Create: `marketing/product-film/src/components/Caption.tsx`
- [ ] **Step 1: Create `theme.ts`**
```ts
export const theme = {
bg: "#09090b",
surface: "rgba(255,255,255,0.03)",
border: "rgba(255,255,255,0.10)",
text: "#ffffff",
textDim: "rgba(255,255,255,0.55)",
blue: "#3b82f6",
cyan: "#22d3ee",
emerald: "#10b981",
amber: "#f59e0b",
red: "#ef4444",
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif',
mono: 'ui-monospace, SFMono-Regular, Menlo, monospace',
// gradient used in the landing headline
brandGradient: "linear-gradient(90deg, #60a5fa, #3b82f6, #22d3ee)",
} as const;
```
- [ ] **Step 2: Create `BlueprintBg.tsx`**
```tsx
import { AbsoluteFill } from "remotion";
import { theme } from "../theme";
export const BlueprintBg: React.FC<{ opacity?: number }> = ({ opacity = 0.4 }) => {
const line = "rgba(59,130,246,0.10)";
return (
<AbsoluteFill
style={{
backgroundColor: theme.bg,
backgroundImage: `linear-gradient(${line} 1px, transparent 1px), linear-gradient(90deg, ${line} 1px, transparent 1px)`,
backgroundSize: "48px 48px",
opacity,
}}
/>
);
};
```
- [ ] **Step 3: Create `Caption.tsx`**
```tsx
import { useCurrentFrame, interpolate, AbsoluteFill } from "remotion";
import { theme } from "../theme";
type Position = "bottom" | "center";
export const Caption: React.FC<{
text: string;
startFrame: number;
durationInFrames: number;
position?: Position;
}> = ({ text, startFrame, durationInFrames, position = "bottom" }) => {
const frame = useCurrentFrame();
const local = frame - startFrame;
if (local < 0 || local > durationInFrames) return null;
const opacity = interpolate(
local,
[0, 12, durationInFrames - 12, durationInFrames],
[0, 1, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
const y = interpolate(local, [0, 12], [20, 0], { extrapolateRight: "clamp" });
return (
<AbsoluteFill
style={{
justifyContent: position === "bottom" ? "flex-end" : "center",
alignItems: "center",
paddingBottom: position === "bottom" ? 90 : 0,
}}
>
<div
style={{
opacity,
transform: `translateY(${y}px)`,
maxWidth: 1200,
textAlign: "center",
fontFamily: theme.font,
fontWeight: 800,
fontSize: position === "center" ? 64 : 44,
lineHeight: 1.15,
color: theme.text,
background: "rgba(9,9,11,0.55)",
backdropFilter: "blur(8px)",
padding: "18px 36px",
borderRadius: 18,
border: `1px solid ${theme.border}`,
}}
>
{text}
</div>
</AbsoluteFill>
);
};
```
- [ ] **Step 4: Verify it renders (temporary harness)**
Temporarily set `src/Film.tsx` body to render a caption, then render a still:
```tsx
import { AbsoluteFill } from "remotion";
import { BlueprintBg } from "./components/BlueprintBg";
import { Caption } from "./components/Caption";
export const Film: React.FC = () => (
<AbsoluteFill>
<BlueprintBg />
<Caption text="A storm hits. The leads flood in. Then what?" startFrame={0} durationInFrames={60} />
</AbsoluteFill>
);
```
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/caption.png --frame=30`
Expected: `out/caption.png` shows the caption over a blueprint grid. No error. (Film.tsx is replaced in Task 11; this is just a render check.)
- [ ] **Step 5: Commit**
```bash
git add marketing/product-film/src/theme.ts marketing/product-film/src/components/BlueprintBg.tsx marketing/product-film/src/components/Caption.tsx
git commit -m "feat(film): theme tokens, blueprint background, caption component"
```
---
### Task 6: ScreenFrame + KenBurns
**Files:**
- Create: `marketing/product-film/src/components/ScreenFrame.tsx`
- Create: `marketing/product-film/src/components/KenBurns.tsx`
- [ ] **Step 1: Create `KenBurns.tsx`**
```tsx
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
// Slow zoom/pan over its children across the whole sequence the component lives in.
export const KenBurns: React.FC<{
children: React.ReactNode;
from?: number;
to?: number;
durationInFrames: number;
}> = ({ children, from = 1.0, to = 1.08, durationInFrames }) => {
const frame = useCurrentFrame();
const scale = interpolate(frame, [0, durationInFrames], [from, to], {
extrapolateRight: "clamp",
});
return <div style={{ width: "100%", height: "100%", transform: `scale(${scale})` }}>{children}</div>;
};
```
- [ ] **Step 2: Create `ScreenFrame.tsx`** (native `<img>` with a placeholder fallback so sequences render even before captures exist)
```tsx
import { useState } from "react";
import { AbsoluteFill, staticFile } from "remotion";
import { theme } from "../theme";
export const ScreenFrame: React.FC<{
captureId: string;
// 0..1 inset of the framed screen within the canvas
scale?: number;
}> = ({ captureId, scale = 0.86 }) => {
const [errored, setErrored] = useState(false);
const src = staticFile(`captures/${captureId}.png`);
return (
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center", backgroundColor: theme.bg }}>
<div
style={{
width: `${scale * 100}%`,
aspectRatio: "16 / 9",
borderRadius: 16,
overflow: "hidden",
border: `1px solid ${theme.border}`,
boxShadow: "0 40px 120px rgba(0,0,0,0.55)",
backgroundColor: "#0c0c0e",
position: "relative",
}}
>
{/* browser chrome bar */}
<div style={{ height: 34, display: "flex", alignItems: "center", gap: 8, padding: "0 14px", background: "rgba(255,255,255,0.04)", borderBottom: `1px solid ${theme.border}` }}>
<span style={{ width: 11, height: 11, borderRadius: 999, background: "#ef4444" }} />
<span style={{ width: 11, height: 11, borderRadius: 999, background: "#f59e0b" }} />
<span style={{ width: 11, height: 11, borderRadius: 999, background: "#10b981" }} />
<span style={{ marginLeft: 14, color: theme.textDim, fontFamily: theme.mono, fontSize: 13 }}>app.lynkeduppro.com</span>
</div>
<div style={{ position: "absolute", top: 34, left: 0, right: 0, bottom: 0 }}>
{errored ? (
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: theme.textDim, fontFamily: theme.mono, fontSize: 22, background: "#0c0c0e" }}>
[capture missing: {captureId}]
</div>
) : (
<img
src={src}
onError={() => setErrored(true)}
style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top center", display: "block" }}
/>
)}
</div>
</div>
</AbsoluteFill>
);
};
```
- [ ] **Step 3: Verify it renders**
Temporarily set `src/Film.tsx` to `<ScreenFrame captureId="owner-snapshot" />` and run:
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/frame.png --frame=0`
Expected: shows the `owner-snapshot` capture in a framed browser window (or the `[capture missing]` placeholder if captures not yet run). No error.
- [ ] **Step 4: Commit**
```bash
git add marketing/product-film/src/components/ScreenFrame.tsx marketing/product-film/src/components/KenBurns.tsx
git commit -m "feat(film): screen frame (with placeholder fallback) + ken burns"
```
---
### Task 7: Synthetic Cursor + TypeReveal
**Files:**
- Create: `marketing/product-film/src/components/Cursor.tsx`
- Create: `marketing/product-film/src/components/TypeReveal.tsx`
- [ ] **Step 1: Create `Cursor.tsx`** (springs from `from``to`, plus a click pulse at `clickAtFrame`)
```tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate, AbsoluteFill } from "remotion";
import { theme } from "../theme";
type Pt = { x: number; y: number }; // pixels in the 1920x1080 canvas
export const Cursor: React.FC<{
from: Pt;
to: Pt;
moveStartFrame: number;
moveDurationFrames?: number;
clickAtFrame?: number;
}> = ({ from, to, moveStartFrame, moveDurationFrames = 22, clickAtFrame }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const p = spring({
frame: frame - moveStartFrame,
fps,
durationInFrames: moveDurationFrames,
config: { damping: 200 },
});
const x = interpolate(p, [0, 1], [from.x, to.x]);
const y = interpolate(p, [0, 1], [from.y, to.y]);
let pulse = 0;
if (clickAtFrame !== undefined) {
pulse = interpolate(frame - clickAtFrame, [0, 8], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
}
return (
<AbsoluteFill>
<div style={{ position: "absolute", left: x, top: y, transform: "translate(-3px,-3px)" }}>
{pulse > 0 && (
<div
style={{
position: "absolute",
left: -18,
top: -18,
width: 36,
height: 36,
borderRadius: 999,
background: theme.blue,
opacity: pulse * 0.4,
transform: `scale(${1 + (1 - pulse)})`,
}}
/>
)}
{/* arrow cursor */}
<svg width="30" height="30" viewBox="0 0 24 24" fill="none">
<path d="M5 3l14 8-6 1.5L10 18 5 3z" fill="white" stroke="#0c0c0e" strokeWidth="1.2" />
</svg>
</div>
</AbsoluteFill>
);
};
```
- [ ] **Step 2: Create `TypeReveal.tsx`** (types `text` across a span of frames; for form-fill beats)
```tsx
import { useCurrentFrame } from "remotion";
import { theme } from "../theme";
export const TypeReveal: React.FC<{
text: string;
startFrame: number;
framesPerChar?: number;
style?: React.CSSProperties;
}> = ({ text, startFrame, framesPerChar = 1.6, style }) => {
const frame = useCurrentFrame();
const shown = Math.max(0, Math.floor((frame - startFrame) / framesPerChar));
const visible = text.slice(0, shown);
const blinkOn = Math.floor(frame / 15) % 2 === 0;
return (
<span style={{ fontFamily: theme.font, color: theme.text, ...style }}>
{visible}
{shown < text.length && blinkOn ? <span style={{ opacity: 0.7 }}>|</span> : null}
</span>
);
};
```
- [ ] **Step 3: Verify it renders**
Temporarily set `src/Film.tsx` to render a `ScreenFrame` with a `Cursor from={{x:200,y:200}} to={{x:1200,y:700}} moveStartFrame={0} clickAtFrame={25} />`, then:
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/cursor.png --frame=26`
Expected: cursor visible near (1200,700) with a click pulse. No error.
- [ ] **Step 4: Commit**
```bash
git add marketing/product-film/src/components/Cursor.tsx marketing/product-film/src/components/TypeReveal.tsx
git commit -m "feat(film): synthetic cursor + type-reveal"
```
---
### Task 8: PillarTitle
**Files:**
- Create: `marketing/product-film/src/components/PillarTitle.tsx`
- [ ] **Step 1: Create `PillarTitle.tsx`**
```tsx
import { useCurrentFrame, interpolate, spring, useVideoConfig, AbsoluteFill } from "remotion";
import { theme } from "../theme";
export const PillarTitle: React.FC<{
index: string; // "①" etc, or "01"
title: string;
subtitle: string;
startFrame: number;
durationInFrames: number;
}> = ({ index, title, subtitle, startFrame, durationInFrames }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const local = frame - startFrame;
if (local < 0 || local > durationInFrames) return null;
const enter = spring({ frame: local, fps, config: { damping: 200 }, durationInFrames: 20 });
const opacity = interpolate(local, [0, 10, durationInFrames - 12, durationInFrames], [0, 1, 1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const y = interpolate(enter, [0, 1], [40, 0]);
return (
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center", opacity }}>
<div style={{ transform: `translateY(${y}px)`, textAlign: "center", fontFamily: theme.font }}>
<div style={{ fontSize: 34, fontWeight: 800, color: theme.blue, marginBottom: 16, fontFamily: theme.mono }}>{index}</div>
<div
style={{
fontSize: 96,
fontWeight: 900,
letterSpacing: "-0.02em",
color: theme.text,
background: theme.brandGradient,
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
{title}
</div>
<div style={{ fontSize: 36, color: theme.textDim, marginTop: 18, fontWeight: 600 }}>{subtitle}</div>
</div>
</AbsoluteFill>
);
};
```
- [ ] **Step 2: Verify it renders**
Temporarily set `src/Film.tsx` to `<PillarTitle index="①" title="Turn storms into pipeline" subtitle="See the storm before your competition" startFrame={0} durationInFrames={60} />` and run:
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/title.png --frame=25`
Expected: gradient title card centered. No error.
- [ ] **Step 3: Commit**
```bash
git add marketing/product-film/src/components/PillarTitle.tsx
git commit -m "feat(film): pillar title card"
```
---
### Task 9: Crane swing curve (TDD) + Crane SVG + CraneCard
**Files:**
- Create: `marketing/product-film/src/components/craneSwing.ts`
- Test: `marketing/product-film/src/components/craneSwing.test.ts`
- Create: `marketing/product-film/src/components/Crane.tsx`
- Create: `marketing/product-film/src/components/CraneCard.tsx`
- [ ] **Step 1: Write the failing test**
```ts
import { describe, it, expect } from "vitest";
import { swingRotation } from "./craneSwing";
describe("swingRotation", () => {
it("starts at the +3deg keyframe, amplified by the entry boost", () => {
// t=0: base = +3, boost = 3 -> 9deg
expect(swingRotation(0, 30)).toBeCloseTo(9, 5);
});
it("settles to within +/-3deg after the settle window", () => {
for (let f = 30 * 3; f < 30 * 12; f++) {
expect(Math.abs(swingRotation(f, 30))).toBeLessThanOrEqual(3.0001);
}
});
it("is periodic at 4s once settled (same phase 4s apart)", () => {
const a = swingRotation(30 * 4, 30); // t=4s
const b = swingRotation(30 * 8, 30); // t=8s
expect(a).toBeCloseTo(b, 5);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cd marketing/product-film && npx vitest run src/components/craneSwing.test.ts`
Expected: FAIL — cannot find module `./craneSwing`.
- [ ] **Step 3: Implement `craneSwing.ts`** (reproduces the landing `craneSwing`: ±3° / 4s, with a damped entry)
```ts
// Reproduces the landing page .crane-swing animation: rotate(+3deg) <-> rotate(-3deg),
// 4s ease-in-out loop, transform-origin top center. A cosine approximates the eased pendulum.
// An entry "boost" damps a larger initial swing down to +/-3deg over settleSeconds.
export function swingRotation(frame: number, fps: number, settleSeconds = 2): number {
const t = frame / fps;
const period = 4; // seconds
const base = 3 * Math.cos((2 * Math.PI) / period * t); // +3 at t=0
const boost = 1 + 2 * Math.max(0, 1 - t / settleSeconds); // 3x -> 1x across settle window
return base * boost;
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd marketing/product-film && npx vitest run src/components/craneSwing.test.ts`
Expected: PASS (3 tests).
- [ ] **Step 5: Create `Crane.tsx`** — port the landing crane SVG (`src/pages/Landing.jsx:981-1079`) with the dark-theme `--crane-line-*` values substituted as literals (strong `rgba(255,255,255,0.16)`, medium `0.10`, subtle `0.06`, surface `0.03`).
```tsx
// Blueprint tower-crane line art, ported from src/pages/Landing.jsx (dark-theme colors inlined).
const STRONG = "rgba(255,255,255,0.16)";
const MEDIUM = "rgba(255,255,255,0.10)";
const SUBTLE = "rgba(255,255,255,0.06)";
const SURFACE = "rgba(255,255,255,0.03)";
export const Crane: React.FC<{ width?: number }> = ({ width = 1100 }) => (
<svg viewBox="0 0 800 460" fill="none" style={{ width, display: "block" }} aria-hidden>
{/* tower rails */}
<line x1="145" y1="55" x2="145" y2="460" stroke={STRONG} strokeWidth="2" />
<line x1="195" y1="55" x2="195" y2="460" stroke={STRONG} strokeWidth="2" />
{[93,131,169,207,245,283,321,359,397,435].map((y) => (
<line key={y} x1="145" y1={y} x2="195" y2={y} stroke={MEDIUM} strokeWidth="1" />
))}
{/* X-bracing */}
{[[93,131],[169,207],[245,283],[321,359],[359,397],[397,435],[435,460]].map(([a,b],i)=>(
<g key={i}>
<line x1="145" y1={a} x2="195" y2={b} stroke={SUBTLE} strokeWidth="0.75" />
<line x1="195" y1={a} x2="145" y2={b} stroke={SUBTLE} strokeWidth="0.75" />
</g>
))}
{/* slewing platform + cab */}
<rect x="135" y="55" width="70" height="18" rx="2" fill={SURFACE} stroke={MEDIUM} strokeWidth="1.5" />
<rect x="135" y="40" width="34" height="17" rx="1" fill="rgba(59,130,246,0.06)" stroke="rgba(59,130,246,0.18)" strokeWidth="1" />
<rect x="140" y="44" width="12" height="8" rx="0.5" fill="rgba(59,130,246,0.1)" stroke="rgba(59,130,246,0.22)" strokeWidth="0.5" />
{/* A-frame apex */}
<line x1="170" y1="8" x2="145" y2="55" stroke={STRONG} strokeWidth="1.5" />
<line x1="170" y1="8" x2="195" y2="55" stroke={STRONG} strokeWidth="1.5" />
<line x1="155" y1="36" x2="185" y2="36" stroke={MEDIUM} strokeWidth="1" />
{/* support cables */}
<line x1="170" y1="8" x2="30" y2="58" stroke={MEDIUM} strokeWidth="1" />
<line x1="170" y1="8" x2="720" y2="58" stroke={MEDIUM} strokeWidth="1" />
{/* counter-jib */}
<line x1="30" y1="58" x2="145" y2="58" stroke={MEDIUM} strokeWidth="1.5" />
<line x1="48" y1="70" x2="145" y2="70" stroke={MEDIUM} strokeWidth="1" />
<line x1="30" y1="58" x2="48" y2="70" stroke={MEDIUM} strokeWidth="1" />
<rect x="28" y="58" width="26" height="18" rx="1" fill="rgba(59,130,246,0.08)" stroke="rgba(59,130,246,0.2)" strokeWidth="1" />
<rect x="58" y="60" width="18" height="14" rx="1" fill="rgba(59,130,246,0.05)" stroke="rgba(59,130,246,0.14)" strokeWidth="0.75" />
{/* main jib */}
<line x1="195" y1="58" x2="720" y2="58" stroke={STRONG} strokeWidth="2" />
<line x1="195" y1="70" x2="720" y2="70" stroke={MEDIUM} strokeWidth="1.5" />
<line x1="720" y1="58" x2="720" y2="70" stroke={MEDIUM} strokeWidth="1" />
{[[215,260],[280,325],[345,390],[410,455],[475,520],[540,585],[605,650],[670,715]].map(([a,b],i)=>(
<line key={i} x1={a} y1="70" x2={b} y2="58" stroke={SUBTLE} strokeWidth="0.75" />
))}
{/* trolley + hoist cable + hook */}
<rect x="390" y="70" width="20" height="10" rx="1" fill="rgba(59,130,246,0.1)" stroke="rgba(59,130,246,0.28)" strokeWidth="1" />
<line x1="400" y1="80" x2="400" y2="430" stroke="rgba(59,130,246,0.28)" strokeWidth="1.5" strokeDasharray="6 4" />
<rect x="394" y="430" width="12" height="8" rx="2" fill="rgba(59,130,246,0.1)" stroke="rgba(59,130,246,0.3)" strokeWidth="1" />
<path d="M400 438 C400 453 392 453 392 448" stroke="rgba(59,130,246,0.4)" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
);
```
- [ ] **Step 6: Create `CraneCard.tsx`** — the CTA card that descends on the cable and settles into the swing (verbatim landing copy).
```tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
import { theme } from "../theme";
import { swingRotation } from "./craneSwing";
export const CraneCard: React.FC<{ dropStartFrame?: number }> = ({ dropStartFrame = 0 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const local = frame - dropStartFrame;
// descend into place over the first ~1.2s
const drop = spring({ frame: local, fps, durationInFrames: 36, config: { damping: 14, mass: 0.8 } });
const dropY = interpolate(drop, [0, 1], [-220, 0]);
const rotate = swingRotation(Math.max(0, local), fps);
return (
<div
style={{
position: "relative",
transformOrigin: "top center",
transform: `translateY(${dropY}px) rotate(${rotate}deg)`,
width: 760,
background: "rgba(255,255,255,0.03)",
backdropFilter: "blur(8px)",
borderRadius: 24,
border: `1px solid ${theme.border}`,
boxShadow: "0 30px 90px rgba(0,0,0,0.55)",
padding: "56px 64px",
textAlign: "center",
fontFamily: theme.font,
}}
>
{/* attachment point connecting to the cable */}
<div style={{ position: "absolute", top: -14, left: "50%", transform: "translateX(-50%)", width: 26, height: 26, borderRadius: 999, background: theme.bg, border: "1px solid rgba(59,130,246,0.25)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ width: 9, height: 9, borderRadius: 999, background: "rgba(59,130,246,0.5)" }} />
</div>
<div style={{ display: "inline-block", padding: "6px 14px", borderRadius: 999, border: "1px solid rgba(16,185,129,0.3)", color: theme.emerald, fontSize: 18, fontWeight: 700, marginBottom: 24 }}> Project Ready</div>
<div style={{ fontSize: 64, fontWeight: 900, color: theme.text, letterSpacing: "-0.02em", marginBottom: 14 }}>Ready to Build?</div>
<div style={{ fontSize: 26, color: theme.textDim, maxWidth: 460, margin: "0 auto 36px", lineHeight: 1.4 }}>
Whether you protect one home or manage a thousand roofs, the future of roofing starts here.
</div>
<div style={{ display: "flex", gap: 18, justifyContent: "center" }}>
<div style={{ padding: "18px 34px", borderRadius: 999, background: theme.blue, color: "white", fontWeight: 700, fontSize: 22 }}>Schedule Demo </div>
<div style={{ padding: "18px 34px", borderRadius: 999, background: "rgba(255,255,255,0.06)", border: `1px solid ${theme.border}`, color: theme.text, fontWeight: 700, fontSize: 22 }}>Start Free Trial </div>
</div>
</div>
);
};
```
- [ ] **Step 7: Commit**
```bash
git add marketing/product-film/src/components/craneSwing.ts marketing/product-film/src/components/craneSwing.test.ts marketing/product-film/src/components/Crane.tsx marketing/product-film/src/components/CraneCard.tsx
git commit -m "feat(film): crane swing curve (TDD), crane SVG, suspended CTA card"
```
---
### Task 10: AudioTrack (music bed, gated)
**Files:**
- Create: `marketing/product-film/src/components/AudioTrack.tsx`
- [ ] **Step 1: Create `AudioTrack.tsx`** (no-op until a music file is added and `HAS_MUSIC` flipped — keeps render green without the asset)
```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 = () => {
const frame = useCurrentFrame();
if (!HAS_MUSIC) return null;
// fade in at start, fade out over the last 2s
const volume = interpolate(
frame,
[0, 30, TOTAL_FRAMES - 60, TOTAL_FRAMES],
[0, 0.7, 0.7, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
);
return <Audio src={staticFile("audio/music.mp3")} volume={volume} />;
};
```
- [ ] **Step 2: Commit**
```bash
git add marketing/product-film/src/components/AudioTrack.tsx
git commit -m "feat(film): gated audio track with fade in/out"
```
---
## Phase 3 — Assembly skeleton
### Task 11: Wire all 8 sequences into the film (stubs first)
**Files:**
- Create: `marketing/product-film/src/sequences/ColdOpen.tsx` (stub)
- Create: `marketing/product-film/src/sequences/Promise.tsx` (stub)
- Create: `marketing/product-film/src/sequences/Pillar1Storms.tsx` (stub)
- Create: `marketing/product-film/src/sequences/Pillar2Dispatch.tsx` (stub)
- Create: `marketing/product-film/src/sequences/Pillar3Rails.tsx` (stub)
- Create: `marketing/product-film/src/sequences/Pillar4Control.tsx` (stub)
- Create: `marketing/product-film/src/sequences/Pillar5Profit.tsx` (stub)
- Create: `marketing/product-film/src/sequences/CraneClose.tsx` (stub)
- Modify: `marketing/product-film/src/Film.tsx`
- [ ] **Step 1: Create 8 stub sequence files**
Each stub renders a `PillarTitle` so the skeleton is legible. Create all 8 with this shape, changing `index`/`title`/`subtitle`/duration constant:
`ColdOpen.tsx`:
```tsx
import { PillarTitle } from "../components/PillarTitle";
import { DURATIONS } from "../timing";
export const ColdOpen: React.FC = () => (
<PillarTitle index="" title="Cold Open" subtitle="(stub)" startFrame={0} durationInFrames={DURATIONS.coldOpen} />
);
```
`Promise.tsx``DURATIONS.promise`, title "Promise".
`Pillar1Storms.tsx``DURATIONS.pillar1`, index "①", title "Pillar 1".
`Pillar2Dispatch.tsx``DURATIONS.pillar2`, index "②", title "Pillar 2".
`Pillar3Rails.tsx``DURATIONS.pillar3`, index "③", title "Pillar 3".
`Pillar4Control.tsx``DURATIONS.pillar4`, index "④", title "Pillar 4".
`Pillar5Profit.tsx``DURATIONS.pillar5`, index "⑤", title "Pillar 5".
`CraneClose.tsx``DURATIONS.craneClose`, title "Crane Close".
(Each is the same 6 lines with substituted props.)
- [ ] **Step 2: Replace `Film.tsx` with the real assembly**
```tsx
import { AbsoluteFill, Series } from "remotion";
import { DURATIONS } from "./timing";
import { theme } from "./theme";
import { AudioTrack } from "./components/AudioTrack";
import { ColdOpen } from "./sequences/ColdOpen";
import { Promise as PromiseSeq } from "./sequences/Promise";
import { Pillar1Storms } from "./sequences/Pillar1Storms";
import { Pillar2Dispatch } from "./sequences/Pillar2Dispatch";
import { Pillar3Rails } from "./sequences/Pillar3Rails";
import { Pillar4Control } from "./sequences/Pillar4Control";
import { Pillar5Profit } from "./sequences/Pillar5Profit";
import { CraneClose } from "./sequences/CraneClose";
export const Film: React.FC = () => {
return (
<AbsoluteFill style={{ backgroundColor: theme.bg }}>
<Series>
<Series.Sequence durationInFrames={DURATIONS.coldOpen}><ColdOpen /></Series.Sequence>
<Series.Sequence durationInFrames={DURATIONS.promise}><PromiseSeq /></Series.Sequence>
<Series.Sequence durationInFrames={DURATIONS.pillar1}><Pillar1Storms /></Series.Sequence>
<Series.Sequence durationInFrames={DURATIONS.pillar2}><Pillar2Dispatch /></Series.Sequence>
<Series.Sequence durationInFrames={DURATIONS.pillar3}><Pillar3Rails /></Series.Sequence>
<Series.Sequence durationInFrames={DURATIONS.pillar4}><Pillar4Control /></Series.Sequence>
<Series.Sequence durationInFrames={DURATIONS.pillar5}><Pillar5Profit /></Series.Sequence>
<Series.Sequence durationInFrames={DURATIONS.craneClose}><CraneClose /></Series.Sequence>
</Series>
<AudioTrack />
</AbsoluteFill>
);
};
```
> Note: `Promise` is a JS global, so it's imported `as PromiseSeq`.
- [ ] **Step 3: Verify the full skeleton renders end-to-end**
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/skeleton-mid.png --frame=4000`
Expected: a stub title card renders (whichever sequence frame 4000 lands in). No error.
Run: `cd marketing/product-film && npx remotion render LynkedUpProFilm out/skeleton.mp4 --frames=0-90`
Expected: a short mp4 renders without error (proves the Series wiring + duration are valid).
- [ ] **Step 4: Commit**
```bash
git add marketing/product-film/src/sequences marketing/product-film/src/Film.tsx
git commit -m "feat(film): assemble 8-sequence series skeleton"
```
---
## Phase 4 — Build each sequence
> Pattern for every sequence: a short `PillarTitle` intro (first ~2.5s), then one or more `ScreenFrame` captures under a `KenBurns`, with a `Cursor` choreographing the key action and `Caption`s carrying the message (caption text mirrors the spec §5 VO lines). Cursor coordinates are in 1920×1080 canvas space; pick targets over the relevant UI region in the capture. All frame numbers below are **local** to the sequence (the `Series.Sequence` resets frame to 0).
### Task 12: ColdOpen + Promise sequences
**Files:**
- Modify: `marketing/product-film/src/sequences/ColdOpen.tsx`
- Modify: `marketing/product-film/src/sequences/Promise.tsx`
- [ ] **Step 1: Implement `ColdOpen.tsx`** (landing-hero motif + pain caption)
```tsx
import { AbsoluteFill, useCurrentFrame, interpolate } from "remotion";
import { BlueprintBg } from "../components/BlueprintBg";
import { Caption } from "../components/Caption";
import { theme } from "../theme";
import { DURATIONS } from "../timing";
export const ColdOpen: React.FC = () => {
const frame = useCurrentFrame();
const hud = interpolate(frame, [10, 30], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill>
<BlueprintBg opacity={0.5} />
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center", flexDirection: "column" }}>
<div style={{ fontFamily: theme.font, fontWeight: 900, fontSize: 130, color: theme.text, letterSpacing: "-0.03em", lineHeight: 0.95, textAlign: "center" }}>
ROOFING<br />
<span style={{ background: theme.brandGradient, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }}>REVOLUTIONIZED</span>
</div>
</AbsoluteFill>
{/* HUD annotations echoing the landing hero */}
<div style={{ position: "absolute", bottom: 80, left: 80, opacity: hud, borderLeft: `1px solid ${theme.cyan}`, paddingLeft: 14, fontFamily: theme.mono, color: theme.cyan, fontSize: 22 }}>
5,847 PROPERTIES TRACKED
</div>
<div style={{ position: "absolute", bottom: 80, right: 80, opacity: hud, borderRight: `1px solid ${theme.amber}`, paddingRight: 14, textAlign: "right", fontFamily: theme.mono, color: theme.amber, fontSize: 22 }}>
HAIL WATCH ACTIVE · ETA 35 MIN
</div>
<Caption text="A storm hits. The leads flood in. Then what?" startFrame={Math.round(DURATIONS.coldOpen * 0.45)} durationInFrames={Math.round(DURATIONS.coldOpen * 0.5)} />
</AbsoluteFill>
);
};
```
- [ ] **Step 2: Implement `Promise.tsx`** (logo/tagline beat)
```tsx
import { AbsoluteFill, useCurrentFrame, useVideoConfig, spring, interpolate } from "remotion";
import { BlueprintBg } from "../components/BlueprintBg";
import { theme } from "../theme";
import { DURATIONS } from "../timing";
export const Promise: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const rise = spring({ frame, fps, durationInFrames: 24, config: { damping: 200 } });
const y = interpolate(rise, [0, 1], [30, 0]);
const opacity = interpolate(frame, [0, 14, DURATIONS.promise - 12, DURATIONS.promise], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<AbsoluteFill>
<BlueprintBg opacity={0.4} />
<AbsoluteFill style={{ alignItems: "center", justifyContent: "center", opacity }}>
<div style={{ transform: `translateY(${y}px)`, textAlign: "center", fontFamily: theme.font }}>
<div style={{ fontSize: 76, fontWeight: 900, color: theme.text, letterSpacing: "-0.02em" }}>
LynkedUp <span style={{ background: theme.brandGradient, WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent" }}>Pro</span>
</div>
<div style={{ fontSize: 40, color: theme.textDim, marginTop: 18, fontWeight: 600 }}>From the storm to the bank in one system.</div>
<div style={{ fontSize: 28, color: theme.blue, marginTop: 10, fontWeight: 700 }}>Your team runs it. You control all of it.</div>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
```
- [ ] **Step 3: Verify both render**
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/coldopen.png --frame=200`
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/promise.png --frame=380`
Expected: cold-open hero (frame ~200) and promise card (frame ~380) render. No error.
- [ ] **Step 4: Commit**
```bash
git add marketing/product-film/src/sequences/ColdOpen.tsx marketing/product-film/src/sequences/Promise.tsx
git commit -m "feat(film): cold open + promise sequences"
```
---
### Task 13: Pillar 1 — Turn storms into pipeline
**Files:**
- Modify: `marketing/product-film/src/sequences/Pillar1Storms.tsx`
- [ ] **Step 1: Implement `Pillar1Storms.tsx`**
```tsx
import { AbsoluteFill, Sequence } from "remotion";
import { PillarTitle } from "../components/PillarTitle";
import { ScreenFrame } from "../components/ScreenFrame";
import { KenBurns } from "../components/KenBurns";
import { Cursor } from "../components/Cursor";
import { Caption } from "../components/Caption";
import { DURATIONS } from "../timing";
const D = DURATIONS.pillar1;
const TITLE = 75; // 2.5s
export const Pillar1Storms: React.FC = () => {
const afterTitle = D - TITLE;
const half = Math.floor(afterTitle / 2);
return (
<AbsoluteFill>
<Sequence durationInFrames={TITLE}>
<PillarTitle index="①" title="Turn storms into pipeline" subtitle="See the storm before your competition" startFrame={0} durationInFrames={TITLE} />
</Sequence>
{/* Beat A: Storm Intel — zones light up; cursor assigns a zone */}
<Sequence from={TITLE} durationInFrames={half}>
<KenBurns durationInFrames={half}><ScreenFrame captureId="storm-intel" /></KenBurns>
<Cursor from={{ x: 1400, y: 300 }} to={{ x: 1480, y: 520 }} moveStartFrame={20} clickAtFrame={44} />
<Caption text="Storm Intel maps the damage the moment it lands." startFrame={10} durationInFrames={half - 20} />
</Sequence>
{/* Beat B: Lead Verification — verify a lead */}
<Sequence from={TITLE + half} durationInFrames={afterTitle - half}>
<KenBurns durationInFrames={afterTitle - half}><ScreenFrame captureId="lead-verification" /></KenBurns>
<Cursor from={{ x: 900, y: 400 }} to={{ x: 1500, y: 500 }} moveStartFrame={18} clickAtFrame={40} />
<Caption text="Every lead is verified before it reaches your pipeline." startFrame={10} durationInFrames={afterTitle - half - 20} />
</Sequence>
</AbsoluteFill>
);
};
```
- [ ] **Step 2: Verify it renders**
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p1.png --frame=750`
Expected: Pillar 1 storm-intel beat with cursor + caption. No error.
- [ ] **Step 3: Commit**
```bash
git add marketing/product-film/src/sequences/Pillar1Storms.tsx
git commit -m "feat(film): pillar 1 — storms into pipeline"
```
---
### Task 14: Pillar 2 — Right person on every job
**Files:**
- Modify: `marketing/product-film/src/sequences/Pillar2Dispatch.tsx`
- [ ] **Step 1: Implement `Pillar2Dispatch.tsx`**
```tsx
import { AbsoluteFill, Sequence } from "remotion";
import { PillarTitle } from "../components/PillarTitle";
import { ScreenFrame } from "../components/ScreenFrame";
import { KenBurns } from "../components/KenBurns";
import { Cursor } from "../components/Cursor";
import { Caption } from "../components/Caption";
import { DURATIONS } from "../timing";
const D = DURATIONS.pillar2;
const TITLE = 75;
export const Pillar2Dispatch: React.FC = () => {
const afterTitle = D - TITLE;
const half = Math.floor(afterTitle / 2);
return (
<AbsoluteFill>
<Sequence durationInFrames={TITLE}>
<PillarTitle index="②" title="The right person, automatically" subtitle="AI picks the rep — proximity + skill" startFrame={0} durationInFrames={TITLE} />
</Sequence>
{/* Beat A: LynkDispatch AI recommendation; cursor assigns */}
<Sequence from={TITLE} durationInFrames={half}>
<KenBurns durationInFrames={half}><ScreenFrame captureId="dispatch" /></KenBurns>
<Cursor from={{ x: 700, y: 350 }} to={{ x: 1430, y: 430 }} moveStartFrame={20} clickAtFrame={46} />
<Caption text="LynkDispatch matches each lead to the closest, best-skilled rep." startFrame={10} durationInFrames={half - 20} />
</Sequence>
{/* Beat B: Pipeline kanban — move a deal forward */}
<Sequence from={TITLE + half} durationInFrames={afterTitle - half}>
<KenBurns durationInFrames={afterTitle - half}><ScreenFrame captureId="kanban" /></KenBurns>
<Cursor from={{ x: 560, y: 500 }} to={{ x: 1180, y: 520 }} moveStartFrame={16} clickAtFrame={12} />
<Caption text="Deals move through the pipeline instead of going cold." startFrame={10} durationInFrames={afterTitle - half - 20} />
</Sequence>
</AbsoluteFill>
);
};
```
- [ ] **Step 2: Verify it renders**
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p2.png --frame=2050`
Expected: Pillar 2 dispatch/kanban beat. No error.
- [ ] **Step 3: Commit**
```bash
git add marketing/product-film/src/sequences/Pillar2Dispatch.tsx
git commit -m "feat(film): pillar 2 — right person, automatically"
```
---
### Task 15: Pillar 3 — Every job runs on rails
**Files:**
- Modify: `marketing/product-film/src/sequences/Pillar3Rails.tsx`
- [ ] **Step 1: Implement `Pillar3Rails.tsx`** (3 beats: project lifecycle → estimates → sub portal)
```tsx
import { AbsoluteFill, Sequence } from "remotion";
import { PillarTitle } from "../components/PillarTitle";
import { ScreenFrame } from "../components/ScreenFrame";
import { KenBurns } from "../components/KenBurns";
import { Cursor } from "../components/Cursor";
import { Caption } from "../components/Caption";
import { DURATIONS } from "../timing";
const D = DURATIONS.pillar3;
const TITLE = 75;
export const Pillar3Rails: React.FC = () => {
const afterTitle = D - TITLE;
const beat = Math.floor(afterTitle / 3);
const last = afterTitle - beat * 2;
return (
<AbsoluteFill>
<Sequence durationInFrames={TITLE}>
<PillarTitle index="③" title="Every job runs on rails" subtitle="From inspection to estimate to crew" startFrame={0} durationInFrames={TITLE} />
</Sequence>
<Sequence from={TITLE} durationInFrames={beat}>
<KenBurns durationInFrames={beat}><ScreenFrame captureId="projects" /></KenBurns>
<Cursor from={{ x: 800, y: 360 }} to={{ x: 1300, y: 440 }} moveStartFrame={16} clickAtFrame={38} />
<Caption text="Every job on a clear track." startFrame={8} durationInFrames={beat - 16} />
</Sequence>
<Sequence from={TITLE + beat} durationInFrames={beat}>
<KenBurns durationInFrames={beat}><ScreenFrame captureId="estimates" /></KenBurns>
<Cursor from={{ x: 900, y: 400 }} to={{ x: 1450, y: 470 }} moveStartFrame={16} clickAtFrame={38} />
<Caption text="Inspections and estimates, built in." startFrame={8} durationInFrames={beat - 16} />
</Sequence>
{/* Sub portal — sees only their task */}
<Sequence from={TITLE + beat * 2} durationInFrames={last}>
<KenBurns durationInFrames={last}><ScreenFrame captureId="sub-dashboard" /></KenBurns>
<Cursor from={{ x: 700, y: 380 }} to={{ x: 1200, y: 460 }} moveStartFrame={16} clickAtFrame={38} />
<Caption text="Subcontractors get a focused portal — just their task, nothing else." startFrame={8} durationInFrames={last - 16} />
</Sequence>
</AbsoluteFill>
);
};
```
- [ ] **Step 2: Verify it renders**
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p3.png --frame=3400`
Expected: Pillar 3 beat renders. No error.
- [ ] **Step 3: Commit**
```bash
git add marketing/product-film/src/sequences/Pillar3Rails.tsx
git commit -m "feat(film): pillar 3 — every job runs on rails"
```
---
### Task 16: Pillar 4 — Scale with control (access boundaries)
**Files:**
- Modify: `marketing/product-film/src/sequences/Pillar4Control.tsx`
- [ ] **Step 1: Implement `Pillar4Control.tsx`** (owner sets permission → switch to rep view → boundary visible; then sub portal)
```tsx
import { AbsoluteFill, Sequence } from "remotion";
import { PillarTitle } from "../components/PillarTitle";
import { ScreenFrame } from "../components/ScreenFrame";
import { KenBurns } from "../components/KenBurns";
import { Cursor } from "../components/Cursor";
import { Caption } from "../components/Caption";
import { DURATIONS } from "../timing";
const D = DURATIONS.pillar4;
const TITLE = 75;
export const Pillar4Control: React.FC = () => {
const afterTitle = D - TITLE;
const beat = Math.floor(afterTitle / 3);
const last = afterTitle - beat * 2;
return (
<AbsoluteFill>
<Sequence durationInFrames={TITLE}>
<PillarTitle index="④" title="Scale with control" subtitle="You decide who sees what" startFrame={0} durationInFrames={TITLE} />
</Sequence>
{/* Owner toggles a module permission */}
<Sequence from={TITLE} durationInFrames={beat}>
<KenBurns durationInFrames={beat}><ScreenFrame captureId="settings-access" /></KenBurns>
<Cursor from={{ x: 1000, y: 380 }} to={{ x: 1480, y: 520 }} moveStartFrame={16} clickAtFrame={40} />
<Caption text="Set permissions by role or by person." startFrame={8} durationInFrames={beat - 16} />
</Sequence>
{/* Switch to the rep's view — Financials gone, only their leads */}
<Sequence from={TITLE + beat} durationInFrames={beat}>
<KenBurns durationInFrames={beat}><ScreenFrame captureId="rep-leads" /></KenBurns>
<Caption text="The rep sees only their own leads — no more, no less." startFrame={8} durationInFrames={beat - 16} />
</Sequence>
{/* Sub sees only their jobs */}
<Sequence from={TITLE + beat * 2} durationInFrames={last}>
<KenBurns durationInFrames={last}><ScreenFrame captureId="sub-projects" /></KenBurns>
<Caption text="The crew sees only their job. You see everything." startFrame={8} durationInFrames={last - 16} />
</Sequence>
</AbsoluteFill>
);
};
```
- [ ] **Step 2: Verify it renders**
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p4.png --frame=4900`
Expected: Pillar 4 beat renders. No error.
- [ ] **Step 3: Commit**
```bash
git add marketing/product-film/src/sequences/Pillar4Control.tsx
git commit -m "feat(film): pillar 4 — scale with control (access boundaries)"
```
---
### Task 17: Pillar 5 — Know your true profit
**Files:**
- Modify: `marketing/product-film/src/sequences/Pillar5Profit.tsx`
- [ ] **Step 1: Implement `Pillar5Profit.tsx`** (financials → leaderboard; storm-attribution bookend in caption)
```tsx
import { AbsoluteFill, Sequence } from "remotion";
import { PillarTitle } from "../components/PillarTitle";
import { ScreenFrame } from "../components/ScreenFrame";
import { KenBurns } from "../components/KenBurns";
import { Cursor } from "../components/Cursor";
import { Caption } from "../components/Caption";
import { DURATIONS } from "../timing";
const D = DURATIONS.pillar5;
const TITLE = 75;
export const Pillar5Profit: React.FC = () => {
const afterTitle = D - TITLE;
const half = Math.floor(afterTitle / 2);
return (
<AbsoluteFill>
<Sequence durationInFrames={TITLE}>
<PillarTitle index="⑤" title="Know your true profit" subtitle="Real margin on every job" startFrame={0} durationInFrames={TITLE} />
</Sequence>
{/* Financial overview — drill into a job */}
<Sequence from={TITLE} durationInFrames={half}>
<KenBurns durationInFrames={half}><ScreenFrame captureId="owner-snapshot" /></KenBurns>
<Cursor from={{ x: 700, y: 360 }} to={{ x: 1250, y: 430 }} moveStartFrame={18} clickAtFrame={42} />
<Caption text="Revenue recognized job by job, on a true cost-to-cost basis." startFrame={10} durationInFrames={half - 20} />
</Sequence>
{/* Leaderboard — storm attribution bookend */}
<Sequence from={TITLE + half} durationInFrames={afterTitle - half}>
<KenBurns durationInFrames={afterTitle - half}><ScreenFrame captureId="leaderboard" /></KenBurns>
<Caption text="And every dollar traces back to the storm that started it." startFrame={10} durationInFrames={afterTitle - half - 20} />
</Sequence>
</AbsoluteFill>
);
};
```
- [ ] **Step 2: Verify it renders**
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/p5.png --frame=6300`
Expected: Pillar 5 financial beat renders. No error.
- [ ] **Step 3: Commit**
```bash
git add marketing/product-film/src/sequences/Pillar5Profit.tsx
git commit -m "feat(film): pillar 5 — know your true profit"
```
---
### Task 18: CraneClose sequence
**Files:**
- Modify: `marketing/product-film/src/sequences/CraneClose.tsx`
- [ ] **Step 1: Implement `CraneClose.tsx`** (crane lowers the swinging CTA card; end plate)
```tsx
import { AbsoluteFill } from "remotion";
import { BlueprintBg } from "../components/BlueprintBg";
import { Crane } from "../components/Crane";
import { CraneCard } from "../components/CraneCard";
import { theme } from "../theme";
export const CraneClose: React.FC = () => {
return (
<AbsoluteFill>
<BlueprintBg opacity={0.5} />
{/* crane anchored top; card hangs from its hoist cable */}
<AbsoluteFill style={{ alignItems: "center" }}>
<div style={{ marginTop: -10 }}>
<Crane width={1180} />
</div>
</AbsoluteFill>
<AbsoluteFill style={{ alignItems: "center", justifyContent: "flex-start", marginTop: 470 }}>
<CraneCard dropStartFrame={6} />
</AbsoluteFill>
{/* end plate */}
<div style={{ position: "absolute", bottom: 48, width: "100%", textAlign: "center", fontFamily: theme.mono, color: theme.textDim, fontSize: 24 }}>
LynkedUpPro.com · 866-259-6533
</div>
</AbsoluteFill>
);
};
```
- [ ] **Step 2: Verify it renders (card descended + swinging)**
Run: `cd marketing/product-film && npx remotion still LynkedUpProFilm out/crane-mid.png --frame=7900`
Expected: the crane with the "Ready to Build?" card suspended from the cable, rotated slightly (mid-swing). No error.
- [ ] **Step 3: Commit**
```bash
git add marketing/product-film/src/sequences/CraneClose.tsx
git commit -m "feat(film): crane CTA close (swinging suspended card)"
```
---
## Phase 5 — Audio + final render
### Task 19: Add music + enable audio
**Files:**
- Add (by hand): `marketing/product-film/public/audio/music.mp3`
- Modify: `marketing/product-film/src/components/AudioTrack.tsx`
- [ ] **Step 1: Place a royalty-free track**
Drop a licensed/royalty-free cinematic-corporate track (≥ 4:30 long) at `marketing/product-film/public/audio/music.mp3`.
> If no track is available at execution time, **skip this task** — the film renders silently (captions carry the message) and music can be added later. Note the skip in the task report.
- [ ] **Step 2: Enable audio**
In `src/components/AudioTrack.tsx`, change:
```tsx
export const HAS_MUSIC = false;
```
to:
```tsx
export const HAS_MUSIC = true;
```
- [ ] **Step 3: Verify audio is referenced without render error**
Run: `cd marketing/product-film && npx remotion render LynkedUpProFilm out/audio-check.mp4 --frames=0-60`
Expected: short clip renders; the output has an audio track. No error.
- [ ] **Step 4: Commit**
```bash
git add marketing/product-film/src/components/AudioTrack.tsx
git commit -m "feat(film): enable music bed"
```
---
### Task 20: Full render + README
**Files:**
- Create: `marketing/product-film/README.md`
- [ ] **Step 1: Run the capture pipeline (if not already current)**
Start the CRM (`npm run dev` from repo root), then:
Run: `cd marketing/product-film && npm run capture`
Expected: 17 fresh captures in `public/captures/`.
- [ ] **Step 2: Run the unit tests**
Run: `cd marketing/product-film && npm test`
Expected: all Vitest suites pass (timing, captures, craneSwing).
- [ ] **Step 3: Render the full film**
Run: `cd marketing/product-film && npm run render`
Expected: `out/lynkedup-pro-film.mp4` (~4:255:00, 1920×1080, 30fps) renders with no error. Spot-check: cold-open hero, a captured pillar screen with cursor, the rep/sub boundary beats in Pillar 4, and the swinging crane CTA at the end.
- [ ] **Step 4: Write `README.md`**
````markdown
# LynkedUp Pro — Product Film (Remotion)
Flagship ~5-min marketing film, composed from real app screenshots + animated brand beats.
See `docs/superpowers/specs/2026-05-30-product-film-design.md` for the design.
## Workflow
1. **Capture** the app (dark mode). From the repo root, start the CRM:
```
npm run dev # serves http://localhost:5173
```
Then in this folder:
```
npm install
npx playwright install chromium # first time only
npm run capture # writes public/captures/*.png
```
2. **Preview** in Remotion Studio:
```
npm run dev
```
3. **Render**:
```
npm run render # -> out/lynkedup-pro-film.mp4
```
## Notes
- `public/captures/` and `out/` are gitignored (local artifacts). Re-run `npm run capture` to refresh.
- Music: drop a royalty-free track at `public/audio/music.mp3` and set `HAS_MUSIC = true` in `src/components/AudioTrack.tsx`.
- Voiceover: captions ship now; VO script lives in the design spec §5 (record later, drop onto the same timeline).
- Does **not** modify any CRM code — it only screenshots the running app.
````
- [ ] **Step 5: Commit**
```bash
git add marketing/product-film/README.md
git commit -m "docs(film): capture/render workflow readme"
```
---
## Self-Review
**1. Spec coverage** (against `2026-05-30-product-film-design.md`):
- §4 structure (Cold Open → Promise → 5 pillars → Crane close) → Tasks 1218. ✓
- §5 beat sheet captions/VO → caption text in each sequence task mirrors the §5 lines. ✓
- §6 feature→pillar coverage → manifest (Task 3) captures every screen; pillars reference them. Storm Intel, Territory Map (`territory-map`), Pro Canvas (`pro-canvas`), Lead Verification ✓①; Dispatch, People/skills, Kanban ✓②; Projects, Estimates, Sub tasks/portal ✓③; Settings/access, rep & sub boundaries, people ✓④; Snapshot, Leaderboard ✓⑤. *(Note: `territory-map`, `pro-canvas`, `people-skills`, `sub-tasks-owner`, `rep-dashboard` are captured and available; the sequence tasks above feature a representative subset per pillar to hold ~5 min. Additional captured screens can be slotted into a pillar's KenBurns beats without new capture work.)*
- §7.1 capture pipeline → Task 4 (dark mode, role logins, manifest-driven). ✓
- §7.2 Remotion structure/components → Tasks 111. ✓
- §7.3 audio (captions+music, VO later) → Tasks 10, 19; captions in all sequences. ✓
- §7.4 render 1080p/30fps → Root composition (Task 2) + Task 20. ✓
- Crane close reproduces `craneSwing` (±3°, 4s, top-center, damped entry) → Task 9 (TDD) + Task 18. ✓
- No CRM code edits → all paths under `marketing/product-film/`. ✓
**2. Placeholder scan:** No "TBD/TODO". Every code step has complete code. The only conditional is Task 19 (skippable if no music asset) — that's an explicit, real fallback (`HAS_MUSIC` flag), not a placeholder.
**3. Type/name consistency:** `swingRotation(frame, fps, settleSeconds)` consistent (Task 9 → CraneCard). `validateManifest`/`Manifest`/`Capture` consistent (Task 3). `DURATIONS`/`SEQUENCE_ORDER`/`TOTAL_FRAMES` consistent (Task 2 → Root, Film, sequences). `captureId` prop on `ScreenFrame` matches manifest `id`s. `Promise` sequence exported as `Promise`, imported `as PromiseSeq` (Task 11) — consistent. Cursor prop names (`from`,`to`,`moveStartFrame`,`clickAtFrame`) consistent across sequences.
No gaps found.