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

53 KiB
Raw Blame History

Live 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 ~1516 min live-interaction product film by capturing the real CRM with Playwright video + an injected synthetic cursor, then stitching the clips in Remotion with captions, music, and the swinging-crane bookend.

Architecture: Two stages. (1) Capture — Playwright drives the running CRM (local dev, real APIs), an injected on-screen cursor performs scripted moves/clicks, each beat is recorded to .webm, converted to constant-30fps .mp4 via ffmpeg-static, and its real duration is written into src/clips.manifest.json. (2) Compose — a Remotion LiveFilm composition reads the manifest and lays out each clip via <OffthreadVideo> with caption overlays and a zoom-punch, bookended by the existing landing/crane components. Pure logic (manifest validation, cursor easing, duration math, caption/zoom mapping) is TDD'd with vitest; browser scenes are verified by running capture and asserting the produced clip.

Tech Stack: Remotion 4.0.286 (React 19 + TypeScript), <OffthreadVideo>; Playwright 1.49.1; ffmpeg-static; vitest 2.1.8. All work lives under marketing/product-film/ except the already-done AI-chat change in src/components/Chatbot.jsx (spec §9).

Spec: docs/superpowers/specs/2026-05-30-live-product-film-design.md


Conventions used throughout

  • Working dir for all npm/node commands: marketing/product-film/.
  • CRM dev server must be running first from the repo root: npm run devhttp://localhost:5173 (this serves the real /api handlers via the vite dev runner).
  • Login roles (manifest role): owner (id justin), agentCody (id LUP-1040), subCarlos (id carlos); password password. Plus public (no login) for landing/crane.
  • Viewport / video size: 1920×1080 (recordVideo records CSS pixels → exactly 1080p). FPS 30.
  • Never page.goto between authed screens — auth is in-memory; navigate in-SPA via history.pushState + popstate (helper provided in Task 7).
  • Commits: author is the repo default (Satyam Rastogi); no AI attribution in messages.

File structure (what each new file owns)

File Responsibility
marketing/product-film/src/clips.ts Clip/manifest TS types + validateClipsManifest() + totalLiveFrames() (pure, tested)
marketing/product-film/src/clips.manifest.json The capture/compose contract: 20 clips with role/route/act/captions + ffprobe durations
marketing/product-film/src/clips.test.ts vitest: manifest validity, unique ids, 20 clips, caption ranges
marketing/product-film/src/clipStage.ts Pure helpers captionAt() / zoomAt() (tested)
marketing/product-film/src/clipStage.test.ts vitest for the above
marketing/product-film/src/components/ClipStage.tsx Renders one clip: <OffthreadVideo> + caption overlay + zoom-punch
marketing/product-film/src/LiveFilm.tsx <Series> over manifest clips + landing/crane bookends + act titles
marketing/product-film/scripts/lib/cursor.js Injected cursor page-script + Node easing helpers (easeSteps tested)
marketing/product-film/scripts/lib/cursor.test.js vitest for easing math
marketing/product-film/scripts/lib/record.js recordVideo context + webm→mp4 + ffprobe; pure secondsToFrames/parseFfprobeSeconds tested
marketing/product-film/scripts/lib/record.test.js vitest for the pure parts
marketing/product-film/scripts/lib/spa.js login(page, role) + spaNavigate(page, route) shared by orchestrator/scenes
marketing/product-film/scripts/scenes/<id>.mjs 20 scene scripts — one per manifest clip (export default async (page, cursor, ctx) => {})
marketing/product-film/scripts/capture-video.mjs Orchestrator: per-role login, run scenes, record, convert, write durations
marketing/product-film/VO-SCRIPT.md Per-segment narration for voicing later

Modified: marketing/product-film/src/Root.tsx (register LiveFilm as default), marketing/product-film/package.json (deps + scripts).


Task 1: Project deps & scripts

Files:

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

  • Step 1: Add ffmpeg-static dependency

Run (in marketing/product-film/):

npm install ffmpeg-static@5.2.0

Expected: ffmpeg-static appears under dependencies, install succeeds.

  • Step 2: Add capture/render scripts

Edit marketing/product-film/package.json scripts to add (keep existing entries):

{
  "scripts": {
    "dev": "remotion studio",
    "render": "remotion render LynkedUpProFilm out/lynkedup-pro-film.mp4",
    "render:live": "remotion render LynkedUpProLiveFilm out/lynkedup-pro-live-film.mp4",
    "still": "remotion still LynkedUpProFilm out/still.png",
    "capture": "node scripts/capture.mjs",
    "capture:video": "node scripts/capture-video.mjs",
    "test": "vitest run"
  }
}
  • Step 3: Verify scripts parse

Run: npm run test Expected: existing tests still pass (vitest runs captures.test.ts, timing.test.ts, craneSwing.test.ts).

  • Step 4: Commit
git add marketing/product-film/package.json marketing/product-film/package-lock.json
git commit -m "build(film): add ffmpeg-static and capture:video/render:live scripts"

Task 2: Clips manifest types & validator (TDD)

Files:

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

  • Test: marketing/product-film/src/clips.test.ts

  • Step 1: Write the failing test

Create marketing/product-film/src/clips.test.ts:

import { describe, it, expect } from "vitest";
import {
  validateClipsManifest,
  totalLiveFrames,
  type ClipsManifest,
} from "./clips";

const base: ClipsManifest = {
  fps: 30,
  width: 1920,
  height: 1080,
  clips: [
    {
      id: "storm-intel",
      file: "clips/storm-intel.mp4",
      role: "owner",
      route: "/owner/storm-intel",
      act: "Win the work",
      pillarTitle: "Storm Intelligence",
      durationFrames: 1650,
      captions: [{ fromFrame: 60, toFrame: 240, text: "Drop a pin anywhere." }],
    },
  ],
};

describe("clips manifest validator", () => {
  it("accepts a valid manifest", () => {
    expect(validateClipsManifest(base)).toEqual([]);
  });
  it("rejects wrong fps/dimensions", () => {
    const errs = validateClipsManifest({ ...base, fps: 24, width: 1280 });
    expect(errs).toContain("fps must be 30");
    expect(errs).toContain("dimensions must be 1920x1080");
  });
  it("rejects duplicate ids", () => {
    const errs = validateClipsManifest({ ...base, clips: [base.clips[0], base.clips[0]] });
    expect(errs.some((e) => e.includes("duplicate id"))).toBe(true);
  });
  it("rejects bad file path and route", () => {
    const errs = validateClipsManifest({
      ...base,
      clips: [{ ...base.clips[0], file: "storm.webm", route: "owner/x" }],
    });
    expect(errs.some((e) => e.includes("file must be clips/*.mp4"))).toBe(true);
    expect(errs.some((e) => e.includes('route must start with "/"'))).toBe(true);
  });
  it("rejects caption outside clip duration", () => {
    const errs = validateClipsManifest({
      ...base,
      clips: [{ ...base.clips[0], captions: [{ fromFrame: 10, toFrame: 99999, text: "x" }] }],
    });
    expect(errs.some((e) => e.includes("caption out of range"))).toBe(true);
  });
  it("sums total frames", () => {
    expect(totalLiveFrames(base)).toBe(1650);
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test -- clips Expected: FAIL — Cannot find module './clips'.

  • Step 3: Implement clips.ts

Create marketing/product-film/src/clips.ts:

export type ClipRole = "owner" | "agentCody" | "subCarlos" | "public";

export interface CaptionCue {
  fromFrame: number;
  toFrame: number;
  text: string;
}

export interface KeyMoment {
  frame: number;
  zoom: number; // 1.0 = no zoom; e.g. 1.08 = subtle punch-in
}

export interface Clip {
  id: string;
  file: string; // relative to public/, e.g. "clips/storm-intel.mp4"
  role: ClipRole;
  route: string;
  act: string;
  pillarTitle?: string;
  durationFrames: number; // seeded estimate; overwritten by ffprobe at capture
  keyMoments?: KeyMoment[];
  captions: CaptionCue[];
}

export interface ClipsManifest {
  fps: number;
  width: number;
  height: number;
  clips: Clip[];
}

const VALID_ROLES: ClipRole[] = ["owner", "agentCody", "subCarlos", "public"];

export function validateClipsManifest(m: ClipsManifest): string[] {
  const errors: string[] = [];
  const seen = new Set<string>();
  if (m.fps !== 30) errors.push("fps must be 30");
  if (m.width !== 1920 || m.height !== 1080) errors.push("dimensions must be 1920x1080");
  for (const c of m.clips) {
    if (!c.id) errors.push("clip missing id");
    if (seen.has(c.id)) errors.push(`duplicate id: ${c.id}`);
    seen.add(c.id);
    if (!c.file?.startsWith("clips/") || !c.file.endsWith(".mp4"))
      errors.push(`${c.id}: file must be clips/*.mp4`);
    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}"`);
    if (!Number.isInteger(c.durationFrames) || c.durationFrames <= 0)
      errors.push(`${c.id}: durationFrames must be a positive integer`);
    for (const cap of c.captions ?? []) {
      if (cap.fromFrame < 0 || cap.toFrame > c.durationFrames || cap.fromFrame >= cap.toFrame)
        errors.push(`${c.id}: caption out of range [${cap.fromFrame},${cap.toFrame}] within 0..${c.durationFrames}`);
    }
  }
  return errors;
}

export function totalLiveFrames(m: ClipsManifest): number {
  return m.clips.reduce((acc, c) => acc + c.durationFrames, 0);
}
  • Step 4: Run test to verify it passes

Run: npm run test -- clips Expected: PASS (6 tests).

  • Step 5: Commit
git add marketing/product-film/src/clips.ts marketing/product-film/src/clips.test.ts
git commit -m "feat(film): clips manifest types + validator"

Task 3: Seed the clips manifest (20 clips) (TDD)

Files:

  • Create: marketing/product-film/src/clips.manifest.json
  • Modify: marketing/product-film/src/clips.test.ts

The 20 video clips, with seeded durationFrames = seconds × 30 (overwritten by capture later):

id role route act pillarTitle sec
landing-hero public / Open 12
storm-intel owner /owner/storm-intel Win the work Storm Intelligence 55
territory-map owner /owner/maps Win the work Territory Command 50
lead-create agentCody /emp/fa/leads/new Win the work Lead Creation 60
lead-verify owner /lead-verification Win the work Lead Verification 30
dispatch owner /owner/dispatch Run the operation LynkDispatch 55
kanban owner /owner/kanban Run the operation Pipeline in Motion 50
sub-tasks owner /owner/subcontractor-tasks Run the operation Subcontractor Tasks 35
pro-canvas owner /owner/pro-canvas Estimate & sell Pro-Canvas 60
estimates owner /owner/estimates Estimate & sell Estimates 35
owner-snapshot owner /owner/snapshot See the business Owner Snapshot 55
operator-dashboard agentCody /emp/fa/dashboard See the business Operator Dashboard 30
people-skills owner /owner/people Manage the team People & Skills 50
project-detail owner /owner/projects Manage the team Project Details 55
documents owner /owner/documents Manage the team Documents 25
org-access owner /owner/settings Control & trust Access Control 55
role-perspective owner /owner/snapshot Control & trust Role Perspective 35
ai-assistant owner /chat-assistant Intelligence AI Assistant 35
breadth-flash owner /owner/snapshot Close 18
crane-close public / Close 22

Note on Promise (segment 0b): the pain→promise beat is NOT a captured clip — it reuses the existing native src/sequences/Promise.tsx title card, interleaved in LiveFilm after landing-hero (Task 17). So the manifest holds 20 video clips; Promise is the one native sequence.

  • Step 1: Write the failing test additions

Append to marketing/product-film/src/clips.test.ts:

import manifest from "./clips.manifest.json";

describe("seeded clips.manifest.json", () => {
  it("passes validation", () => {
    expect(validateClipsManifest(manifest as ClipsManifest)).toEqual([]);
  });
  it("has exactly 20 video clips with unique ids", () => {
    const m = manifest as ClipsManifest;
    expect(m.clips.length).toBe(20);
    expect(new Set(m.clips.map((c) => c.id)).size).toBe(20);
  });
  it("opens with landing-hero and ends with crane-close", () => {
    const m = manifest as ClipsManifest;
    expect(m.clips[0].id).toBe("landing-hero");
    expect(m.clips[m.clips.length - 1].id).toBe("crane-close");
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test -- clips Expected: FAIL — Cannot find module './clips.manifest.json'.

  • Step 3: Create the manifest

Create marketing/product-film/src/clips.manifest.json with fps:30, width:1920, height:1080 and the 20 clips from the table above. Each clip: set durationFrames to sec × 30, file to clips/<id>.mp4, and at least one caption within [0, durationFrames). Example head (fill all 20 from the table, in table order):

{
  "fps": 30,
  "width": 1920,
  "height": 1080,
  "clips": [
    {
      "id": "landing-hero",
      "file": "clips/landing-hero.mp4",
      "role": "public",
      "route": "/",
      "act": "Open",
      "durationFrames": 360,
      "keyMoments": [{ "frame": 120, "zoom": 1.05 }],
      "captions": [{ "fromFrame": 30, "toFrame": 330, "text": "The operating system for modern roofing companies." }]
    },
    {
      "id": "storm-intel",
      "file": "clips/storm-intel.mp4",
      "role": "owner",
      "route": "/owner/storm-intel",
      "act": "Win the work",
      "pillarTitle": "Storm Intelligence",
      "durationFrames": 1650,
      "keyMoments": [{ "frame": 420, "zoom": 1.08 }],
      "captions": [
        { "fromFrame": 60, "toFrame": 360, "text": "Drop a pin anywhere in your market." },
        { "fromFrame": 420, "toFrame": 900, "text": "Live NOAA + IEM storm history — instantly." }
      ]
    }
  ]
}
  • Step 4: Run test to verify it passes

Run: npm run test -- clips Expected: PASS (9 tests total).

  • Step 5: Commit
git add marketing/product-film/src/clips.manifest.json marketing/product-film/src/clips.test.ts
git commit -m "feat(film): seed 20-clip manifest (capture/compose contract)"

Task 4: Cursor easing helpers + injected page script (TDD)

Files:

  • Create: marketing/product-film/scripts/lib/cursor.js

  • Test: marketing/product-film/scripts/lib/cursor.test.js

  • Step 1: Write the failing test

Create marketing/product-film/scripts/lib/cursor.test.js:

import { describe, it, expect } from "vitest";
import { easeInOutCubic, easeSteps } from "./cursor.js";

describe("cursor easing", () => {
  it("eases 0→1 monotonically with fixed endpoints", () => {
    expect(easeInOutCubic(0)).toBe(0);
    expect(easeInOutCubic(1)).toBe(1);
    expect(easeInOutCubic(0.25)).toBeLessThan(0.25); // slow start
    expect(easeInOutCubic(0.75)).toBeGreaterThan(0.75); // fast then settle
  });
  it("produces N integer steps ending exactly at target", () => {
    const pts = easeSteps({ x: 0, y: 0 }, { x: 100, y: 40 }, 10);
    expect(pts).toHaveLength(10);
    expect(pts[pts.length - 1]).toEqual({ x: 100, y: 40 });
    expect(pts.every((p) => Number.isInteger(p.x) && Number.isInteger(p.y))).toBe(true);
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test -- cursor Expected: FAIL — cannot find ./cursor.js.

  • Step 3: Implement cursor.js

Create marketing/product-film/scripts/lib/cursor.js:

// Eased cursor motion + an injected on-screen pointer (recorded video has no OS cursor).
export const easeInOutCubic = (t) =>
  t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;

export function easeSteps(from, to, steps) {
  const pts = [];
  for (let i = 1; i <= steps; i++) {
    const t = easeInOutCubic(i / steps);
    pts.push({
      x: Math.round(from.x + (to.x - from.x) * t),
      y: Math.round(from.y + (to.y - from.y) * t),
    });
  }
  return pts;
}

// Page-side script: appends a pointer element and exposes window.__cursor.
// pointer-events:none so it never blocks real clicks. Injected via addInitScript.
export const CURSOR_INIT_SCRIPT = `
(() => {
  if (window.__cursorEl) return;
  const el = document.createElement('div');
  el.id = '__film_cursor';
  Object.assign(el.style, {
    position: 'fixed', left: '0', top: '0', width: '22px', height: '22px',
    marginLeft: '-4px', marginTop: '-4px', zIndex: '2147483647',
    pointerEvents: 'none', transition: 'transform 0.04s linear',
    background: 'transparent',
  });
  el.innerHTML =
    "<svg width='22' height='22' viewBox='0 0 22 22'><path d='M2 2 L2 17 L6 13 L9 20 L12 19 L9 12 L15 12 Z' fill='white' stroke='%230B5FFF' stroke-width='1.5'/></svg>";
  document.documentElement.appendChild(el);
  const ring = document.createElement('div');
  Object.assign(ring.style, {
    position: 'fixed', left: '0', top: '0', width: '34px', height: '34px',
    marginLeft: '-17px', marginTop: '-17px', borderRadius: '50%',
    border: '2px solid rgba(11,95,255,0.9)', zIndex: '2147483646',
    pointerEvents: 'none', opacity: '0', transform: 'scale(0.4)',
    transition: 'opacity 0.3s, transform 0.3s',
  });
  document.documentElement.appendChild(ring);
  window.__cursorEl = el; window.__cursorRing = ring;
  window.__cursorMove = (x, y) => {
    el.style.transform = 'translate(' + x + 'px,' + y + 'px)';
    ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)';
  };
  window.__cursorPulse = (x, y) => {
    ring.style.transition = 'none';
    ring.style.opacity = '0.9';
    ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(0.4)';
    requestAnimationFrame(() => {
      ring.style.transition = 'opacity 0.4s, transform 0.4s';
      ring.style.opacity = '0';
      ring.style.transform = 'translate(' + x + 'px,' + y + 'px) scale(1.4)';
    });
  };
})();
`;

// Node-side driver bound to a Playwright page. Moves the on-screen cursor in eased
// steps, then fires the REAL mouse event at the same point so the app responds.
export function makeCursor(page) {
  let pos = { x: 960, y: 540 };
  const apply = async (x, y) => {
    await page.evaluate(([px, py]) => window.__cursorMove?.(px, py), [x, y]);
    await page.mouse.move(x, y);
  };
  return {
    async moveTo(x, y, ms = 600) {
      const steps = Math.max(8, Math.round(ms / 16));
      for (const p of easeSteps(pos, { x, y }, steps)) {
        await apply(p.x, p.y);
        await page.waitForTimeout(ms / steps);
      }
      pos = { x, y };
    },
    async click(x, y, ms = 600) {
      if (x != null) await this.moveTo(x, y, ms);
      await page.evaluate(([px, py]) => window.__cursorPulse?.(px, py), [pos.x, pos.y]);
      await page.mouse.click(pos.x, pos.y);
    },
    async clickLocator(locator, ms = 600) {
      const box = await locator.boundingBox();
      if (!box) throw new Error("clickLocator: element not visible");
      await this.click(box.x + box.width / 2, box.y + box.height / 2, ms);
    },
    get position() {
      return pos;
    },
  };
}
  • Step 4: Run test to verify it passes

Run: npm run test -- cursor Expected: PASS (2 tests).

  • Step 5: Commit
git add marketing/product-film/scripts/lib/cursor.js marketing/product-film/scripts/lib/cursor.test.js
git commit -m "feat(film): synthetic cursor (eased moves + injected on-screen pointer)"

Task 5: Recording + ffmpeg helpers (TDD pure parts)

Files:

  • Create: marketing/product-film/scripts/lib/record.js

  • Test: marketing/product-film/scripts/lib/record.test.js

  • Step 1: Write the failing test

Create marketing/product-film/scripts/lib/record.test.js:

import { describe, it, expect } from "vitest";
import { secondsToFrames, parseFfprobeSeconds } from "./record.js";

describe("record helpers", () => {
  it("converts seconds to frames at 30fps (min 1)", () => {
    expect(secondsToFrames(2, 30)).toBe(60);
    expect(secondsToFrames(1.017, 30)).toBe(31);
    expect(secondsToFrames(0, 30)).toBe(1);
  });
  it("parses ffprobe duration output", () => {
    expect(parseFfprobeSeconds("55.033000\n")).toBeCloseTo(55.033, 3);
  });
  it("throws on bad ffprobe output", () => {
    expect(() => parseFfprobeSeconds("N/A")).toThrow();
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test -- record Expected: FAIL — cannot find ./record.js.

  • Step 3: Implement record.js

Create marketing/product-film/scripts/lib/record.js:

import { spawn } from "node:child_process";
import { mkdir, rm, readdir, rename } from "node:fs/promises";
import { join, dirname } from "node:path";
import ffmpegPath from "ffmpeg-static";

export const secondsToFrames = (seconds, fps) => Math.max(1, Math.round(seconds * fps));

export function parseFfprobeSeconds(stdout) {
  const n = parseFloat(String(stdout).trim());
  if (!Number.isFinite(n) || n <= 0) throw new Error(`bad ffprobe duration: ${stdout}`);
  return n;
}

function run(bin, args) {
  return new Promise((resolve, reject) => {
    const p = spawn(bin, args);
    let out = "", err = "";
    p.stdout.on("data", (d) => (out += d));
    p.stderr.on("data", (d) => (err += d));
    p.on("close", (code) => (code === 0 ? resolve(out) : reject(new Error(err || `exit ${code}`))));
  });
}

// ffmpeg-static bundles ffmpeg; ffprobe is not included, so we read duration from
// ffmpeg's own output (-i prints "Duration: HH:MM:SS.ss"). Robust + dependency-free.
export async function probeSeconds(file) {
  let stderr = "";
  try {
    await run(ffmpegPath, ["-i", file]);
  } catch (e) {
    stderr = e.message;
  }
  const m = stderr.match(/Duration:\s*(\d+):(\d+):(\d+\.\d+)/);
  if (!m) throw new Error(`could not read duration of ${file}`);
  return (+m[1]) * 3600 + (+m[2]) * 60 + parseFloat(m[3]);
}

// Convert a recorded .webm to a constant-30fps .mp4 (h.264, yuv420p for broad support).
export async function webmToMp4(webm, mp4, fps = 30) {
  await mkdir(dirname(mp4), { recursive: true });
  await run(ffmpegPath, [
    "-y", "-i", webm,
    "-r", String(fps), "-vsync", "cfr",
    "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium",
    "-an", mp4,
  ]);
}

// Playwright writes recordVideo files with random names; grab the newest .webm in dir.
export async function newestWebm(dir) {
  const files = (await readdir(dir)).filter((f) => f.endsWith(".webm"));
  if (!files.length) throw new Error(`no .webm produced in ${dir}`);
  files.sort();
  return join(dir, files[files.length - 1]);
}

export { rm, rename };
  • Step 4: Run test to verify it passes

Run: npm run test -- record Expected: PASS (3 tests).

  • Step 5: Commit
git add marketing/product-film/scripts/lib/record.js marketing/product-film/scripts/lib/record.test.js
git commit -m "feat(film): recording + ffmpeg(webm→mp4) + duration helpers"

Task 6: Shared login + SPA navigation lib

Files:

  • Create: marketing/product-film/scripts/lib/spa.js

This extracts the proven login/navigation logic from the existing scripts/capture.mjs so scenes and the orchestrator share it.

  • Step 1: Implement spa.js

Create marketing/product-film/scripts/lib/spa.js:

// Shared auth + in-SPA navigation. Auth is in-memory only, so we NEVER page.goto
// between authed screens — we push history + dispatch popstate (React Router listens).
export const ROLE_LOGIN = {
  owner: { tab: "Owner", id: "justin" },
  agentCody: { tab: "Employee", id: "LUP-1040" },
  subCarlos: { tab: "Sub-Con", id: "carlos" },
};

export 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" });
  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.waitForFunction(() => !location.pathname.startsWith("/login"));
  await page.waitForLoadState("networkidle");
}

export async function spaNavigate(page, route) {
  await page.evaluate((r) => {
    window.history.pushState({}, "", r);
    window.dispatchEvent(new PopStateEvent("popstate"));
  }, route);
  await page.waitForFunction((r) => location.pathname === r, route);
}
  • Step 2: Verify it imports cleanly

Run: node --input-type=module -e "import('./marketing/product-film/scripts/lib/spa.js').then(m=>console.log(Object.keys(m)))" Expected: prints [ 'ROLE_LOGIN', 'login', 'spaNavigate' ].

  • Step 3: Commit
git add marketing/product-film/scripts/lib/spa.js
git commit -m "feat(film): shared login + in-SPA navigation helpers"

Task 7: Capture orchestrator + reference scene (storm-intel)

Files:

  • Create: marketing/product-film/scripts/scenes/storm-intel.mjs
  • Create: marketing/product-film/scripts/capture-video.mjs

The orchestrator owns: browser/context lifecycle, dark theme, cursor injection, login-per-role ordering (with public first, no login), running the requested scene(s), recording each to its own context, converting to mp4, and writing real durationFrames back into the manifest. Scenes own only choreography.

Scene contract: export default async (page, cursor, ctx) => {} where ctx = { baseUrl, spaNavigate, login, role, waitSettle }. The orchestrator has already logged in (for non-public roles) and SPA-navigated to clip.route before calling the scene; the scene performs the interaction and any extra waits.

  • Step 1: Implement the reference scene storm-intel.mjs

Create marketing/product-film/scripts/scenes/storm-intel.mjs:

// Storm Intel: drop a pin, wait for the REAL storm-history fetch, reveal the panel.
export default async (page, cursor, ctx) => {
  // Map tiles stream in — let them settle before interacting.
  await page.waitForTimeout(2500);

  // Enable pin-drop mode if there's a toggle (button text "Pin" / aria), else skip.
  const pinToggle = page.getByRole("button", { name: /pin/i }).first();
  if (await pinToggle.count()) {
    await cursor.clickLocator(pinToggle);
    await page.waitForTimeout(400);
  }

  // Click a point on the map (center-ish). The map container fills the main area.
  const map = page.locator(".leaflet-container").first();
  const box = await map.boundingBox();
  const target = { x: box.x + box.width * 0.5, y: box.y + box.height * 0.5 };

  // Drive the real storm-history fetch and wait for it, so the panel fill is on camera.
  const waitFetch = page
    .waitForResponse((r) => /\/api\/storm-history/.test(r.url()), { timeout: 15000 })
    .catch(() => null); // tolerate cached/no-fetch; the pin still renders
  await cursor.click(target.x, target.y, 800);
  await waitFetch;
  await page.waitForTimeout(2500); // hold on the populated panel

  // Pan the cursor to the populated history panel for emphasis.
  const panel = page.getByText(/storm history/i).first();
  if (await panel.count()) await cursor.clickLocator(panel, 700);
  await page.waitForTimeout(1500);
};
  • Step 2: Implement the orchestrator capture-video.mjs

Create marketing/product-film/scripts/capture-video.mjs:

import { chromium } from "playwright";
import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { login, spaNavigate } from "./lib/spa.js";
import { makeCursor, CURSOR_INIT_SCRIPT } from "./lib/cursor.js";
import { newestWebm, webmToMp4, probeSeconds, secondsToFrames } from "./lib/record.js";

const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..");
const BASE_URL = "http://localhost:5173";
const FPS = 30;
const VIEWPORT = { width: 1920, height: 1080 };

const onlyId = process.argv.includes("--scene")
  ? process.argv[process.argv.indexOf("--scene") + 1]
  : null;

async function loadScene(id) {
  const mod = await import(`./scenes/${id}.mjs`).catch(() => null);
  return mod?.default ?? null;
}

async function main() {
  const manifestPath = join(ROOT, "src/clips.manifest.json");
  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
  const clips = onlyId ? manifest.clips.filter((c) => c.id === onlyId) : manifest.clips;
  if (!clips.length) throw new Error(`no clip matches --scene ${onlyId}`);

  const outDir = join(ROOT, "public/clips");
  await mkdir(outDir, { recursive: true });
  const browser = await chromium.launch();

  // Order: public scenes first (no login), then group by role to log in once each.
  const order = [...clips].sort((a, b) => roleRank(a.role) - roleRank(b.role));
  let currentRole = null;
  let page = null;
  let context = null;

  for (const clip of order) {
    const scene = await loadScene(clip.id);
    if (!scene) {
      console.warn(`! no scene file for ${clip.id} — skipping`);
      continue;
    }
    // Fresh per-clip context so each recording is its own .webm.
    const tmpVideoDir = join(outDir, `_rec_${clip.id}`);
    await rm(tmpVideoDir, { recursive: true, force: true });
    context = await browser.newContext({
      viewport: VIEWPORT,
      deviceScaleFactor: 1, // recordVideo records CSS px; keep 1 for exact 1080p
      recordVideo: { dir: tmpVideoDir, size: VIEWPORT },
    });
    await context.addInitScript("try{localStorage.setItem('theme','dark')}catch(e){}");
    await context.addInitScript(CURSOR_INIT_SCRIPT);
    page = await context.newPage();
    const cursor = makeCursor(page);

    if (clip.role !== "public") {
      await login(page, BASE_URL, clip.role);
      await spaNavigate(page, clip.route);
    } else {
      await page.goto(`${BASE_URL}${clip.route}`, { waitUntil: "networkidle" });
    }
    await page.waitForTimeout(800);

    console.log(`▶ recording ${clip.id} (${clip.role}) ...`);
    await scene(page, cursor, {
      baseUrl: BASE_URL,
      spaNavigate,
      login: (role) => login(page, BASE_URL, role),
      role: clip.role,
      waitSettle: (ms = 1500) => page.waitForTimeout(ms),
    });

    await context.close(); // flush the .webm
    const webm = await newestWebm(tmpVideoDir);
    const mp4 = join(outDir, `${clip.id}.mp4`);
    await webmToMp4(webm, mp4, FPS);
    await rm(tmpVideoDir, { recursive: true, force: true });

    const seconds = await probeSeconds(mp4);
    clip.durationFrames = secondsToFrames(seconds, FPS);
    console.log(`✓ ${clip.id}${mp4} (${seconds.toFixed(2)}s = ${clip.durationFrames}f)`);
  }

  await browser.close();
  // Persist updated durations (only the clips we captured this run).
  await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
  console.log(`\nDone. Captured ${clips.length} clip(s); manifest durations updated.`);
}

function roleRank(role) {
  return { public: 0, owner: 1, agentCody: 2, subCarlos: 3 }[role] ?? 9;
}

main().catch((e) => {
  console.error(e);
  process.exit(1);
});
  • Step 3: Run the reference scene end-to-end

Prereq: CRM dev server running (npm run dev at repo root). Run (in marketing/product-film/): npm run capture:video -- --scene storm-intel Expected: console shows ✓ storm-intel → .../public/clips/storm-intel.mp4 (NN.NNs = NNNNf); the file exists; src/clips.manifest.json storm-intel.durationFrames updated to the real value.

  • Step 4: Verify the clip is valid 1080p30 and tests still pass

Run: npm run test -- clips → PASS (manifest still valid after duration update). Manually scrub public/clips/storm-intel.mp4 — confirm the cursor moves, the pin drops, and the storm-history panel populates on camera.

  • Step 5: Commit
git add marketing/product-film/scripts/capture-video.mjs marketing/product-film/scripts/scenes/storm-intel.mjs marketing/product-film/src/clips.manifest.json
git commit -m "feat(film): capture orchestrator + storm-intel reference scene"

(Note: public/clips/*.mp4 are gitignored — see Task 19 for .gitignore.)


Tasks 813: Author the remaining interaction scenes

Each scene below is its own file marketing/product-film/scripts/scenes/<id>.mjs exporting async (page, cursor, ctx) => {}. They reuse the cursor API (moveTo, click, clickLocator) and ctx. Because selectors must match the running DOM, every scene task has the same shape:

  1. Pin selectors: with the dev server running, open the route in a browser and read the real button text / roles / classes for the interactions named below (use Playwright's getByRole/getByText first; fall back to a stable class/data-*).
  2. Write the scene performing the listed beats with await page.waitForResponse(...) for any async load.
  3. Run npm run capture:video -- --scene <id> → clip produced, duration written.
  4. Scrub the mp4 to confirm the cause→effect reveal reads clearly.
  5. Commit scripts/scenes/<id>.mjs + the manifest duration change.

Do NOT invent selectors in the plan — pin them from the DOM in step 1. The interaction beats below are the spec each scene must satisfy; they come from the verified inventory in the design doc.

Task 8 — Act "Win the work" remainder + open

  • landing-hero (public, /): let hero animation play; ease cursor across the hero; subtle dwell on the headline + primary CTA. No clicks needed (it's the brand open). ~12s.
  • crane-close (public, /): scroll to the end CTA and let the swinging-crane CTA animation run (the signature closer the user specifically asked for); ease the cursor to the CTA button and dwell while it swings. No navigation away. ~22s.
  • territory-map (owner, /owner/maps): wait for tiles; drop a pin (or draw a 3-vertex zone, double-click to close); open the assignment control (ZoneAssignmentModal); pick an agent; confirm. Use waitForResponse if a fetch fires. ~50s.
  • lead-create (agentCody, /emp/fa/leads/new): toggle Quick/Full; type into first name, last name, phone, address using page.type() (so text appears on camera); pick a lead source from the dropdown; click Save; wait for the success toast. ~60s.
  • lead-verify (owner, /lead-verification): open a lead's verification details; step through the verify/enrich action; show the verified state. ~30s.

Run/scrub/commit each per the shape above.

Task 9 — "Run the operation"

  • dispatch (owner, /owner/dispatch): click a lead card in the queue → LeadQuickViewModal/recommendation drawer opens; accept the AI-recommended rep; show the log/efficiency update; toggle Storm Mode. Use waitForResponse for hail/assignment fetches. ~55s.
  • kanban (owner, /owner/kanban): drag a lead card from one stage to the next using page.mouse.down/move/up (drive the on-screen cursor alongside); the campaign toast appears; open the campaign edit modal and show the message. ~50s.
  • sub-tasks (owner, /owner/subcontractor-tasks): open a task → change its status via the status control; show the updated state. ~35s.

Task 10 — "Estimate & sell"

  • pro-canvas (owner, /owner/pro-canvas): open the canvas; pick a template (TemplateSelectionModal); add/adjust a measurement (MeasurementsModal); if an XP/level-up indicator fires, dwell on it. Highest scripting care — keep moves deliberate. ~60s.
  • estimates (owner, /owner/estimates): open an estimate from the list (EstimateDetailModal); reveal the material/cost breakdown; scroll the totals. ~35s.

Task 11 — "See the business"

  • owner-snapshot (owner, /owner/snapshot): click a financial KPI card → FinancialDetailsModal; dwell on a chart (budget/pie/funnel); close; open the action center; show commission/storm attribution panels. ~55s.
  • operator-dashboard (agentCody, /emp/fa/dashboard): let the animated revenue counter run; dwell on the live weather widget (it fetches — waitForResponse on the weather call if present); pan to the leaderboard. ~30s.

Task 12 — "Manage the team"

  • people-skills (owner, /owner/people): type in the search; click a person → detail panel; click a skill score input, type a new value, click Save (skill bar updates); dwell on the masked sensitive-info row. ~50s.
  • project-detail (owner, /owner/projects): click a project row → /owner/projects/:id; cycle a curated set of tabs by clicking each: Overview → Budget → Milestones → Estimates → Docs → Team (real tab ids in OwnerProjectDetail.jsx: overview,budget,changeOrders,rfis,milestones,invoices,estimates,risks,inspections,docs,activity,team). Dwell ~3s per tab. ~55s.
  • documents (owner, /owner/documents): browse the library; open a document preview; show status badges. ~25s.

Task 13 — "Control & trust"

  • org-access (owner, /owner/settings): open the Access Control matrix; toggle one permission cell (it records in the Change Log — open the log to show the entry); open the commission rules section and reveal a role override. ~55s.
  • role-perspective (owner, /owner/snapshot): show the owner snapshot briefly; then call ctx.login('agentCody') + ctx.spaNavigate('/emp/fa/dashboard') to show the operator's narrower view; then ctx.login('subCarlos') + ctx.spaNavigate('/subcontractor/dashboard'). Caption emphasizes "same workspace, role-scoped access." ~35s. (This scene starts as owner; it re-logs in within the scene.)
  • breadth-flash (owner, /owner/snapshot): a fast "everything else" montage — ctx.spaNavigate through ~5 screens in quick succession with a short dwell each (e.g. /owner/vendors, /owner/documents, /owner/estimates, /admin/leaderboard, /owner/projects) while recording. One caption ("And everything else a roofing company runs on."). This is the still-b-roll replacement, captured as one continuous video clip. ~18s.

Task 14: AI Assistant scene + live answer verification

Files:

  • Create: marketing/product-film/scripts/scenes/ai-assistant.mjs

  • (Depends on spec §9 — model+budget already DONE in src/components/Chatbot.jsx.)

  • Step 1: Verify the chat answers end-to-end (live)

Prereq: dev server running with a valid VITE_GROQ_API_KEY in .env. Manually (or via the scene harness): log in as justin, open the assistant (route /chat-assistant or the floating chatbot), ask the locked demo question:

"Which of my projects are over budget, and what's my biggest compliance risk right now?" Confirm the answer is complete, accurate against the owner data, and free of <think> noise. If it stalls/empties, adjust the demo question to one the curated context fully answers (record the final question in VO-SCRIPT.md).

  • Step 2: Write the scene

Create marketing/product-film/scripts/scenes/ai-assistant.mjs:

// AI Assistant: ask the locked demo question, wait for the streamed answer on camera.
const DEMO_Q = "Which of my projects are over budget, and what's my biggest compliance risk right now?";

export default async (page, cursor, ctx) => {
  // Open the assistant input (route is /chat-assistant; if a floating launcher is used,
  // pin its selector during Step 1 and click it here instead).
  const input = page.getByRole("textbox").first();
  await input.waitFor({ state: "visible", timeout: 8000 });
  await cursor.clickLocator(input);
  await input.type(DEMO_Q, { delay: 35 }); // typed on camera

  // Send and wait for the assistant's reply to render (Groq call).
  const send = page.getByRole("button", { name: /send/i }).first();
  if (await send.count()) await cursor.clickLocator(send);
  else await page.keyboard.press("Enter");

  // Wait for a new assistant message to appear, then hold while it reads.
  await page.waitForTimeout(1500);
  await page.waitForFunction(
    () => document.body.innerText.length > 0, // replaced in Step 3 with a tighter check
    { timeout: 20000 }
  ).catch(() => null);
  await page.waitForTimeout(6000); // dwell on the answer
};
  • Step 3: Tighten the reveal wait

After pinning the assistant message selector in Step 1, replace the waitForFunction body with a check that the assistant message count increased (e.g. document.querySelectorAll('[data-role="assistant"]').length or the real message container class). Re-run.

  • Step 4: Capture & verify

Run: npm run capture:video -- --scene ai-assistant Expected: clip shows the question typed and a coherent streamed answer; duration written to manifest.

  • Step 5: Commit
git add marketing/product-film/scripts/scenes/ai-assistant.mjs marketing/product-film/src/clips.manifest.json
git commit -m "feat(film): AI assistant scene + locked demo question"

Task 15: ClipStage pure helpers — caption & zoom mapping (TDD)

Files:

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

  • Test: marketing/product-film/src/clipStage.test.ts

  • Step 1: Write the failing test

Create marketing/product-film/src/clipStage.test.ts:

import { describe, it, expect } from "vitest";
import { captionAt, zoomAt } from "./clipStage";

const caps = [
  { fromFrame: 0, toFrame: 30, text: "A" },
  { fromFrame: 30, toFrame: 60, text: "B" },
];

describe("clipStage helpers", () => {
  it("returns the active caption for a frame (inclusive start, exclusive end)", () => {
    expect(captionAt(caps, 0)?.text).toBe("A");
    expect(captionAt(caps, 29)?.text).toBe("A");
    expect(captionAt(caps, 30)?.text).toBe("B");
    expect(captionAt(caps, 60)).toBeNull();
  });
  it("ramps zoom toward the nearest key moment and back to 1 away from it", () => {
    const km = [{ frame: 100, zoom: 1.1 }];
    expect(zoomAt(km, 100)).toBeCloseTo(1.1, 5);
    expect(zoomAt(km, 0)).toBeCloseTo(1.0, 2);
    expect(zoomAt([], 50)).toBe(1);
    expect(zoomAt(km, 70)).toBeGreaterThan(1); // ramping in
    expect(zoomAt(km, 70)).toBeLessThan(1.1);
  });
});
  • Step 2: Run test to verify it fails

Run: npm run test -- clipStage Expected: FAIL — cannot find ./clipStage.

  • Step 3: Implement clipStage.ts

Create marketing/product-film/src/clipStage.ts:

import type { CaptionCue, KeyMoment } from "./clips";

export function captionAt(captions: CaptionCue[], frame: number): CaptionCue | null {
  for (const c of captions) {
    if (frame >= c.fromFrame && frame < c.toFrame) return c;
  }
  return null;
}

// Triangular ramp into the nearest key moment over a 60-frame window on each side.
const RAMP = 60;
export function zoomAt(keyMoments: KeyMoment[] | undefined, frame: number): number {
  if (!keyMoments || keyMoments.length === 0) return 1;
  let best = 1;
  for (const km of keyMoments) {
    const dist = Math.abs(frame - km.frame);
    if (dist > RAMP) continue;
    const t = 1 - dist / RAMP; // 1 at the moment, 0 at the edge
    const z = 1 + (km.zoom - 1) * t;
    if (z > best) best = z;
  }
  return best;
}
  • Step 4: Run test to verify it passes

Run: npm run test -- clipStage Expected: PASS (2 tests).

  • Step 5: Commit
git add marketing/product-film/src/clipStage.ts marketing/product-film/src/clipStage.test.ts
git commit -m "feat(film): clip caption/zoom mapping helpers"

Task 16: ClipStage component

Files:

  • Create: marketing/product-film/src/components/ClipStage.tsx

Renders a single clip with the existing Caption component and a zoom-punch driven by the tested helpers. Reuses theme.ts.

  • Step 1: Implement ClipStage.tsx

Create marketing/product-film/src/components/ClipStage.tsx:

import { OffthreadVideo, staticFile, useCurrentFrame, AbsoluteFill } from "remotion";
import type { Clip } from "../clips";
import { captionAt, zoomAt } from "../clipStage";
import { Caption } from "./Caption";

export const ClipStage: React.FC<{ clip: Clip }> = ({ clip }) => {
  const frame = useCurrentFrame();
  const zoom = zoomAt(clip.keyMoments, frame);
  const caption = captionAt(clip.captions, frame);
  return (
    <AbsoluteFill style={{ backgroundColor: "#0a0a0a" }}>
      <AbsoluteFill style={{ transform: `scale(${zoom})`, transformOrigin: "center center" }}>
        <OffthreadVideo src={staticFile(clip.file)} />
      </AbsoluteFill>
      {caption ? <Caption text={caption.text} /> : null}
    </AbsoluteFill>
  );
};

If the existing Caption component's prop name differs from text, adapt this line to its real signature (check src/components/Caption.tsx).

  • Step 2: Verify it type-checks / builds in studio

Run: npx remotion studio (or npm run dev) — confirm no TypeScript/import errors in the terminal. Stop the studio.

  • Step 3: Commit
git add marketing/product-film/src/components/ClipStage.tsx
git commit -m "feat(film): ClipStage (OffthreadVideo + caption + zoom-punch)"

Task 17: LiveFilm composition (with music bed) + register as default

Files:

  • Modify: marketing/product-film/src/components/AudioTrack.tsx

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

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

  • Step 1: Parameterize AudioTrack total length

AudioTrack currently hardcodes the old film's TOTAL_FRAMES for its fade-out, so the new film (different length) needs it passed in. Edit marketing/product-film/src/components/AudioTrack.tsx:

import { Audio, staticFile, useCurrentFrame, interpolate } from "remotion";
import { TOTAL_FRAMES } from "../timing";

// Flip to true after dropping a royalty-free track at public/audio/music.mp3.
export const HAS_MUSIC = false;

export const AudioTrack: React.FC<{ totalFrames?: number }> = ({ totalFrames = TOTAL_FRAMES }) => {
  const frame = useCurrentFrame();
  if (!HAS_MUSIC) return null;
  // fade in at start, fade out over the last 2s of the given length
  const volume = interpolate(
    frame,
    [0, 30, totalFrames - 60, totalFrames],
    [0, 0.7, 0.7, 0],
    { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
  );
  return <Audio src={staticFile("audio/music.mp3")} volume={volume} />;
};

(Existing Film.tsx usage of <AudioTrack /> keeps working via the default.)

  • Step 2: Implement LiveFilm.tsx (clips + music bed)

Create marketing/product-film/src/LiveFilm.tsx. The native Promise title card is interleaved right after the landing-hero clip; all other clips (incl. breadth-flash second-to-last and crane-close last) follow in manifest order:

import { Series, AbsoluteFill } from "remotion";
import manifest from "./clips.manifest.json";
import { totalLiveFrames, type ClipsManifest } from "./clips";
import { DURATIONS } from "./timing";
import { ClipStage } from "./components/ClipStage";
import { AudioTrack } from "./components/AudioTrack";
import { Promise as PromiseSeq } from "./sequences/Promise";

const m = manifest as ClipsManifest;
// All captured clips + the one native (Promise) sequence.
export const LIVE_TOTAL = totalLiveFrames(m) + DURATIONS.promise;

export const LiveFilm: React.FC = () => {
  const [hero, ...rest] = m.clips; // landing-hero is first in the manifest
  return (
    <AbsoluteFill>
      <AudioTrack totalFrames={LIVE_TOTAL} />
      <Series>
        <Series.Sequence durationInFrames={hero.durationFrames}>
          <ClipStage clip={hero} />
        </Series.Sequence>
        <Series.Sequence durationInFrames={DURATIONS.promise}>
          <PromiseSeq />
        </Series.Sequence>
        {rest.map((clip) => (
          <Series.Sequence key={clip.id} durationInFrames={clip.durationFrames}>
            <ClipStage clip={clip} />
          </Series.Sequence>
        ))}
      </Series>
    </AbsoluteFill>
  );
};

Promise is aliased to PromiseSeq to avoid shadowing the global Promise. The crane closer is the captured crane-close clip (last in the manifest); if you later prefer the native Remotion crane (CraneClose.tsx), swap the final entry to render that instead. Music is a drop-in: set HAS_MUSIC = true after placing public/audio/music.mp3.

  • Step 3: Register as the default composition in Root.tsx

Replace marketing/product-film/src/Root.tsx with:

import { Composition } from "remotion";
import { Film } from "./Film";
import { LiveFilm, LIVE_TOTAL } from "./LiveFilm";
import { TOTAL_FRAMES, FPS, WIDTH, HEIGHT } from "./timing";

export const Root: React.FC = () => {
  return (
    <>
      <Composition
        id="LynkedUpProLiveFilm"
        component={LiveFilm}
        durationInFrames={LIVE_TOTAL}
        fps={FPS}
        width={WIDTH}
        height={HEIGHT}
      />
      <Composition
        id="LynkedUpProFilm"
        component={Film}
        durationInFrames={TOTAL_FRAMES}
        fps={FPS}
        width={WIDTH}
        height={HEIGHT}
      />
    </>
  );
};
  • Step 4: Verify both compositions load

Run: npx remotion studio — confirm LynkedUpProLiveFilm appears and scrubs (clips show once captured). Stop studio.

  • Step 5: Commit
git add marketing/product-film/src/components/AudioTrack.tsx marketing/product-film/src/LiveFilm.tsx marketing/product-film/src/Root.tsx
git commit -m "feat(film): LiveFilm composition (clips + music bed) registered as default"

Task 18: Pre-render smoke check (all clips present)

Files:

  • Create: marketing/product-film/scripts/check-clips.mjs

  • Step 1: Implement the check

Create marketing/product-film/scripts/check-clips.mjs:

import { readFile, access } 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, "..");

const manifest = JSON.parse(await readFile(join(ROOT, "src/clips.manifest.json"), "utf8"));
const missing = [];
for (const c of manifest.clips) {
  try {
    await access(join(ROOT, "public", c.file));
  } catch {
    missing.push(c.file);
  }
}
if (missing.length) {
  console.error(`Missing ${missing.length} clip file(s):\n  ${missing.join("\n  ")}`);
  process.exit(1);
}
console.log(`All ${manifest.clips.length} clip files present.`);
  • Step 2: Wire it into render

Edit marketing/product-film/package.json render:live to run the check first:

"render:live": "node scripts/check-clips.mjs && remotion render LynkedUpProLiveFilm out/lynkedup-pro-live-film.mp4"
  • Step 3: Verify it fails loudly when clips are missing

Run: node scripts/check-clips.mjs Expected: lists any not-yet-captured clips and exits non-zero (until all 20 are captured).

  • Step 4: Commit
git add marketing/product-film/scripts/check-clips.mjs marketing/product-film/package.json
git commit -m "feat(film): pre-render smoke check for missing clips"

Task 19: VO script + gitignore clips/output

Files:

  • Create: marketing/product-film/VO-SCRIPT.md

  • Modify: marketing/product-film/.gitignore

  • Step 1: Ignore heavy capture artifacts

Append to marketing/product-film/.gitignore:

public/clips/
out/
  • Step 2: Write the VO script skeleton

Create marketing/product-film/VO-SCRIPT.md with one section per clip (in manifest order), each containing: the on-screen caption(s), the narration line(s) timed to the interaction, and (for ai-assistant) the locked demo question. Use the spec's act structure as headings. Each section is 24 lines of voiceable narration matching the captions in clips.manifest.json.

  • Step 3: Commit
git add marketing/product-film/.gitignore marketing/product-film/VO-SCRIPT.md
git commit -m "docs(film): VO script + ignore clips/output artifacts"

Task 20: Capture all scenes & final render

  • Step 1: Capture every scene

Prereq: CRM dev server running. Run (in marketing/product-film/): npm run capture:video Expected: all 20 clips produced in public/clips/, manifest durations updated, tests still pass (npm run test -- clips).

  • Step 2: Smoke check

Run: node scripts/check-clips.mjs Expected: All 20 clip files present.

  • Step 3: Preview & render

Run: npx remotion studio — scrub the full LynkedUpProLiveFilm, confirm pacing/captions/bookends. Then: Run: npm run render:live Expected: out/lynkedup-pro-live-film.mp4 produced (~1516 min, 1080p30).

  • Step 4: Commit the finalized manifest
git add marketing/product-film/src/clips.manifest.json
git commit -m "feat(film): finalize live-film clip durations after full capture"

Self-review notes (for the executor)

  • Selectors are pinned from the live DOM, not invented — Tasks 814 require opening each route and reading real button/role/text before writing the scene. This is intentional: the running app is the source of truth.
  • TDD applies to pure logic (manifest validation, cursor easing, duration math, caption/zoom mapping) — those tasks have failing-test-first steps. Browser scenes are integration-verified by producing and scrubbing the clip; that is the correct verification level for video capture.
  • The AI workstream (spec §9) is already implemented — Task 14 only verifies the live answer and locks the demo question.
  • No CRM source changes beyond the already-done Chatbot.jsx edit.