1859 lines
71 KiB
Markdown
1859 lines
71 KiB
Markdown
# 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:25–5: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 12–18. ✓
|
||
- §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 1–11. ✓
|
||
- §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.
|