← back to Rentv 2026

mockups/video/build-frontend-video.js

53 lines

#!/usr/bin/env node
/* FRONT-END-ONLY walkthrough — reuses the 12 already-rendered app-scene clips
   (s1..s12) so no VO is re-billed; only a front-focused intro + outro are new. */
const path = require('path'), fs = require('fs');
const { execFileSync } = require('child_process');
const SK = path.join(process.env.HOME, '.claude/skills/app-demo-video');
let generateVoice, ffprobeDuration;
try { ({ generateVoice } = require(path.join(SK, 'scripts/tts.js'))); } catch {}
try { ({ ffprobeDuration } = require(path.join(SK, 'scripts/merge.js'))); } catch {}
const { chromium } = require(path.join(SK, 'node_modules/playwright'));
const OUT = path.resolve(__dirname, 'out'), SLIDES = path.join(OUT, 'slides'), AUDIO = path.join(OUT, 'audio');
const FINAL = path.join(OUT, 'rentv-frontend-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, 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(mp3);
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:70px;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:840px;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">Front-End Walkthrough · RENTV.com</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=1280:800,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 shot = async (html, png) => { await page.setContent(html, { waitUntil: 'networkidle' }); await page.screenshot({ path: png }); };
  const clips = [];
  // front intro
  const ip = path.join(SLIDES, 'fe-intro.png'); await shot(cardHtml('REN<span>TV</span>', 'A walkthrough of every front-end component — ten distinct layouts, one shared chrome, all on live data.'), ip);
  const ia = await vo("Here's a tour of the RENTV front end — ten distinct layout concepts, one shared navigation chrome, every element numbered, all rendering the live market.", path.join(AUDIO, 'fe-intro.mp3'));
  clips.push(clip(ip, ia, path.join(SLIDES, 'fe-intro.mp4')));
  // reuse the 12 app-scene clips already rendered
  for (let i = 1; i <= 12; i++) { const c = path.join(SLIDES, `s${i}.mp4`); if (fs.existsSync(c)) clips.push(c); }
  // front outro
  const op = path.join(SLIDES, 'fe-outro.png'); await shot(cardHtml('Pick your <span>10</span>', 'Turn on numbers, click the pieces you want, and the final RENTV front page is assembled from your picks.'), op);
  const oa = await vo("That's the front end. Turn on numbers, pick the pieces you like across the ten, and the final page is built from your choices.", path.join(AUDIO, 'fe-outro.mp3'));
  clips.push(clip(op, oa, path.join(SLIDES, 'fe-outro.mp4')));
  await browser.close();
  const list = path.join(OUT, 'concat-fe.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(`FRONT-END video: ${FINAL}\n  ${d.toFixed(1)}s (${(d / 60).toFixed(2)} min) · ${clips.length} scenes`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });