# 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 # Film.tsx # of the 8 sequences + 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 (

LynkedUp Pro Film

); }; ``` - [ ] **Step 7: Create `src/Root.tsx`** ```tsx import { Composition } from "remotion"; import { Film } from "./Film"; export const Root: React.FC = () => { return ( ); }; ``` - [ ] **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 = { 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 ( ); }; ``` - [ ] **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(); 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 -> ...` 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 ( ); }; ``` - [ ] **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 (
{text}
); }; ``` - [ ] **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 = () => ( ); ``` 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
{children}
; }; ``` - [ ] **Step 2: Create `ScreenFrame.tsx`** (native `` 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 (
{/* browser chrome bar */}
app.lynkeduppro.com
{errored ? (
[capture missing: {captureId}]
) : ( setErrored(true)} style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "top center", display: "block" }} /> )}
); }; ``` - [ ] **Step 3: Verify it renders** Temporarily set `src/Film.tsx` to `` 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 (
{pulse > 0 && (
)} {/* arrow cursor */}
); }; ``` - [ ] **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 ( {visible} {shown < text.length && blinkOn ? | : null} ); }; ``` - [ ] **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 (
{index}
{title}
{subtitle}
); }; ``` - [ ] **Step 2: Verify it renders** Temporarily set `src/Film.tsx` to `` 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 }) => ( {/* tower rails */} {[93,131,169,207,245,283,321,359,397,435].map((y) => ( ))} {/* X-bracing */} {[[93,131],[169,207],[245,283],[321,359],[359,397],[397,435],[435,460]].map(([a,b],i)=>( ))} {/* slewing platform + cab */} {/* A-frame apex */} {/* support cables */} {/* counter-jib */} {/* main jib */} {[[215,260],[280,325],[345,390],[410,455],[475,520],[540,585],[605,650],[670,715]].map(([a,b],i)=>( ))} {/* trolley + hoist cable + hook */} ); ``` - [ ] **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 (
{/* attachment point connecting to the cable */}
✓ Project Ready
Ready to Build?
Whether you protect one home or manage a thousand roofs, the future of roofing starts here.
Schedule Demo →
Start Free Trial →
); }; ``` - [ ] **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