← back to Rentv
mockups/video/build-rentv-video.js
184 lines
#!/usr/bin/env node
/* RENTV build walkthrough — narrated, screen-synced MP4.
Captures the REAL redesigned front-end (10 numbered layouts, hamburger chrome,
pick-tray) + the REAL backend API responses, and narrates each component.
Each scene's on-screen time == its voiceover length → voice stays synced to screen.
VO: skill tts chain (clone-voice → edge-tts free-neural → espeak), macOS `say` safety net.
$0 (local) — no paid API. 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 GALLERY = 'http://127.0.0.1:9719';
const OUT = path.resolve(__dirname, 'out');
const 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-build-walkthrough.mp4');
const RED = '#c8102e', W = 1280, H = 800;
const data = f => JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', f), 'utf8'));
const esc = s => String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
// ── voiceover: reuse the skill chain, fall back to macOS `say` if it yields nothing ──
async function vo(text, outMp3) {
try { if (generateVoice) { await generateVoice({ text, output: outMp3 }); } } catch {}
if (!fs.existsSync(outMp3) || fs.statSync(outMp3).size < 2048) {
const aiff = outMp3.replace(/\.mp3$/, '.aiff');
execFileSync('say', ['-v', 'Samantha', '-o', aiff, text]);
execFileSync('ffmpeg', ['-y', '-i', aiff, '-c:a', 'libmp3lame', '-b:a', '192k', outMp3], { stdio: 'ignore' });
}
return outMp3;
}
const dur = mp3 => (ffprobeDuration ? ffprobeDuration(mp3)
: parseFloat(execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', mp3], { encoding: 'utf8' }).trim()));
// ── scene slide: full-bleed screen + caption bar + progress ──
function sceneHtml({ shotB64, 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,.94),rgba(6,9,12,.7) 55%,transparent);color:#fff}
.kick{font-size:12px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;color:${RED}}
.kick b{color:#fff}
h2{font-size:27px;font-weight:800;margin-top:5px;letter-spacing:-.4px;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:16px;right:30px;font-weight:800;font-size:13px;letter-spacing:.02em;color:#fff;text-shadow:0 1px 3px #000;top:26px}
.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,${shotB64}"></div>
<div class="prog">${pips}</div>
<div class="brand">REN<span>TV</span></div>
<div class="bar"><div class="kick"><b>${esc(kicker)}</b></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:72px;font-weight:900;letter-spacing:-2px}.w span{color:${RED}}
.acc{width:80px;height:5px;background:${RED};border-radius:3px}
.s{font-size:22px;color:#aab2ba;max-width:820px;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">Commercial Real Estate News · The Regional Authority</div></body></html>`;
}
// backend "API console" slide from real snapshot JSON
function apiConsoleHtml({ method, route, note, json }) {
const pretty = esc(JSON.stringify(json, null, 2)).split('\n').slice(0, 34).join('\n');
return `<!doctype html><html><head><meta charset=utf-8><style>
*{margin:0;box-sizing:border-box}body{width:${W}px;height:${H}px;background:#0b0e12;color:#e7ecf1;font-family:'Inter',system-ui,sans-serif;padding:0}
.top{padding:22px 30px 14px}.top .k{font-size:12px;font-weight:800;letter-spacing:.16em;text-transform:uppercase;color:${RED}}
.top h2{font-size:26px;font-weight:800;margin-top:6px}
.req{margin:0 30px;background:#11161c;border:1px solid #232a32;border-radius:10px 10px 0 0;padding:11px 16px;font:700 15px ui-monospace,Menlo,monospace}
.req .m{color:#37d67a}.req .r{color:#fff}.req .a{color:#6b747d;float:right;font-weight:500;font-size:13px}
pre{margin:0 30px;background:#0e131a;border:1px solid #232a32;border-top:0;border-radius:0 0 10px 10px;padding:16px;font:13px/1.5 ui-monospace,Menlo,monospace;color:#b9c4cf;height:520px;overflow:hidden;white-space:pre-wrap}
.note{padding:12px 30px;color:#8b939b;font-size:14px}
.brand{position:absolute;top:22px;right:30px;font-weight:800;color:#fff}.brand span{color:${RED}}
.accent{position:absolute;bottom:0;left:0;height:4px;width:100%;background:${RED}}
</style></head><body>
<div class="brand">REN<span>TV</span></div>
<div class="top"><div class="k">Back-end · Express API · pm2 :9704</div><h2>${esc(route)}</h2></div>
<div class="req"><span class="m">${method}</span> <span class="r">${esc(route)}</span><span class="a">200 · live</span></div>
<pre>${pretty}</pre>
<div class="note">${esc(note)}</div><div class="accent"></div>
</body></html>`;
}
async function shot(page, html, png) { await page.setContent(html, { waitUntil: 'networkidle' }); await page.screenshot({ path: png }); }
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;
// scene defs: front app captures + backend consoles + cards. TOTAL for progress:
const appScenes = [
['L02', null, 'The front end · 10 concepts', 'Editorial Magazine', "We rebuilt the RENTV front page as ten genuinely distinct layouts — different structures, not recolors. Every one renders the real live wire, the real LA County assessments, and the real brokerage data."],
['L02', 'ham', 'Global chrome · every screen', 'One hamburger, upper-left', "The rule holds across all ten. A single modern hamburger in the upper-left carries every navigation tab — Sales, Leases, Financing, Development, Markets, LA Commercial, The REview and more — plus a live snapshot of the market: the CRE index, the ten-year, LA office vacancy, and the top asset."],
['L01', 'picks', 'Pick & choose · numbered', 'Number every element', "Every element is numbered, like L-oh-one dot oh-seven. Turn on numbers, click a badge, and it drops into a Picks tray. Mix the masthead from one layout, the ticker from another, the map from a third — then hand back the list, and the final page is assembled from your picks."],
['L01', null, 'Layout 1 · Trading Desk', 'Trading Desk', "Layout one is a Bloomberg-style trading desk: a live metric rail, a news blotter tape, an LA watchlist and a brokerage league table — maximum density for the deal-watcher."],
['L03', null, 'Layout 3 · Brutalist Grid', 'Brutalist Grid', "Layout three goes brutalist — hard rules, oversized type, and a rigid cell grid where the lead story and the nine-hundred-ninety-five-million-dollar Beverly Center block dominate."],
['L04', null, 'Layout 4 · Card Masonry', 'Card Masonry', "Layout four is the most visual: an image-forward masonry with section filter chips, where stories, assets and firms all become browsable cards."],
['L05', null, 'Layout 5 · Split-Screen', 'Split-Screen', "Layout five splits the screen — the live newsroom scrolls on the left while a dark LA-intelligence panel stays pinned on the right."],
['L06', null, 'Layout 6 · Broadcast', 'Broadcast', "Layout six is broadcast news: a scrolling headline tape, an on-air video hero from The REview, and section columns underneath."],
['L07', null, 'Layout 7 · Map-Anchored', 'Map-Anchored', "Layout seven anchors on an LA map. Each marquee asset is a pin; clicking one drives a live detail panel with the assessment, the square footage and the year built."],
['L08', null, 'Layout 8 · Timeline', 'Timeline Feed', "Layout eight is a single vertical timeline, interleaving live headlines, marquee-asset callouts and recorded property transfers into one running spine of market events."],
['L09', null, 'Layout 9 · Broadsheet', 'The RENTV Daily', "Layout nine is a full newspaper front page — flag, dateline, a two-column lead, a news river, a Property-of-the-Day box and brokerage standings."],
['L10', null, 'Layout 10 · Swiss Index', 'Swiss Index', "And layout ten is pure restraint — a numbered index of every story, the LA market ranked by value, and tabular market data. The market, indexed."],
];
const apiScenes = [
['news', 'GET', '/api/news', "The live rentv-dot-com wire, refreshed on a schedule. Thirty stories, each tagged by sector, feeding every layout's headlines.", data('news.json')],
['la', 'GET', '/api/la-commercial', "LA County Assessor parcels, AI-written news briefs, and California DRE brokers — synced in from the usrealestate back-end into the marquee, the recorded-activity table and the league tables.", data('la-commercial.json')],
['ticker', 'GET', '/api/ticker', "The market ticker: the CRE index, the ten-year treasury, LA office vacancy and Inland-Empire industrial cap rates — the data points that also live inside the hamburger.", data('ticker.json')],
['videos', 'GET', '/api/videos', "The REview video catalog, with RENTV Originals merged in on read — so the video sync can never wipe the house-produced promos.", data('videos.json')],
];
const total = 1 /*intro*/ + appScenes.length + apiScenes.length + 1 /*arch*/ + 1 /*outro*/;
// ── intro ──
{ const png = path.join(SLIDES, 's00.png'); await shot(page, cardHtml({ big: 'REN<span>TV</span>', sub: 'A ground-up redesign of the commercial-real-estate news platform — front end and back end.' }), png);
const a = await vo("This is the RENTV rebuild — a commercial real estate news platform, front end and back end. Let's walk through every component.", path.join(AUDIO, 's00.mp3'));
clips.push(clip(png, a, path.join(SLIDES, 's00.mp4'))); idx++; console.log('intro done'); }
// ── app scenes ──
for (const [L, mode, kicker, title, narr] of appScenes) {
await page.goto(`${GALLERY}/${L}.html`, { waitUntil: 'networkidle' });
await page.waitForTimeout(1200); // let live data render
if (mode === 'ham') { await page.click('#rvBurger').catch(() => {}); await page.waitForTimeout(500); }
if (mode === 'picks') { await page.click('.t-pick').catch(() => {}); await page.waitForTimeout(400); }
const appPng = path.join(SHOTS, `${L}-${mode || 'x'}.png`);
await page.screenshot({ path: appPng });
const b64 = fs.readFileSync(appPng).toString('base64');
const slidePng = path.join(SLIDES, `s${String(idx).padStart(2, '0')}.png`);
await shot(page, sceneHtml({ shotB64: b64, kicker, title, i: idx, n: total }), slidePng);
const a = await vo(narr, path.join(AUDIO, `s${idx}.mp3`));
clips.push(clip(slidePng, a, path.join(SLIDES, `s${idx}.mp4`))); console.log(`${L}/${mode || 'full'} done`); idx++;
}
// ── backend scenes ──
for (const [id, method, route, note, json] of apiScenes) {
const png = path.join(SLIDES, `api-${id}.png`);
await shot(page, apiConsoleHtml({ method, route, note, json }), png);
const a = await vo(note, path.join(AUDIO, `api-${id}.mp3`));
clips.push(clip(png, a, path.join(SLIDES, `api-${id}.mp4`))); console.log(`api ${route} done`); idx++;
}
// ── architecture / cutover card ──
{ const png = path.join(SLIDES, 'arch.png');
await shot(page, cardHtml({ big: 'One canonical site', sub: 'The divergent forks were consolidated: the richer build now serves the canonical domain, with the consulting portal untouched — every future feature lands once.' }), png);
const a = await vo("Behind it all, the divergent forks were consolidated. The richer build now serves the canonical domain, the consulting portal stays untouched, and every future feature lands in one place instead of two.", path.join(AUDIO, 'arch.mp3'));
clips.push(clip(png, a, path.join(SLIDES, 'arch.mp4'))); idx++; console.log('arch done'); }
// ── outro ──
{ const png = path.join(SLIDES, 'outro.png');
await shot(page, cardHtml({ big: 'Pick your <span>10</span>', sub: 'Ten front-end concepts, a live back end, and a numbered pick-and-choose workflow — ready to assemble into the final RENTV.' }), png);
const a = await vo("That's the full platform — ten front-end concepts over a live back end, every element numbered and pickable. Choose the pieces you want, and the final RENTV comes together from your picks.", path.join(AUDIO, 'outro.mp3'));
clips.push(clip(png, a, path.join(SLIDES, 'outro.mp4'))); console.log('outro done'); }
await browser.close();
// ── concat ──
const list = path.join(OUT, 'concat.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), mb = (fs.statSync(FINAL).size / 1048576).toFixed(1);
console.log(`\n=== DONE ===\n ${FINAL}\n ${d.toFixed(1)}s (${(d / 60).toFixed(2)} min) · ${mb} MB · ${clips.length} scenes`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });