← back to Rentv 2026

mockups/video/build-admin-video.js

89 lines

#!/usr/bin/env node
/* BACK-OFFICE ADMIN walkthrough — the CRCP-style broker & owner directory.
   Captures the real clickable lists (LinkedIn / website / Maps / firm / CA-DRE),
   a row expanded to its action buttons, search, and the firms + owners tabs.
   VO synced to each screen. Never mentions how long the build took. */
const path = require('path'), fs = require('fs');
const { execFileSync } = require('child_process');
const SK = path.join(process.env.HOME, '.claude/skills/app-demo-video');
const { chromium } = require(path.join(SK, 'node_modules/playwright'));
let generateVoice, ffprobeDuration;
try { ({ generateVoice } = require(path.join(SK, 'scripts/tts.js'))); } catch {}
try { ({ ffprobeDuration } = require(path.join(SK, 'scripts/merge.js'))); } catch {}
const ADMIN = 'http://127.0.0.1:9719/admin/';
const OUT = path.resolve(__dirname, 'out'), SHOTS = path.join(OUT, 'shots'), SLIDES = path.join(OUT, 'slides'), AUDIO = path.join(OUT, 'audio');
[OUT, SHOTS, SLIDES, AUDIO].forEach(d => fs.mkdirSync(d, { recursive: true }));
const FINAL = path.join(OUT, 'rentv-admin-walkthrough.mp4');
const RED = '#c8102e', W = 1280, H = 800;
const esc = s => String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
async function vo(text, out) {
  try { if (generateVoice) await generateVoice({ text, output: out }); } catch {}
  if (!fs.existsSync(out) || fs.statSync(out).size < 2048) {
    const aiff = out.replace(/\.mp3$/, '.aiff'); execFileSync('say', ['-v', 'Samantha', '-o', aiff, text]);
    execFileSync('ffmpeg', ['-y', '-i', aiff, '-c:a', 'libmp3lame', '-b:a', '192k', out], { stdio: 'ignore' });
  } return out;
}
const dur = m => ffprobeDuration(m);
function sceneHtml({ b64, kicker, title, i, n }) {
  const pips = Array.from({ length: n }, (_, k) => `<span class="pip${k <= i ? ' on' : ''}"></span>`).join('');
  return `<!doctype html><html><head><meta charset=utf-8><style>*{margin:0;box-sizing:border-box}html,body{width:${W}px;height:${H}px;overflow:hidden;background:#0b0e12;font-family:-apple-system,'Inter',system-ui,sans-serif}
   .stage{position:absolute;inset:0}.stage img{width:100%;height:100%;object-fit:cover;object-position:top center}
   .bar{position:absolute;left:0;right:0;bottom:0;padding:26px 30px 20px;background:linear-gradient(0deg,rgba(6,9,12,.95),rgba(6,9,12,.7) 55%,transparent);color:#fff}
   .kick{font-size:12px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;color:${RED}}h2{font-size:26px;font-weight:800;margin-top:5px;max-width:1000px}
   .prog{position:absolute;top:16px;left:30px;right:30px;display:flex;gap:5px}.pip{flex:1;height:3px;border-radius:2px;background:rgba(255,255,255,.22)}.pip.on{background:${RED}}
   .brand{position:absolute;top:26px;right:30px;font-weight:800;font-size:13px;color:#fff;text-shadow:0 1px 3px #000}.brand span{color:${RED}}
   .accent{position:absolute;bottom:0;left:0;height:4px;width:100%;background:${RED}}</style></head><body>
   <div class="stage"><img src="data:image/png;base64,${b64}"></div><div class="prog">${pips}</div>
   <div class="brand">REN<span>TV</span> · Admin</div><div class="bar"><div class="kick">${esc(kicker)}</div><h2>${esc(title)}</h2></div><div class="accent"></div></body></html>`;
}
function cardHtml(big, sub) {
  return `<!doctype html><html><head><meta charset=utf-8><style>*{margin:0;box-sizing:border-box}body{width:${W}px;height:${H}px;background:radial-gradient(circle at 50% 35%,#141a22,#0b0e12);color:#fff;font-family:-apple-system,'Inter',system-ui,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px}.w{font-size:56px;font-weight:900;letter-spacing:-1.5px;text-align:center}.w span{color:${RED}}.acc{width:80px;height:5px;background:${RED};border-radius:3px}.s{font-size:22px;color:#aab2ba;max-width:860px;text-align:center;line-height:1.5}.foot{position:absolute;bottom:34px;font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:#6b747d}</style></head><body><div class="w">${big}</div><div class="acc"></div><div class="s">${esc(sub)}</div><div class="foot">Back-office · Broker &amp; Owner Intelligence</div></body></html>`;
}
function clip(png, mp3, out) {
  const d = dur(mp3), frames = Math.max(1, Math.round((d + 1.4) * 30));
  execFileSync('ffmpeg', ['-y', '-loop', '1', '-i', png, '-i', mp3, '-filter_complex',
    `[0:v]scale=2560:1600,zoompan=z='min(zoom+0.0006,1.05)':d=${frames}:s=${W}x${H}:fps=30[v];[1:a]adelay=400|400,apad=pad_dur=1[a]`,
    '-map', '[v]', '-map', '[a]', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'fast', '-crf', '20', '-c:a', 'aac', '-b:a', '192k', '-t', String(d + 1.4), out], { stdio: 'ignore' });
  return out;
}
(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage({ viewport: { width: W, height: H } });
  const clips = []; let idx = 0;
  const scenes = [
    { kicker: 'Back-office · the directory', title: 'Broker & Owner Intelligence', prep: async () => { await page.goto(ADMIN, { waitUntil: 'networkidle' }); await page.waitForTimeout(1400); },
      narr: "This is the RENTV back office — a broker and owner intelligence desk over the California DRE registry: three hundred eighty-two thousand brokers and thirty-four thousand firms, synced from the real-estate back-end. Every row is live and searchable." },
    { kicker: 'Every field is a link', title: 'Click a broker → find them anywhere', prep: async () => { await page.click('.row:first-child .r'); await page.waitForTimeout(700); },
      narr: "Like the CRCP desk, every field is a link and every row opens inline. Click a broker and you get one-tap actions: find them on LinkedIn, open their firm's website, map their address, jump to the firm's own page, or pull their California DRE license — no leaving the list." },
    { kicker: 'Instant filter', title: 'Search across name, firm, city, license', prep: async () => { await page.click('.row:first-child .r').catch(() => {}); await page.fill('#q', 'realty'); await page.waitForTimeout(700); },
      narr: "Search filters instantly across name, firm, city and license number — type a firm, a city, or a license and the list narrows in real time, then sort by name, city, firm, or agent count." },
    { kicker: 'Firms tab', title: 'Firms ranked by licensed agents', prep: async () => { await page.fill('#q', ''); await page.click('button[data-tab=firms]'); await page.waitForTimeout(700); },
      narr: "Switch to Firms and every brokerage is ranked by licensed-agent count, each linking straight to the company on LinkedIn, its official website, its headquarters on the map, and its corporate DRE record." },
    { kicker: 'Owners & Assets', title: 'Every marquee asset, mappable', prep: async () => { await page.click('button[data-tab=owners]'); await page.waitForTimeout(500); await page.click('.row:first-child .r'); await page.waitForTimeout(700); },
      narr: "And Owners and Assets turns the marquee properties into leads — map the address, drop into Street View, open the LA County Assessor record, or search out the owner and the deal behind it." },
    { kicker: 'One chrome, everywhere', title: 'Same hamburger — all nav + data', prep: async () => { await page.goto(ADMIN, { waitUntil: 'networkidle' }); await page.waitForTimeout(1000); await page.click('#rvBurger'); await page.waitForTimeout(500); },
      narr: "The admin shares the exact same chrome as the front end — one hamburger holding every tab and the live market snapshot, and the user icon reserved only for sign-in." },
  ];
  const total = scenes.length + 2;
  // intro
  { const png = path.join(SLIDES, 'ad-intro.png'); await (async () => { await page.setContent(cardHtml('RENTV Admin', 'A back-office broker and owner intelligence desk — every record clickable out to LinkedIn, the firm, the map, and the DRE.'), { waitUntil: 'networkidle' }); await page.screenshot({ path: png }); })();
    const a = await vo("Here's the RENTV back office — the broker and owner intelligence desk. Let's walk through the clickable lists.", path.join(AUDIO, 'ad-intro.mp3'));
    clips.push(clip(png, a, path.join(SLIDES, 'ad-intro.mp4'))); idx++; }
  for (const s of scenes) {
    await s.prep();
    const shot = path.join(SHOTS, `ad-${idx}.png`); await page.screenshot({ path: shot });
    const b64 = fs.readFileSync(shot).toString('base64');
    const slide = path.join(SLIDES, `ad-${idx}.png`); await page.setContent(sceneHtml({ b64, kicker: s.kicker, title: s.title, i: idx, n: total }), { waitUntil: 'networkidle' }); await page.screenshot({ path: slide });
    const a = await vo(s.narr, path.join(AUDIO, `ad-${idx}.mp3`));
    clips.push(clip(slide, a, path.join(SLIDES, `ad-${idx}.mp4`))); console.log(`scene ${idx} done`); idx++;
  }
  // outro
  { const png = path.join(SLIDES, 'ad-outro.png'); await page.setContent(cardHtml('From list to <span>lead</span>', 'Search the registry, click any broker, firm or asset, and reach LinkedIn, the website, the map and the DRE in one tap.'), { waitUntil: 'networkidle' }); await page.screenshot({ path: png });
    const a = await vo("From a list to a lead in one click — search the registry, open any broker, firm or asset, and reach LinkedIn, the website, the map and the license instantly.", path.join(AUDIO, 'ad-outro.mp3'));
    clips.push(clip(png, a, path.join(SLIDES, 'ad-outro.mp4'))); }
  await browser.close();
  const list = path.join(OUT, 'concat-admin.txt'); fs.writeFileSync(list, clips.map(c => `file '${path.resolve(c)}'`).join('\n'));
  execFileSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', list, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-preset', 'fast', '-crf', '20', '-c:a', 'aac', '-b:a', '192k', FINAL], { stdio: 'ignore' });
  const d = dur(FINAL); console.log(`ADMIN video: ${FINAL}\n  ${d.toFixed(1)}s (${(d / 60).toFixed(2)} min) · ${clips.length} scenes`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });