[object Object]

← back to Crcp Pitch Video

heygen presenter tooling (3 client videos); untrack .env secret from history

9f75b98d1ea3e8a4d9fa2b1c4060baf78b45a65a · 2026-07-12 10:17:43 -0700 · Steve Abrams

Files touched

Diff

commit 9f75b98d1ea3e8a4d9fa2b1c4060baf78b45a65a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 12 10:17:43 2026 -0700

    heygen presenter tooling (3 client videos); untrack .env secret from history
---
 .gitignore                            |   6 ++
 build-client-videos.js                |  84 +++++++++++++++++++++++++++
 client-audio/escrow-officer/intro.mp3 | Bin 0 -> 63573 bytes
 client-audio/escrow-officer/s0.mp3    | Bin 0 -> 71515 bytes
 client-audio/escrow-officer/s1.mp3    | Bin 0 -> 51453 bytes
 client-audio/escrow-officer/s2.mp3    | Bin 0 -> 84889 bytes
 client-audio/escrow-officer/s3.mp3    | Bin 0 -> 67753 bytes
 client-audio/escrow-officer/s4.mp3    | Bin 0 -> 77784 bytes
 client-audio/escrow-officer/s5.mp3    | Bin 0 -> 76948 bytes
 client-audio/loan-officer/intro.mp3   | Bin 0 -> 75276 bytes
 client-audio/loan-officer/s0.mp3      | Bin 0 -> 59812 bytes
 client-audio/loan-officer/s1.mp3      | Bin 0 -> 75276 bytes
 client-audio/loan-officer/s2.mp3      | Bin 0 -> 91577 bytes
 client-audio/loan-officer/s3.mp3      | Bin 0 -> 90323 bytes
 client-audio/loan-officer/s4.mp3      | Bin 0 -> 91577 bytes
 client-audio/loan-officer/s5.mp3      | Bin 0 -> 79874 bytes
 client-audio/title-rep/intro.mp3      | Bin 0 -> 71515 bytes
 client-audio/title-rep/s0.mp3         | Bin 0 -> 91577 bytes
 client-audio/title-rep/s1.mp3         | Bin 0 -> 73186 bytes
 client-audio/title-rep/s2.mp3         | Bin 0 -> 84889 bytes
 client-audio/title-rep/s3.mp3         | Bin 0 -> 77784 bytes
 client-audio/title-rep/s4.mp3         | Bin 0 -> 94502 bytes
 client-audio/title-rep/s5.mp3         | Bin 0 -> 95338 bytes
 heygen/jobs.tsv                       |   3 +
 heygen/poll.sh                        |  46 +++++++++++++++
 heygen/prompt-escrow-officer.txt      |   7 +++
 heygen/prompt-loan-officer.txt        |   7 +++
 heygen/prompt-title-rep.txt           |   7 +++
 render-clients.sh                     |  21 +++++++
 src/ClientWalk.tsx                    |  65 +++++++++++++++++++++
 src/Root.tsx                          |   7 +++
 src/client-escrow-officer.json        | 106 ++++++++++++++++++++++++++++++++++
 src/client-loan-officer.json          | 106 ++++++++++++++++++++++++++++++++++
 src/client-title-rep.json             | 106 ++++++++++++++++++++++++++++++++++
 34 files changed, 571 insertions(+)

diff --git a/.gitignore b/.gitignore
index 3e1cebe..0a4728b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,9 @@ audio/
 audio_short/
 walkthrough/
 public/walkthrough/
+
+# secrets
+.env
+.env*
+heygen/poll.log
+heygen/done.txt
diff --git a/build-client-videos.js b/build-client-videos.js
new file mode 100644
index 0000000..73c16e8
--- /dev/null
+++ b/build-client-videos.js
@@ -0,0 +1,84 @@
+/* Build 3 client-specific videos (loan officer / escrow / title) from the shared walkthrough
+ * screenshots — each with its own intro card, tailored captions + cloned-voice narration. */
+const fs = require('fs'), path = require('path'), { execSync } = require('child_process'), https = require('https');
+
+const M = JSON.parse(fs.readFileSync(path.join(__dirname, 'walkthrough/manifest.json'), 'utf8'));
+const boxOf = i => M[i] && M[i].box;
+const shotOf = i => `walkthrough/${M[i].shot}`;
+const VOICE = 'Xa9qV4wNbvSkdUWsYLzq'; let KEY = '';
+for (const p of [process.env.HOME + '/Projects/secrets-manager/.env', process.env.HOME + '/.claude/skills/clone-voice/.env']) {
+  try { for (const l of fs.readFileSync(p, 'utf8').split('\n')) if (l.includes('ELEVENLABS_API_KEY')) { KEY = l.split('=')[1].trim().replace(/"/g, ''); break; } } catch {}
+  if (KEY) break;
+}
+const FPS = 30;
+
+// Each client: intro + ordered steps referencing shot indices, with client-specific caption + VO.
+const CLIENTS = [
+  {
+    id: 'loan-officer', name: 'Loan Officers', accent: '#6BCB77',
+    intro: { head: 'For Loan Officers', sub: 'Turn top agents into a borrower pipeline', vo: "Here's how a loan officer uses C-R-C-P to build a steady pipeline of borrowers." },
+    steps: [
+      [0, 'Your borrowers come from agents', "Your borrowers come from agents — and every active agent is right here."],
+      [12, 'Start where loans are biggest', "Start where the loans are biggest. The luxury markets, where a single deal is a jumbo loan."],
+      [6, 'Sort by activity', "Sort by listings to surface the busiest agents — the ones with buyers who need financing right now."],
+      [9, 'See their live deal flow', "Open an agent to see their current listings and recent sales — proof of real, active deal flow."],
+      [10, 'Pitch a co-marketing play', "Generate a co-marketing pitch: offer to pre-qualify their buyers, or co-host an open house."],
+      [11, 'Load your dialer', "Export your target list and load it straight into your dialer. That's your week of calls, done."],
+    ],
+  },
+  {
+    id: 'escrow-officer', name: 'Escrow Officers', accent: '#58a6ff',
+    intro: { head: 'For Escrow Officers', sub: 'Win the agents, win their escrows', vo: "Here's how an escrow officer uses C-R-C-P to win more openings." },
+    steps: [
+      [0, 'The agent picks escrow', "The agent chooses escrow — so your job is knowing exactly which agents to court."],
+      [13, 'Pick your market', "Pick your market, and load every licensed agent working it."],
+      [6, 'Find the highest-volume agents', "Sort by activity. The highest-volume agents open the most escrows — those are your priorities."],
+      [9, 'Read their pipeline', "Open an agent to read their pipeline: how many deals are about to need an escrow officer."],
+      [4, 'Target a whole brokerage', "Or target an entire brokerage at once. Win the office, and you win its escrows."],
+      [11, 'Build your outreach list', "Draft your intro and export the list. Now you're first in line, before the deal even opens."],
+    ],
+  },
+  {
+    id: 'title-rep', name: 'Title Reps', accent: '#C8A24B',
+    intro: { head: 'For Title Reps', sub: 'Own the relationships that carry the biggest premiums', vo: "Here's how a title rep uses C-R-C-P to build the relationships that matter." },
+    steps: [
+      [0, 'Title is a relationship business', "Title is a relationship business — and C-R-C-P hands you the relationships worth building."],
+      [12, 'Go where inventory is priciest', "Target the markets with the priciest inventory — the biggest title premiums in the country."],
+      [5, 'See where each agent works', "Turn on State and confidence to see exactly where each agent is licensed and active."],
+      [6, 'Find the deal-controllers', "Sort by activity to find the agents controlling the most transactions in that market."],
+      [9, 'Walk in informed', "Study their listings and recent sales before you reach out — so you walk in already knowing their business."],
+      [10, 'A relationship-first intro', "Draft a relationship-first intro, build your call list, and start showing up before your competitor does."],
+    ],
+  },
+];
+
+function tts(text, out) {
+  return new Promise((res, rej) => {
+    const body = JSON.stringify({ text, model_id: 'eleven_multilingual_v2', voice_settings: { stability: 0.5, similarity_boost: 0.8, style: 0.15 } });
+    const req = https.request(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE}`, { method: 'POST', headers: { 'xi-api-key': KEY, 'Content-Type': 'application/json', Accept: 'audio/mpeg' } }, r => { const c = []; r.on('data', d => c.push(d)); r.on('end', () => { fs.writeFileSync(out, Buffer.concat(c)); res(); }); });
+    req.on('error', rej); req.write(body); req.end();
+  });
+}
+const dur = f => parseFloat(execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 "${f}"`).toString().trim());
+
+(async () => {
+  let totalChars = 0;
+  for (const c of CLIENTS) {
+    const aud = path.join(__dirname, `client-audio/${c.id}`); fs.mkdirSync(aud, { recursive: true });
+    const scenes = [];
+    // intro
+    await tts(c.intro.vo, path.join(aud, 'intro.mp3')); totalChars += c.intro.vo.length;
+    const introDur = dur(path.join(aud, 'intro.mp3'));
+    scenes.push({ kind: 'intro', head: c.intro.head, sub: c.intro.sub, accent: c.accent, durFrames: Math.ceil((introDur + 1.2) * FPS), audioStartSec: 0 });
+    for (let k = 0; k < c.steps.length; k++) {
+      const [si, cap, vo] = c.steps[k];
+      const mp3 = path.join(aud, `s${k}.mp3`); await tts(vo, mp3); totalChars += vo.length;
+      const d = dur(mp3);
+      scenes.push({ kind: 'step', shot: shotOf(si), box: boxOf(si), cap, label: c.name, accent: c.accent, durFrames: Math.ceil((d + 1.35) * FPS) });
+    }
+    let acc = 0; for (const s of scenes) { s.audioStartSec = acc / FPS; acc += s.durFrames; }
+    fs.writeFileSync(path.join(__dirname, `src/client-${c.id}.json`), JSON.stringify({ fps: FPS, total: acc, name: c.name, accent: c.accent, scenes }, null, 1));
+    console.log(`  ${c.id}: ${scenes.length} scenes, ${(acc / FPS).toFixed(0)}s`);
+  }
+  console.log(`✔ 3 client videos, ${totalChars} chars (~$${(totalChars / 1000 * 0.30).toFixed(2)})`);
+})();
diff --git a/client-audio/escrow-officer/intro.mp3 b/client-audio/escrow-officer/intro.mp3
new file mode 100644
index 0000000..7ffdd57
Binary files /dev/null and b/client-audio/escrow-officer/intro.mp3 differ
diff --git a/client-audio/escrow-officer/s0.mp3 b/client-audio/escrow-officer/s0.mp3
new file mode 100644
index 0000000..8aa50f3
Binary files /dev/null and b/client-audio/escrow-officer/s0.mp3 differ
diff --git a/client-audio/escrow-officer/s1.mp3 b/client-audio/escrow-officer/s1.mp3
new file mode 100644
index 0000000..5fd6829
Binary files /dev/null and b/client-audio/escrow-officer/s1.mp3 differ
diff --git a/client-audio/escrow-officer/s2.mp3 b/client-audio/escrow-officer/s2.mp3
new file mode 100644
index 0000000..a1ea9fc
Binary files /dev/null and b/client-audio/escrow-officer/s2.mp3 differ
diff --git a/client-audio/escrow-officer/s3.mp3 b/client-audio/escrow-officer/s3.mp3
new file mode 100644
index 0000000..4d9831a
Binary files /dev/null and b/client-audio/escrow-officer/s3.mp3 differ
diff --git a/client-audio/escrow-officer/s4.mp3 b/client-audio/escrow-officer/s4.mp3
new file mode 100644
index 0000000..c761e5c
Binary files /dev/null and b/client-audio/escrow-officer/s4.mp3 differ
diff --git a/client-audio/escrow-officer/s5.mp3 b/client-audio/escrow-officer/s5.mp3
new file mode 100644
index 0000000..cee4905
Binary files /dev/null and b/client-audio/escrow-officer/s5.mp3 differ
diff --git a/client-audio/loan-officer/intro.mp3 b/client-audio/loan-officer/intro.mp3
new file mode 100644
index 0000000..f898498
Binary files /dev/null and b/client-audio/loan-officer/intro.mp3 differ
diff --git a/client-audio/loan-officer/s0.mp3 b/client-audio/loan-officer/s0.mp3
new file mode 100644
index 0000000..096c859
Binary files /dev/null and b/client-audio/loan-officer/s0.mp3 differ
diff --git a/client-audio/loan-officer/s1.mp3 b/client-audio/loan-officer/s1.mp3
new file mode 100644
index 0000000..a303c4d
Binary files /dev/null and b/client-audio/loan-officer/s1.mp3 differ
diff --git a/client-audio/loan-officer/s2.mp3 b/client-audio/loan-officer/s2.mp3
new file mode 100644
index 0000000..342f996
Binary files /dev/null and b/client-audio/loan-officer/s2.mp3 differ
diff --git a/client-audio/loan-officer/s3.mp3 b/client-audio/loan-officer/s3.mp3
new file mode 100644
index 0000000..4540875
Binary files /dev/null and b/client-audio/loan-officer/s3.mp3 differ
diff --git a/client-audio/loan-officer/s4.mp3 b/client-audio/loan-officer/s4.mp3
new file mode 100644
index 0000000..54ef03e
Binary files /dev/null and b/client-audio/loan-officer/s4.mp3 differ
diff --git a/client-audio/loan-officer/s5.mp3 b/client-audio/loan-officer/s5.mp3
new file mode 100644
index 0000000..4fea2b3
Binary files /dev/null and b/client-audio/loan-officer/s5.mp3 differ
diff --git a/client-audio/title-rep/intro.mp3 b/client-audio/title-rep/intro.mp3
new file mode 100644
index 0000000..fa7560f
Binary files /dev/null and b/client-audio/title-rep/intro.mp3 differ
diff --git a/client-audio/title-rep/s0.mp3 b/client-audio/title-rep/s0.mp3
new file mode 100644
index 0000000..a0fcea8
Binary files /dev/null and b/client-audio/title-rep/s0.mp3 differ
diff --git a/client-audio/title-rep/s1.mp3 b/client-audio/title-rep/s1.mp3
new file mode 100644
index 0000000..1d56382
Binary files /dev/null and b/client-audio/title-rep/s1.mp3 differ
diff --git a/client-audio/title-rep/s2.mp3 b/client-audio/title-rep/s2.mp3
new file mode 100644
index 0000000..86717e5
Binary files /dev/null and b/client-audio/title-rep/s2.mp3 differ
diff --git a/client-audio/title-rep/s3.mp3 b/client-audio/title-rep/s3.mp3
new file mode 100644
index 0000000..9f39ee2
Binary files /dev/null and b/client-audio/title-rep/s3.mp3 differ
diff --git a/client-audio/title-rep/s4.mp3 b/client-audio/title-rep/s4.mp3
new file mode 100644
index 0000000..d9668b8
Binary files /dev/null and b/client-audio/title-rep/s4.mp3 differ
diff --git a/client-audio/title-rep/s5.mp3 b/client-audio/title-rep/s5.mp3
new file mode 100644
index 0000000..031b91c
Binary files /dev/null and b/client-audio/title-rep/s5.mp3 differ
diff --git a/heygen/jobs.tsv b/heygen/jobs.tsv
new file mode 100644
index 0000000..f97dbeb
--- /dev/null
+++ b/heygen/jobs.tsv
@@ -0,0 +1,3 @@
+loan-officer	90f6792159e642bf8baa4995cdee965e	43c7caa8855840479aad2f8121e5d509
+escrow-officer	9441d11f2f54448d8d9ccf8f14c105de	edbfe795805f4f0eab093203aaa3a92b
+title-rep	50186aaa0af84d9c9829e423869f1f1e	ae2f3a27a8c34b4c875f7470ff204006
diff --git a/heygen/poll.sh b/heygen/poll.sh
new file mode 100755
index 0000000..cbfa624
--- /dev/null
+++ b/heygen/poll.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+# Silent poller: check the 3 HeyGen jobs, download each when completed.
+export PATH="$HOME/.local/bin:$PATH"
+export HEYGEN_API_KEY=$(grep -m1 '^HEYGEN_API_KEY=' ~/Projects/secrets-manager/.env | cut -d= -f2- | tr -d '"'\''')
+cd ~/Projects/crcp-pitch-video
+mkdir -p out
+LOG=heygen/poll.log
+: > "$LOG"
+declare_done() { echo "$1" >> heygen/done.txt; }
+: > heygen/done.txt
+
+ids=$(cut -f2 heygen/jobs.tsv)
+deadline=$(( $(date +%s) + 75*60 ))
+
+while :; do
+  alldone=1
+  while IFS=$'\t' read -r name vid sid; do
+    [ -z "$vid" ] && continue
+    grep -q "^$vid\$" heygen/done.txt 2>/dev/null && continue
+    [ -f "out/heygen-$name.mp4" ] && { declare_done "$vid"; continue; }
+    resp=$(heygen video get "$vid" </dev/null 2>/dev/null)
+    st=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['data'].get('status',''))" 2>/dev/null)
+    echo "$(date +%H:%M:%S) $name $st" >> "$LOG"
+    if [ "$st" = "completed" ] || [ "$st" = "success" ]; then
+      url=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['data'].get('video_url',''))" 2>/dev/null)
+      dur=$(echo "$resp" | python3 -c "import sys,json;print(json.load(sys.stdin)['data'].get('duration',''))" 2>/dev/null)
+      if [ -n "$url" ]; then
+        curl -fsSL "$url" -o "out/heygen-$name.mp4" && echo "$(date +%H:%M:%S) DOWNLOADED $name (dur=$dur) $(ls -lh out/heygen-$name.mp4 | awk '{print $5}')" >> "$LOG"
+        declare_done "$vid"
+      else
+        alldone=0
+      fi
+    elif [ "$st" = "failed" ] || [ "$st" = "error" ]; then
+      err=$(echo "$resp" | python3 -c "import sys,json;d=json.load(sys.stdin)['data'];print(d.get('error') or d.get('failure') or 'unknown')" 2>/dev/null)
+      echo "$(date +%H:%M:%S) FAILED $name: $err" >> "$LOG"
+      declare_done "$vid"
+    else
+      alldone=0
+    fi
+  done < heygen/jobs.tsv
+  [ "$alldone" = "1" ] && { echo "ALL DONE" >> "$LOG"; break; }
+  [ "$(date +%s)" -ge "$deadline" ] && { echo "TIMEOUT" >> "$LOG"; break; }
+  sleep 60
+done
+echo "=== poll finished ===" >> "$LOG"
+ls -lh out/heygen-*.mp4 2>/dev/null >> "$LOG"
diff --git a/heygen/prompt-escrow-officer.txt b/heygen/prompt-escrow-officer.txt
new file mode 100644
index 0000000..575b2ac
--- /dev/null
+++ b/heygen/prompt-escrow-officer.txt
@@ -0,0 +1,7 @@
+Create a 50-second LANDSCAPE (16:9) explainer video for ESCROW OFFICERS. A confident, polished business spokesperson speaks directly to camera in a modern office setting, with clean supporting on-screen text and subtle data-style motion graphics.
+
+Subject: CRCP — the Commercial Real Estate Command Post at crcp.agentabrams.com — is a searchable intelligence database of every licensed real estate agent and broker across the 20 most expensive housing markets in America. Over 355,000 agents, sourced from official government real-estate license records, each shown with their license status, live listings, and recent sales activity.
+
+Angle for escrow officers: The agent chooses the escrow. So your job is knowing exactly which agents to court. Use CRCP to load every licensed agent in your market, sort by activity to find the highest-volume agents — they open the most escrows — read an agent's pipeline to see how many deals are about to need an escrow officer, or target an entire brokerage at once. Win the office, win its escrows.
+
+Tone: sharp, credible, benefit-driven B2B. End with the call to action: "Visit CRCP and be first in line, before the deal even opens." Keep on-screen text minimal and professional.
diff --git a/heygen/prompt-loan-officer.txt b/heygen/prompt-loan-officer.txt
new file mode 100644
index 0000000..8ec3f4a
--- /dev/null
+++ b/heygen/prompt-loan-officer.txt
@@ -0,0 +1,7 @@
+Create a 50-second LANDSCAPE (16:9) explainer video for LOAN OFFICERS. A confident, polished business spokesperson speaks directly to camera in a modern office setting, with clean supporting on-screen text and subtle data-style motion graphics.
+
+Subject: CRCP — the Commercial Real Estate Command Post at crcp.agentabrams.com — is a searchable intelligence database of every licensed real estate agent and broker across the 20 most expensive housing markets in America. Over 355,000 agents, sourced from official government real-estate license records, each shown with their license status, live listings, and recent sales activity.
+
+Angle for loan officers: Your borrowers come from agents. Use CRCP to find the highest-volume agents in luxury markets — where a single deal is a jumbo loan — sort agents by listing activity to surface who has buyers needing financing right now, open an agent to see real, live deal flow, then build a targeted call list for co-marketing: offer to pre-qualify their buyers or co-host an open house.
+
+Tone: sharp, credible, benefit-driven B2B. End with the call to action: "Visit CRCP and turn top agents into your borrower pipeline." Keep on-screen text minimal and professional.
diff --git a/heygen/prompt-title-rep.txt b/heygen/prompt-title-rep.txt
new file mode 100644
index 0000000..9378ec3
--- /dev/null
+++ b/heygen/prompt-title-rep.txt
@@ -0,0 +1,7 @@
+Create a 50-second LANDSCAPE (16:9) explainer video for TITLE REPS. A confident, polished business spokesperson speaks directly to camera in a modern office setting, with clean supporting on-screen text and subtle data-style motion graphics.
+
+Subject: CRCP — the Commercial Real Estate Command Post at crcp.agentabrams.com — is a searchable intelligence database of every licensed real estate agent and broker across the 20 most expensive housing markets in America. Over 355,000 agents, sourced from official government real-estate license records, each shown with their license status, live listings, and recent sales activity.
+
+Angle for title reps: Title is a relationship business, and CRCP hands you the relationships worth building. Target the markets with the priciest inventory — the biggest title premiums in the country — see exactly where each agent is licensed and active, sort by activity to find the agents controlling the most transactions, study their listings and recent sales so you walk in already knowing their business, then build a relationship-first call list and show up before your competitor does.
+
+Tone: sharp, credible, benefit-driven B2B. End with the call to action: "Visit CRCP and own the relationships that carry the biggest premiums." Keep on-screen text minimal and professional.
diff --git a/render-clients.sh b/render-clients.sh
new file mode 100755
index 0000000..24e3223
--- /dev/null
+++ b/render-clients.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+cd ~/Projects/crcp-pitch-video
+SHELL_BIN="$HOME/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell"
+declare -A COMP=( [loan-officer]=ClientLoanOfficer [escrow-officer]=ClientEscrow [title-rep]=ClientTitle )
+for id in loan-officer escrow-officer title-rep; do
+  comp=${COMP[$id]}
+  echo "=== render $comp ==="
+  npx remotion render "$comp" "out/client-$id.mp4" --concurrency=4 --browser-executable="$SHELL_BIN" 2>&1 | grep -viE "Getting|Downloading|Bundling|Rendered [0-9]" | tail -3
+  # mux narration (intro + steps) at audioStartSec offsets
+  node -e "
+    const fs=require('fs'),cp=require('child_process');
+    const d=JSON.parse(fs.readFileSync('src/client-$id.json'));
+    const dir='client-audio/$id';
+    let inputs=[],filt=[],labs=[];
+    d.scenes.forEach((s,i)=>{ const f=(s.kind==='intro')?dir+'/intro.mp3':dir+'/s'+(i-1)+'.mp3'; inputs.push('-i',f); const ms=Math.round(s.audioStartSec*1000); filt.push('['+(i+1)+']adelay='+ms+'|'+ms+'[a'+i+']'); labs.push('[a'+i+']'); });
+    const fc=filt.join(';')+';'+labs.join('')+'amix=inputs='+d.scenes.length+':normalize=0[aout]';
+    const args=['-y','-loglevel','error','-i','out/client-$id.mp4',...inputs,'-filter_complex',fc,'-map','0:v','-map','[aout]','-c:v','copy','-c:a','aac','-b:a','160k','-shortest','out/client-$id-narrated.mp4'];
+    cp.execFileSync('ffmpeg',args); console.log('  muxed client-$id-narrated.mp4');
+  "
+done
+echo "ALL CLIENT VIDEOS DONE"
diff --git a/src/ClientWalk.tsx b/src/ClientWalk.tsx
new file mode 100644
index 0000000..537b3a4
--- /dev/null
+++ b/src/ClientWalk.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { AbsoluteFill, Img, staticFile, Sequence, useCurrentFrame, useVideoConfig, interpolate, spring, Easing } from 'remotion';
+
+const SANS = '-apple-system, "Helvetica Neue", Arial, sans-serif';
+const SERIF = 'Georgia, "Times New Roman", serif';
+type Box = { x: number; y: number; width: number; height: number } | null;
+
+const IntroScene: React.FC<{ head: string; sub: string; accent: string; dur: number }> = ({ head, sub, accent }) => {
+  const f = useCurrentFrame(); const { fps } = useVideoConfig();
+  const s = spring({ frame: f - 4, fps, config: { damping: 14 } });
+  const fade = interpolate(f, [4, 24], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
+  return (
+    <AbsoluteFill style={{ background: 'radial-gradient(1200px 700px at 50% 22%, #11151f 0%, #0a0d13 62%)', justifyContent: 'center', alignItems: 'center', textAlign: 'center' }}>
+      <AbsoluteFill style={{ backgroundImage: `linear-gradient(${accent}18 1px, transparent 1px), linear-gradient(90deg, ${accent}18 1px, transparent 1px)`, backgroundSize: '64px 64px', opacity: 0.4 }} />
+      <div style={{ fontFamily: SANS, fontSize: 26, letterSpacing: 8, textTransform: 'uppercase', color: accent, fontWeight: 700, opacity: fade, transform: `translateY(${(1 - s) * 26}px)` }}>CRCP</div>
+      <div style={{ fontFamily: SERIF, fontSize: 96, color: '#EAECEF', fontWeight: 800, marginTop: 22, opacity: fade, transform: `scale(${interpolate(s, [0, 1], [0.92, 1])})` }}>{head}</div>
+      <div style={{ fontFamily: SANS, fontSize: 34, color: '#8A93A2', marginTop: 22, opacity: interpolate(f, [22, 40], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) }}>{sub}</div>
+      <div style={{ width: interpolate(f, [26, 60], [0, 320], { extrapolateRight: 'clamp' }), height: 3, background: accent, marginTop: 40, borderRadius: 3 }} />
+    </AbsoluteFill>
+  );
+};
+
+const StepScene: React.FC<{ shot: string; box: Box; cap: string; label: string; accent: string; dur: number }> = ({ shot, box, cap, label, accent, dur }) => {
+  const f = useCurrentFrame(); const { fps } = useVideoConfig();
+  const z = interpolate(f, [0, dur], [1.0, 1.035], { extrapolateRight: 'clamp' });
+  const appear = interpolate(f, [0, 10], [0, 1], { extrapolateRight: 'clamp' });
+  const pop = spring({ frame: f - 6, fps, config: { damping: 12, mass: 0.6 } });
+  const pulse = 0.55 + 0.45 * Math.abs(Math.sin(f / 11)); const pad = 10;
+  return (
+    <AbsoluteFill style={{ background: '#05070b' }}>
+      <AbsoluteFill style={{ transform: `scale(${z})`, transformOrigin: '50% 45%' }}>
+        <Img src={staticFile(shot)} style={{ width: 1920, height: 1080 }} />
+        <AbsoluteFill style={{ background: 'rgba(3,5,10,0.24)', opacity: appear }} />
+        {box && (
+          <div style={{ position: 'absolute', left: box.x - pad, top: box.y - pad, width: box.width + pad * 2, height: box.height + pad * 2, border: `4px solid ${accent}`, borderRadius: 10, boxShadow: `0 0 ${18 + pulse * 22}px ${accent}, inset 0 0 12px ${accent}40`, opacity: appear * (0.85 + pulse * 0.15), transform: `scale(${interpolate(pop, [0, 1], [1.12, 1])})`, transformOrigin: 'center' }} />
+        )}
+      </AbsoluteFill>
+      <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, height: 138, background: 'linear-gradient(180deg, rgba(5,7,11,0) 0%, rgba(5,7,11,0.93) 44%)', display: 'flex', alignItems: 'flex-end', padding: '0 56px 28px', transform: `translateY(${interpolate(appear, [0, 1], [40, 0])}px)`, opacity: appear }}>
+        <div style={{ display: 'flex', alignItems: 'center', gap: 22 }}>
+          <div style={{ fontFamily: SANS, fontSize: 21, fontWeight: 800, color: '#05070b', background: accent, borderRadius: 8, padding: '6px 14px', textTransform: 'uppercase', letterSpacing: 1 }}>{label}</div>
+          <div style={{ fontFamily: SANS, fontSize: 40, fontWeight: 800, color: '#EAECEF' }}>{cap}</div>
+        </div>
+      </div>
+    </AbsoluteFill>
+  );
+};
+
+export const ClientWalk: React.FC<{ data: any }> = ({ data }) => {
+  let from = 0;
+  return (
+    <AbsoluteFill style={{ background: '#05070b' }}>
+      {data.scenes.map((s: any, i: number) => {
+        const el = (
+          <Sequence key={i} from={from} durationInFrames={s.durFrames}>
+            {s.kind === 'intro'
+              ? <IntroScene head={s.head} sub={s.sub} accent={s.accent} dur={s.durFrames} />
+              : <StepScene shot={s.shot} box={s.box} cap={s.cap} label={s.label} accent={s.accent} dur={s.durFrames} />}
+          </Sequence>
+        );
+        from += s.durFrames;
+        return el;
+      })}
+    </AbsoluteFill>
+  );
+};
diff --git a/src/Root.tsx b/src/Root.tsx
index 9e6f8e7..ebd42f7 100644
--- a/src/Root.tsx
+++ b/src/Root.tsx
@@ -2,6 +2,10 @@ import React from 'react';
 import { Composition } from 'remotion';
 import { CRCPPitch, CRCPShort, TOTAL_FRAMES, SHORT_FRAMES, FPS } from './Video';
 import { CRCPWalkthrough, WALK_FRAMES, WALK_FPS } from './Walkthrough';
+import { ClientWalk } from './ClientWalk';
+import loanData from './client-loan-officer.json';
+import escrowData from './client-escrow-officer.json';
+import titleData from './client-title-rep.json';
 
 export const RemotionRoot: React.FC = () => {
   return (
@@ -9,6 +13,9 @@ export const RemotionRoot: React.FC = () => {
       <Composition id="CRCPPitch" component={CRCPPitch} durationInFrames={TOTAL_FRAMES} fps={FPS} width={1920} height={1080} />
       <Composition id="CRCPShort" component={CRCPShort} durationInFrames={SHORT_FRAMES} fps={FPS} width={1920} height={1080} />
       <Composition id="CRCPWalkthrough" component={CRCPWalkthrough} durationInFrames={WALK_FRAMES} fps={WALK_FPS} width={1920} height={1080} />
+      <Composition id="ClientLoanOfficer" component={ClientWalk} defaultProps={{ data: loanData }} durationInFrames={loanData.total} fps={loanData.fps} width={1920} height={1080} />
+      <Composition id="ClientEscrow" component={ClientWalk} defaultProps={{ data: escrowData }} durationInFrames={escrowData.total} fps={escrowData.fps} width={1920} height={1080} />
+      <Composition id="ClientTitle" component={ClientWalk} defaultProps={{ data: titleData }} durationInFrames={titleData.total} fps={titleData.fps} width={1920} height={1080} />
     </>
   );
 };
diff --git a/src/client-escrow-officer.json b/src/client-escrow-officer.json
new file mode 100644
index 0000000..1c0a9f7
--- /dev/null
+++ b/src/client-escrow-officer.json
@@ -0,0 +1,106 @@
+{
+ "fps": 30,
+ "total": 1194,
+ "name": "Escrow Officers",
+ "accent": "#58a6ff",
+ "scenes": [
+  {
+   "kind": "intro",
+   "head": "For Escrow Officers",
+   "sub": "Win the agents, win their escrows",
+   "accent": "#58a6ff",
+   "durFrames": 154,
+   "audioStartSec": 0
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot00.png",
+   "box": {
+    "x": 1737.765625,
+    "y": 21.296875,
+    "width": 164.234375,
+    "height": 17.390625
+   },
+   "cap": "The agent picks escrow",
+   "label": "Escrow Officers",
+   "accent": "#58a6ff",
+   "durFrames": 173,
+   "audioStartSec": 5.133333333333334
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot13.png",
+   "box": {
+    "x": 0,
+    "y": 58,
+    "width": 1920,
+    "height": 123.375
+   },
+   "cap": "Pick your market",
+   "label": "Escrow Officers",
+   "accent": "#58a6ff",
+   "durFrames": 136,
+   "audioStartSec": 10.9
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot06.png",
+   "box": {
+    "x": 981.984375,
+    "y": 86,
+    "width": 84.90625,
+    "height": 32.21875
+   },
+   "cap": "Find the highest-volume agents",
+   "label": "Escrow Officers",
+   "accent": "#58a6ff",
+   "durFrames": 198,
+   "audioStartSec": 15.433333333333334
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot09.png",
+   "box": {
+    "x": 610,
+    "y": 40,
+    "width": 700,
+    "height": 705.265625
+   },
+   "cap": "Read their pipeline",
+   "label": "Escrow Officers",
+   "accent": "#58a6ff",
+   "durFrames": 166,
+   "audioStartSec": 22.033333333333335
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot04.png",
+   "box": {
+    "x": 16,
+    "y": 263.59375,
+    "width": 227,
+    "height": 529.640625
+   },
+   "cap": "Target a whole brokerage",
+   "label": "Escrow Officers",
+   "accent": "#58a6ff",
+   "durFrames": 184,
+   "audioStartSec": 27.566666666666666
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot11.png",
+   "box": {
+    "x": 1673.046875,
+    "y": 11,
+    "width": 69.34375,
+    "height": 38
+   },
+   "cap": "Build your outreach list",
+   "label": "Escrow Officers",
+   "accent": "#58a6ff",
+   "durFrames": 183,
+   "audioStartSec": 33.7
+  }
+ ]
+}
\ No newline at end of file
diff --git a/src/client-loan-officer.json b/src/client-loan-officer.json
new file mode 100644
index 0000000..555abfe
--- /dev/null
+++ b/src/client-loan-officer.json
@@ -0,0 +1,106 @@
+{
+ "fps": 30,
+ "total": 1326,
+ "name": "Loan Officers",
+ "accent": "#6BCB77",
+ "scenes": [
+  {
+   "kind": "intro",
+   "head": "For Loan Officers",
+   "sub": "Turn top agents into a borrower pipeline",
+   "accent": "#6BCB77",
+   "durFrames": 176,
+   "audioStartSec": 0
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot00.png",
+   "box": {
+    "x": 1737.765625,
+    "y": 21.296875,
+    "width": 164.234375,
+    "height": 17.390625
+   },
+   "cap": "Your borrowers come from agents",
+   "label": "Loan Officers",
+   "accent": "#6BCB77",
+   "durFrames": 151,
+   "audioStartSec": 5.866666666666666
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot12.png",
+   "box": {
+    "x": 0,
+    "y": 58,
+    "width": 1920,
+    "height": 123.375
+   },
+   "cap": "Start where loans are biggest",
+   "label": "Loan Officers",
+   "accent": "#6BCB77",
+   "durFrames": 180,
+   "audioStartSec": 10.9
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot06.png",
+   "box": {
+    "x": 981.984375,
+    "y": 86,
+    "width": 84.90625,
+    "height": 32.21875
+   },
+   "cap": "Sort by activity",
+   "label": "Loan Officers",
+   "accent": "#6BCB77",
+   "durFrames": 211,
+   "audioStartSec": 16.9
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot09.png",
+   "box": {
+    "x": 610,
+    "y": 40,
+    "width": 700,
+    "height": 705.265625
+   },
+   "cap": "See their live deal flow",
+   "label": "Loan Officers",
+   "accent": "#6BCB77",
+   "durFrames": 208,
+   "audioStartSec": 23.933333333333334
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot10.png",
+   "box": {
+    "x": 633,
+    "y": 672.96875,
+    "width": 174.015625,
+    "height": 31
+   },
+   "cap": "Pitch a co-marketing play",
+   "label": "Loan Officers",
+   "accent": "#6BCB77",
+   "durFrames": 211,
+   "audioStartSec": 30.866666666666667
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot11.png",
+   "box": {
+    "x": 1673.046875,
+    "y": 11,
+    "width": 69.34375,
+    "height": 38
+   },
+   "cap": "Load your dialer",
+   "label": "Loan Officers",
+   "accent": "#6BCB77",
+   "durFrames": 189,
+   "audioStartSec": 37.9
+  }
+ ]
+}
\ No newline at end of file
diff --git a/src/client-title-rep.json b/src/client-title-rep.json
new file mode 100644
index 0000000..d6112b8
--- /dev/null
+++ b/src/client-title-rep.json
@@ -0,0 +1,106 @@
+{
+ "fps": 30,
+ "total": 1373,
+ "name": "Title Reps",
+ "accent": "#C8A24B",
+ "scenes": [
+  {
+   "kind": "intro",
+   "head": "For Title Reps",
+   "sub": "Own the relationships that carry the biggest premiums",
+   "accent": "#C8A24B",
+   "durFrames": 169,
+   "audioStartSec": 0
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot00.png",
+   "box": {
+    "x": 1737.765625,
+    "y": 21.296875,
+    "width": 164.234375,
+    "height": 17.390625
+   },
+   "cap": "Title is a relationship business",
+   "label": "Title Reps",
+   "accent": "#C8A24B",
+   "durFrames": 211,
+   "audioStartSec": 5.633333333333334
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot12.png",
+   "box": {
+    "x": 0,
+    "y": 58,
+    "width": 1920,
+    "height": 123.375
+   },
+   "cap": "Go where inventory is priciest",
+   "label": "Title Reps",
+   "accent": "#C8A24B",
+   "durFrames": 176,
+   "audioStartSec": 12.666666666666666
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot05.png",
+   "box": {
+    "x": 16,
+    "y": 861.109375,
+    "width": 227,
+    "height": 320
+   },
+   "cap": "See where each agent works",
+   "label": "Title Reps",
+   "accent": "#C8A24B",
+   "durFrames": 198,
+   "audioStartSec": 18.533333333333335
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot06.png",
+   "box": {
+    "x": 981.984375,
+    "y": 86,
+    "width": 84.90625,
+    "height": 32.21875
+   },
+   "cap": "Find the deal-controllers",
+   "label": "Title Reps",
+   "accent": "#C8A24B",
+   "durFrames": 184,
+   "audioStartSec": 25.133333333333333
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot09.png",
+   "box": {
+    "x": 610,
+    "y": 40,
+    "width": 700,
+    "height": 705.265625
+   },
+   "cap": "Walk in informed",
+   "label": "Title Reps",
+   "accent": "#C8A24B",
+   "durFrames": 217,
+   "audioStartSec": 31.266666666666666
+  },
+  {
+   "kind": "step",
+   "shot": "walkthrough/shot10.png",
+   "box": {
+    "x": 633,
+    "y": 672.96875,
+    "width": 174.015625,
+    "height": 31
+   },
+   "cap": "A relationship-first intro",
+   "label": "Title Reps",
+   "accent": "#C8A24B",
+   "durFrames": 218,
+   "audioStartSec": 38.5
+  }
+ ]
+}
\ No newline at end of file

← b178aa4 CRCP walkthrough video — Playwright click-through capture (1  ·  back to Crcp Pitch Video  ·  (newest)