diff --git a/docs/superpowers/plans/2026-05-30-product-film.md b/docs/superpowers/plans/2026-05-30-product-film.md new file mode 100644 index 0000000..dc1ccf5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-product-film.md @@ -0,0 +1,1858 @@ +# 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