71 KiB
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 undermarketing/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 againstsrc/pages/Login.jsx(Ownerjustin, AgentLUP-1040=Cody Tatum, Sub-Concarlos=Carlos Mendoza; all passwordpassword). Theme keythemeverified againstsrc/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
{
"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
{
"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
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
import { registerRoot } from "remotion";
import { Root } from "./Root";
registerRoot(Root);
- Step 6: Create a trivial
src/Film.tsx(replaced in Task 11)
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
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
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
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
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:
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
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
{
"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
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
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
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
// 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.jsxand adjust therolefor that capture.
- Step 5: Commit (script only — captures are gitignored)
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
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
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
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:
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
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
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)
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
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 fromfrom→to, plus a click pulse atclickAtFrame)
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(typestextacross a span of frames; for form-fill beats)
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
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
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
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
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 landingcraneSwing: ±3° / 4s, with a damped entry)
// 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 (strongrgba(255,255,255,0.16), medium0.10, subtle0.06, surface0.03).
// 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).
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
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 andHAS_MUSICflipped — keeps render green without the asset)
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
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:
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.tsxwith the real assembly
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:
Promiseis a JS global, so it's importedas 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
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
PillarTitleintro (first ~2.5s), then one or moreScreenFramecaptures under aKenBurns, with aCursorchoreographing the key action andCaptions 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 (theSeries.Sequenceresets 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)
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)
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
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
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
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
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
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)
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
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)
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
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)
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
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)
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
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:
export const HAS_MUSIC = false;
to:
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
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
# 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
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-dashboardare 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 ids. Promise sequence exported as Promise, imported as PromiseSeq (Task 11) — consistent. Cursor prop names (from,to,moveStartFrame,clickAtFrame) consistent across sequences.
No gaps found.