[object Object]

← back to Rentv Tour

rentv-tour: 3-act walkthrough — add Growth act (ACT I) before Front End + Backend, VO-duration-paced, Steve cloned voice

75dec7663eba97a377f6610c0ce79940d13638cd · 2026-08-07 14:35:30 -0700 · Steve

Files touched

Diff

commit 75dec7663eba97a377f6610c0ce79940d13638cd
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 14:35:30 2026 -0700

    rentv-tour: 3-act walkthrough — add Growth act (ACT I) before Front End + Backend, VO-duration-paced, Steve cloned voice
---
 generate.mjs   | 35 ++++++++++++++++++++++++++---------
 growth-cap.mjs | 32 ++++++++++++++++++++++++++++++++
 vo-gen.mjs     | 45 +++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 103 insertions(+), 9 deletions(-)

diff --git a/generate.mjs b/generate.mjs
index 2c8dd3c..35b1d87 100644
--- a/generate.mjs
+++ b/generate.mjs
@@ -1,10 +1,15 @@
 import fs from 'fs';
 const M = JSON.parse(fs.readFileSync('videos/site-tour/assets/manifest.json', 'utf8'));
+const VO = JSON.parse(fs.readFileSync('videos/site-tour/assets/vo-durations.json', 'utf8'));
 const W = 1920, H = 1080;
 const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
 
-// paced to Steve's 51.4s VO: front tabs end (~30s) as the narration turns to "the admin side"
-const FRONT_DUR = 1.0833, ADMIN_DUR = 2.443;
+// 3-act structure (Growth -> Front End -> Backend), paced to the 3-act VO. Each act opens with a
+// 1.5s act card, then its frames fill the remainder of that act's narration length exactly.
+const CARD = 1.5;
+const GROWTH_DUR = (VO.growth - CARD) / M.growth.length;
+const FRONT_DUR = (VO.front - CARD) / M.front.length;
+const ADMIN_DUR = (VO.admin - CARD) / M.admin.length;
 const clips = [];   // {html}
 const tweens = [];  // gsap lines
 
@@ -43,12 +48,24 @@ function frame(id, src, label, idx, count, actTag, accent, start, dur) {
 }
 
 // ---- TITLE ----
-card('title', 'RENTV.COM', 'Full Site Walkthrough', 'Every tab — Front End & Admin Backend', 0, 2.5, '#57b0ff');
+card('title', 'RENTV.COM', 'Full Site Walkthrough', 'Growth · Front End · Admin Backend', 0, 2.5, '#34d399');
 t = 2.5;
 
-// ---- FRONT END ACT ----
-const FA_START = t;
-card('front-act', 'ACT I', 'Front End', 'The public CRE news platform', t, 1.5, '#57b0ff');
+// ---- GROWTH ACT (ACT I) ----
+card('growth-act', 'ACT I', 'The Growth Play', 'The strategy — build the owned audience', t, 1.5, '#34d399');
+t += 1.5;
+const growthStart = t;
+M.growth.forEach((g, i) => {
+  const st = growthStart + i * GROWTH_DUR;
+  frame(`g${i}`, `assets/growth/${g.file.split('/').pop()}`, g.label, i + 1, M.growth.length, 'GROWTH', '#10b981', st, GROWTH_DUR);
+});
+const growthEnd = growthStart + M.growth.length * GROWTH_DUR;
+clips.push(`  <div id="tag-growth" class="clip corner-tag" data-start="${growthStart}" data-duration="${(growthEnd-growthStart).toFixed(3)}" data-track-index="4"><span class="dot" style="background:#34d399"></span>GROWTH · strategy</div>`);
+tweens.push(`tl.from('#tag-growth',{autoAlpha:0,duration:0.4},${growthStart.toFixed(3)});`);
+t = growthEnd;
+
+// ---- FRONT END ACT (ACT II) ----
+card('front-act', 'ACT II', 'Front End', 'The public CRE news platform', t, 1.5, '#57b0ff');
 t += 1.5;
 const frontStart = t;
 M.front.forEach((f, i) => {
@@ -61,8 +78,8 @@ clips.push(`  <div id="tag-front" class="clip corner-tag" data-start="${frontSta
 tweens.push(`tl.from('#tag-front',{autoAlpha:0,duration:0.4},${frontStart.toFixed(3)});`);
 t = frontEnd;
 
-// ---- ADMIN ACT ----
-card('admin-act', 'ACT II', 'Admin Backend', 'Internal intelligence — admin only', t, 1.5, '#c8a15a');
+// ---- ADMIN ACT (ACT III) ----
+card('admin-act', 'ACT III', 'Admin Backend', 'Internal intelligence — admin only', t, 1.5, '#c8a15a');
 t += 1.5;
 const adminStart = t;
 M.admin.forEach((a, i) => {
@@ -112,7 +129,7 @@ const html = `<!doctype html>
 <body>
   <div id="root" data-composition-id="main" data-start="0" data-width="${W}" data-height="${H}" data-duration="${TOTAL}" data-fps="30">
   <div id="bg" class="clip" data-start="0" data-duration="${TOTAL}" data-track-index="0"></div>
-  <audio id="vo" src="assets/rentv-vo.m4a" data-start="0" data-volume="1"></audio>
+  <audio id="vo" src="assets/rentv-vo-3act.m4a" data-start="0" data-volume="1"></audio>
 ${clips.join('\n')}
   </div>
   <script>
diff --git a/growth-cap.mjs b/growth-cap.mjs
new file mode 100644
index 0000000..0ca29f9
--- /dev/null
+++ b/growth-cap.mjs
@@ -0,0 +1,32 @@
+import { chromium } from 'playwright';
+import fs from 'fs';
+
+// Growth act (Act I) — scroll-capture the /consulting growth-strategy deck into distinct frames.
+const BASE = 'https://rentv.agentabrams.com';
+const VW = 1440, VH = 810;
+const pad = (n) => String(n).padStart(2, '0');
+fs.mkdirSync('frames/growth', { recursive: true });
+
+const browser = await chromium.launch();
+const ctx = await browser.newContext({ viewport: { width: VW, height: VH }, httpCredentials: { username: 'admin', password: 'DW2024!' }, deviceScaleFactor: 2 });
+const page = await ctx.newPage();
+await page.goto(BASE + '/consulting', { waitUntil: 'networkidle', timeout: 30000 });
+await page.waitForTimeout(1500);
+
+// total scroll height → grab evenly-spaced sections as the Growth frames
+const total = await page.evaluate(() => document.body.scrollHeight);
+const LABELS = ['Growth Strategy', 'The Opportunity', 'Audience First', 'Deal + Conference Engine', 'Every Page, Graded', 'The Play'];
+const N = LABELS.length;
+const manifest = [];
+for (let i = 0; i < N; i++) {
+  const y = Math.round((total - VH) * (i / (N - 1)));
+  await page.evaluate((yy) => window.scrollTo(0, yy), y);
+  await page.waitForTimeout(600);
+  const file = `frames/growth/${pad(i)}-growth.png`;
+  await page.screenshot({ path: file });
+  manifest.push({ i, route: '/consulting', label: LABELS[i], role: 'admin', status: 200, file });
+  console.log(`  [growth] ${pad(i)} y=${y} -> ${LABELS[i]}`);
+}
+await browser.close();
+fs.writeFileSync('frames/growth-manifest.json', JSON.stringify(manifest, null, 2));
+console.log(`DONE: ${manifest.length} growth frames (page height ${total}px)`);
diff --git a/vo-gen.mjs b/vo-gen.mjs
new file mode 100644
index 0000000..a8ef68d
--- /dev/null
+++ b/vo-gen.mjs
@@ -0,0 +1,45 @@
+import fs from 'fs';
+import { execSync } from 'child_process';
+
+// 3-act VO in Steve's cloned voice. Generate each act separately so generate.mjs can pace each
+// act's frames exactly to its narration length, then concat into one continuous track for muxing.
+const KEY = (fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8').match(/ELEVENLABS_API_KEY=(.+)/) || [])[1]?.trim();
+if (!KEY) { console.error('NO ELEVENLABS_API_KEY'); process.exit(1); }
+const VOICE = 'Xa9qV4wNbvSkdUWsYLzq'; // Steve's cloned voice (same as the original walkthrough)
+const MODEL = 'eleven_turbo_v2_5';
+
+const ACTS = {
+  growth: "This is RENTV — and this is the growth play. A trusted name in commercial real estate news, under-leveraged for too long. The strategy is simple: build the owned audience first, then ride the deal and conference calendar. You're not just a website — you're a media company. Every page, graded, with the fix.",
+  front: "Here's the front end — the public commercial real estate news platform. The home wire, the intelligence hub, search, rates and capital markets, sector deep-dives, deal flow by metro, the toolkit, the directory, and the RENTV brief. Everything a CRE audience needs, in one place.",
+  admin: "And behind it — the admin backend, internal intelligence, admin only. The backend hub, the broker and owner desk, audience and C-R-M, ten admin dashboards, the site versions, consulting, and the video library. One unified app — user and admin. RENTV, ready for 2026.",
+};
+
+const durations = {};
+let totalChars = 0;
+for (const [id, text] of Object.entries(ACTS)) {
+  totalChars += text.length;
+  const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE}?output_format=mp3_44100_128`, {
+    method: 'POST',
+    headers: { 'xi-api-key': KEY, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ text, model_id: MODEL, voice_settings: { stability: 0.5, similarity_boost: 0.85, style: 0.1, use_speaker_boost: true } }),
+  });
+  if (!res.ok) { console.error(`TTS ${id} failed: ${res.status} ${await res.text()}`); process.exit(1); }
+  const buf = Buffer.from(await res.arrayBuffer());
+  fs.writeFileSync(`/tmp/vo-${id}.mp3`, buf);
+  execSync(`ffmpeg -v error -y -i /tmp/vo-${id}.mp3 -c:a aac -b:a 160k videos/site-tour/assets/vo-${id}.m4a`);
+  const d = parseFloat(execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 videos/site-tour/assets/vo-${id}.m4a`).toString().trim());
+  durations[id] = d;
+  console.log(`  ${id}: ${d.toFixed(2)}s (${text.length} chars)`);
+}
+
+// concat the 3 acts (with a 2.5s lead silence for the title card) into one continuous VO
+execSync(`ffmpeg -v error -y -f lavfi -t 2.5 -i anullsrc=r=44100:cl=stereo -c:a aac -b:a 160k /tmp/vo-lead.m4a`);
+fs.writeFileSync('/tmp/vo-concat.txt', ["/tmp/vo-lead.m4a","videos/site-tour/assets/vo-growth.m4a","videos/site-tour/assets/vo-front.m4a","videos/site-tour/assets/vo-admin.m4a"].map(f=>`file '${f.startsWith('/')?f:process.cwd()+'/'+f}'`).join('\n'));
+execSync(`ffmpeg -v error -y -f concat -safe 0 -i /tmp/vo-concat.txt -c:a aac -b:a 160k videos/site-tour/assets/rentv-vo-3act.m4a`);
+const totalD = parseFloat(execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 videos/site-tour/assets/rentv-vo-3act.m4a`).toString().trim());
+
+// hand pacing to generate.mjs
+fs.writeFileSync('videos/site-tour/assets/vo-durations.json', JSON.stringify({ lead: 2.5, ...durations, total: totalD }, null, 2));
+const cost = (totalChars / 1000) * 0.30; // ~$0.30 / 1k chars (turbo)
+console.log(`\nTOTAL VO ${totalD.toFixed(2)}s | ${totalChars} chars | est cost ~$${cost.toFixed(2)}`);
+console.log('durations:', JSON.stringify(durations));

← 521eac4 auto-save: 2026-07-28T15:31:27 (2 files) — fullpage-results.  ·  back to Rentv Tour  ·  chore: version bump v1.1.0 (session close — no lint toolchai 49af893 →