← 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 => ({ '&': '&', '<': '<', '>': '>' }[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 & 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); });