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

71 KiB
Raw Blame History

LynkedUp Pro Flagship Product Film — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a self-contained Remotion project at marketing/product-film/ that renders a ~5-minute flagship marketing film of the LynkedUp Pro CRM — composed from real captured app screenshots plus animated brand beats — without modifying any CRM code.

Architecture: Two stages. (1) A Playwright capture script drives the running CRM (npm run dev, port 5173) in dark mode, logs in as the right role, and screenshots each screen listed in a JSON manifest into public/captures/. (2) A Remotion composition (LynkedUpProFilm) arranges those captures inside shared components (synthetic cursor, captions, screen frame, Ken Burns, an animated crane CTA reproducing the landing page's craneSwing) into 8 sequences: Cold Open → Promise → 5 value pillars → Crane CTA close. Pure logic (timing math, swing curve, manifest validation) is TDD'd with Vitest; visual components are verified by rendering a still frame; the whole film is verified by a final remotion render.

Tech Stack: Remotion 4 (React 19 + TypeScript), Vitest (unit tests for pure helpers), Playwright (capture). Isolated package.json — no dependency on the CRM build.

Source spec: docs/superpowers/specs/2026-05-30-product-film-design.md

Hard constraints:

  • Do not edit anything under src/ (CRM code). All new files live under marketing/product-film/.
  • Dark-mode capture. 1920×1080, 30fps. Captions + music now; voiceover later (script lives in the spec §5, mirrored into caption text here).
  • Commits: author is the repo's configured identity (Satyam Rastogi). Do not add any AI/Claude attribution or co-author trailer to commit messages.

File Structure

marketing/product-film/
  package.json                 # remotion, vitest, playwright; scripts: dev, render, still, capture, test
  tsconfig.json
  remotion.config.ts
  .gitignore                   # ignores public/captures/ and out/
  scripts/
    capture.mjs                # Playwright capture pipeline (drives the live CRM)
  src/
    index.ts                   # registerRoot(Root)
    Root.tsx                   # <Composition id="LynkedUpProFilm" .../>
    Film.tsx                   # <Series> of the 8 sequences + <AudioTrack/>
    timing.ts                  # FPS, WIDTH, HEIGHT, DURATIONS, TOTAL_FRAMES  (pure)
    timing.test.ts
    captures.ts                # Manifest types + validateManifest()  (pure)
    captures.test.ts
    captures.manifest.json     # list of every screenshot to capture
    theme.ts                   # brand tokens (colors, fonts, blueprint grid)
    components/
      craneSwing.ts            # swingRotation() pendulum curve  (pure)
      craneSwing.test.ts
      Caption.tsx              # animated lower-third / center caption
      ScreenFrame.tsx          # browser-chrome frame around a capture (+ placeholder fallback)
      KenBurns.tsx             # slow pan/zoom wrapper
      Cursor.tsx               # synthetic cursor: spring move + click pulse
      TypeReveal.tsx           # types text into view char-by-char
      PillarTitle.tsx          # animated section title card
      Crane.tsx                # blueprint tower-crane SVG (ported from Landing.jsx)
      CraneCard.tsx            # suspended CTA card; reproduces craneSwing
      AudioTrack.tsx           # music bed (gated by HAS_MUSIC)
      BlueprintBg.tsx          # blueprint-grid background for animated beats
    sequences/
      ColdOpen.tsx
      Promise.tsx
      Pillar1Storms.tsx
      Pillar2Dispatch.tsx
      Pillar3Rails.tsx
      Pillar4Control.tsx
      Pillar5Profit.tsx
      CraneClose.tsx
  public/
    audio/                     # music.mp3 (dropped in by hand; licensed/royalty-free)
    captures/                  # screenshots produced by scripts/capture.mjs (gitignored)
  out/                         # rendered mp4 (gitignored)
  README.md                    # capture → render workflow

Capture manifest IDs (referenced by sequences; produced by the capture script). role is one of owner | agentCody | subCarlos.

id role route purpose / pillar
storm-intel owner /owner/storm-intel ① storm map
territory-map owner /owner/maps ① territory
pro-canvas owner /owner/pro-canvas ① canvassing
lead-verification owner /lead-verification ① verify
dispatch owner /owner/dispatch ② AI dispatch
people-skills owner /owner/people ② skill scores / ④ people
kanban owner /owner/kanban ② pipeline
settings-access owner /owner/settings ④ roles + ABAC + skill catalog
projects owner /owner/projects ③ project list
estimates owner /owner/estimates ③ estimates
sub-tasks-owner owner /owner/subcontractor-tasks ③ sub tasks (owner view)
owner-snapshot owner /owner/snapshot ⑤ financials/dashboards
leaderboard owner /admin/leaderboard ⑤ leaderboard
rep-leads agentCody /emp/fa/leads ④ boundary: rep sees only own leads
rep-dashboard agentCody /emp/fa/dashboard ④ rep home (no Financials nav)
sub-dashboard subCarlos /subcontractor/dashboard ③/④ sub portal home
sub-projects subCarlos /subcontractor/projects ④ boundary: sub sees only own jobs

Routes verified against src/App.jsx. Logins verified against src/pages/Login.jsx (Owner justin, Agent LUP-1040=Cody Tatum, Sub-Con carlos=Carlos Mendoza; all password password). Theme key theme verified against src/context/ThemeContext.jsx.


Phase 0 — Scaffold & toolchain

Task 1: Scaffold the Remotion project

Files:

  • Create: marketing/product-film/package.json

  • Create: marketing/product-film/tsconfig.json

  • Create: marketing/product-film/remotion.config.ts

  • Create: marketing/product-film/.gitignore

  • Create: marketing/product-film/src/index.ts

  • Create: marketing/product-film/src/Root.tsx

  • Create: marketing/product-film/src/Film.tsx (temporary trivial body)

  • Step 1: Create package.json

{
  "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.jsx and adjust the role for 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 from fromto, plus a click pulse at clickAtFrame)

import { useCurrentFrame, useVideoConfig, spring, interpolate, AbsoluteFill } from "remotion";
import { theme } from "../theme";

type Pt = { x: number; y: number }; // pixels in the 1920x1080 canvas

export const Cursor: React.FC<{
  from: Pt;
  to: Pt;
  moveStartFrame: number;
  moveDurationFrames?: number;
  clickAtFrame?: number;
}> = ({ from, to, moveStartFrame, moveDurationFrames = 22, clickAtFrame }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const p = spring({
    frame: frame - moveStartFrame,
    fps,
    durationInFrames: moveDurationFrames,
    config: { damping: 200 },
  });
  const x = interpolate(p, [0, 1], [from.x, to.x]);
  const y = interpolate(p, [0, 1], [from.y, to.y]);

  let pulse = 0;
  if (clickAtFrame !== undefined) {
    pulse = interpolate(frame - clickAtFrame, [0, 8], [1, 0], {
      extrapolateLeft: "clamp",
      extrapolateRight: "clamp",
    });
  }

  return (
    <AbsoluteFill>
      <div style={{ position: "absolute", left: x, top: y, transform: "translate(-3px,-3px)" }}>
        {pulse > 0 && (
          <div
            style={{
              position: "absolute",
              left: -18,
              top: -18,
              width: 36,
              height: 36,
              borderRadius: 999,
              background: theme.blue,
              opacity: pulse * 0.4,
              transform: `scale(${1 + (1 - pulse)})`,
            }}
          />
        )}
        {/* arrow cursor */}
        <svg width="30" height="30" viewBox="0 0 24 24" fill="none">
          <path d="M5 3l14 8-6 1.5L10 18 5 3z" fill="white" stroke="#0c0c0e" strokeWidth="1.2" />
        </svg>
      </div>
    </AbsoluteFill>
  );
};
  • Step 2: Create TypeReveal.tsx (types text across a span of frames; for form-fill beats)
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 landing craneSwing: ±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 (strong rgba(255,255,255,0.16), medium 0.10, subtle 0.06, surface 0.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 and HAS_MUSIC flipped — 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.tsxDURATIONS.promise, title "Promise". Pillar1Storms.tsxDURATIONS.pillar1, index "①", title "Pillar 1". Pillar2Dispatch.tsxDURATIONS.pillar2, index "②", title "Pillar 2". Pillar3Rails.tsxDURATIONS.pillar3, index "③", title "Pillar 3". Pillar4Control.tsxDURATIONS.pillar4, index "④", title "Pillar 4". Pillar5Profit.tsxDURATIONS.pillar5, index "⑤", title "Pillar 5". CraneClose.tsxDURATIONS.craneClose, title "Crane Close".

(Each is the same 6 lines with substituted props.)

  • Step 2: Replace Film.tsx with 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: Promise is a JS global, so it's imported as PromiseSeq.

  • Step 3: Verify the full skeleton renders end-to-end

Run: cd marketing/product-film && npx remotion still LynkedUpProFilm out/skeleton-mid.png --frame=4000 Expected: a stub title card renders (whichever sequence frame 4000 lands in). No error.

Run: cd marketing/product-film && npx remotion render LynkedUpProFilm out/skeleton.mp4 --frames=0-90 Expected: a short mp4 renders without error (proves the Series wiring + duration are valid).

  • Step 4: Commit
git add marketing/product-film/src/sequences marketing/product-film/src/Film.tsx
git commit -m "feat(film): assemble 8-sequence series skeleton"

Phase 4 — Build each sequence

Pattern for every sequence: a short PillarTitle intro (first ~2.5s), then one or more ScreenFrame captures under a KenBurns, with a Cursor choreographing the key action and Captions carrying the message (caption text mirrors the spec §5 VO lines). Cursor coordinates are in 1920×1080 canvas space; pick targets over the relevant UI region in the capture. All frame numbers below are local to the sequence (the Series.Sequence resets frame to 0).

Task 12: ColdOpen + Promise sequences

Files:

  • Modify: marketing/product-film/src/sequences/ColdOpen.tsx

  • Modify: marketing/product-film/src/sequences/Promise.tsx

  • Step 1: Implement ColdOpen.tsx (landing-hero motif + pain caption)

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:255:00, 1920×1080, 30fps) renders with no error. Spot-check: cold-open hero, a captured pillar screen with cursor, the rep/sub boundary beats in Pillar 4, and the swinging crane CTA at the end.

  • Step 4: Write README.md
# 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 1218. ✓
  • §5 beat sheet captions/VO → caption text in each sequence task mirrors the §5 lines. ✓
  • §6 feature→pillar coverage → manifest (Task 3) captures every screen; pillars reference them. Storm Intel, Territory Map (territory-map), Pro Canvas (pro-canvas), Lead Verification ✓①; Dispatch, People/skills, Kanban ✓②; Projects, Estimates, Sub tasks/portal ✓③; Settings/access, rep & sub boundaries, people ✓④; Snapshot, Leaderboard ✓⑤. (Note: territory-map, pro-canvas, people-skills, sub-tasks-owner, rep-dashboard are captured and available; the sequence tasks above feature a representative subset per pillar to hold ~5 min. Additional captured screens can be slotted into a pillar's KenBurns beats without new capture work.)
  • §7.1 capture pipeline → Task 4 (dark mode, role logins, manifest-driven). ✓
  • §7.2 Remotion structure/components → Tasks 111. ✓
  • §7.3 audio (captions+music, VO later) → Tasks 10, 19; captions in all sequences. ✓
  • §7.4 render 1080p/30fps → Root composition (Task 2) + Task 20. ✓
  • Crane close reproduces craneSwing (±3°, 4s, top-center, damped entry) → Task 9 (TDD) + Task 18. ✓
  • No CRM code edits → all paths under marketing/product-film/. ✓

2. Placeholder scan: No "TBD/TODO". Every code step has complete code. The only conditional is Task 19 (skippable if no music asset) — that's an explicit, real fallback (HAS_MUSIC flag), not a placeholder.

3. Type/name consistency: swingRotation(frame, fps, settleSeconds) consistent (Task 9 → CraneCard). validateManifest/Manifest/Capture consistent (Task 3). DURATIONS/SEQUENCE_ORDER/TOTAL_FRAMES consistent (Task 2 → Root, Film, sequences). captureId prop on ScreenFrame matches manifest 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.