[object Object]

← back to Rentv 2026

tools: reusable $0 local feature-video generator (Chrome shots + say TTS + ffmpeg) + search explainer spec

80231abd607ef887034a0c3202bf8cb363be8c52 · 2026-07-23 15:47:58 -0700 · Steve

Files touched

Diff

commit 80231abd607ef887034a0c3202bf8cb363be8c52
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 23 15:47:58 2026 -0700

    tools: reusable $0 local feature-video generator (Chrome shots + say TTS + ffmpeg) + search explainer spec
---
 data/markets.json       |   2 +-
 data/news.json          |   2 +-
 data/ticker.json        |   2 +-
 tools/feature-video.mjs | 109 ++++++++++++++++++++++++++++++++++++++++++++++++
 tools/specs/search.json |  28 +++++++++++++
 5 files changed, 140 insertions(+), 3 deletions(-)

diff --git a/data/markets.json b/data/markets.json
index d2ae3c32..070a8c97 100644
--- a/data/markets.json
+++ b/data/markets.json
@@ -1,5 +1,5 @@
 {
-  "fetched_at": "2026-07-23T22:26:02.989Z",
+  "fetched_at": "2026-07-23T22:46:03.919Z",
   "source": "Yahoo Finance — live (CBOE Treasury yields, Dow Jones/MSCI REIT indices)",
   "curve": [
     {
diff --git a/data/news.json b/data/news.json
index 06296b06..8759e254 100644
--- a/data/news.json
+++ b/data/news.json
@@ -1,6 +1,6 @@
 {
  "source": "rentv.com — live",
- "fetched_at": "2026-07-23T22:26:02.605Z",
+ "fetched_at": "2026-07-23T22:46:03.615Z",
  "count": 30,
  "items": [
   {
diff --git a/data/ticker.json b/data/ticker.json
index 576abe63..c224a8a9 100644
--- a/data/ticker.json
+++ b/data/ticker.json
@@ -1,5 +1,5 @@
 {
-  "fetched_at": "2026-07-23T22:26:02.963Z",
+  "fetched_at": "2026-07-23T22:46:03.895Z",
   "la_office_vac": {
     "label": "LA OFFICE VAC",
     "value": "23.1%",
diff --git a/tools/feature-video.mjs b/tools/feature-video.mjs
new file mode 100644
index 00000000..841652fc
--- /dev/null
+++ b/tools/feature-video.mjs
@@ -0,0 +1,109 @@
+#!/usr/bin/env node
+// feature-video.mjs — reusable ~$0 local narrated explainer generator for RENTV.
+// Given a spec (title + scenes of {url, say, caption}), it:
+//   1. boots a temp OPEN=1 preview of the site (client fetch works, no auth wall),
+//   2. screenshots each scene URL with headless Chrome (waits for client render),
+//   3. narrates each scene with macOS `say` (free, local),
+//   4. renders title card + caption strips as HTML→PNG via Chrome (this ffmpeg
+//      build has no drawtext/freetype), composited with ffmpeg `overlay`,
+//   5. assembles a title card + narrated stills into an MP4.
+// Everything is local — no paid API. Usage: node tools/feature-video.mjs specs/<name>.json
+'use strict';
+import { execFileSync, spawn } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+import http from 'http';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..');
+const CHROME = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+const OUTDIR = path.join(os.homedir(), 'Videos', 'rentv');
+const W = 1280, H = 860, CAPH = 104;
+
+const specPath = process.argv[2];
+if (!specPath) { console.error('usage: node tools/feature-video.mjs specs/<name>.json'); process.exit(1); }
+const spec = JSON.parse(fs.readFileSync(path.resolve(ROOT, specPath), 'utf8'));
+const PORT = 9714 + Math.floor(Math.random() * 40);
+const BASE = `http://127.0.0.1:${PORT}`;
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rentv-vid-'));
+const sh = (bin, args) => execFileSync(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
+const dur = (f) => parseFloat(sh('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nk=1:nw=1', f]).toString().trim()) || 2;
+const htmlesc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
+
+function waitUp(cb, tries = 40) {
+  http.get(BASE + '/api/health', (r) => { r.resume(); cb(); }).on('error', () => {
+    if (tries <= 0) { console.error('preview never came up'); process.exit(1); }
+    setTimeout(() => waitUp(cb, tries - 1), 250);
+  });
+}
+
+function shot(url, out, w = W, h = H, budget = 6000) {
+  sh(CHROME, ['--headless=new', '--disable-gpu', '--hide-scrollbars', '--no-sandbox',
+    '--force-device-scale-factor=1', `--window-size=${w},${h}`, `--virtual-time-budget=${budget}`,
+    `--screenshot=${out}`, url]);
+}
+
+function renderHTML(html, out, w, h) {
+  const f = `${tmp}/pg-${Math.random().toString(36).slice(2)}.html`;
+  fs.writeFileSync(f, html);
+  shot('file://' + f, out, w, h, 1200);
+}
+
+const FONT = "-apple-system,'Helvetica Neue',Arial,sans-serif";
+
+function titleCard(out) {
+  const png = `${tmp}/title.png`;
+  renderHTML(`<html><body style="margin:0;width:${W}px;height:${H}px;background:#0b0e12;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:${FONT};color:#fff">
+    <div style="color:#c8102e;font-weight:800;letter-spacing:.22em;font-size:24px;margin-bottom:22px">REN<span style="color:#fff">TV</span></div>
+    <div style="font-family:Georgia,serif;font-weight:700;font-size:68px;letter-spacing:-.01em;text-align:center;padding:0 60px">${htmlesc(spec.title)}</div>
+    <div style="color:#a8b0b8;font-size:29px;margin-top:20px;text-align:center;padding:0 80px">${htmlesc(spec.subtitle || '')}</div>
+    <div style="position:absolute;bottom:40px;width:6px;height:6px;background:#c8102e;border-radius:50%"></div>
+  </body></html>`, png, W, H);
+  sh('ffmpeg', ['-y', '-loop', '1', '-i', png, '-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=stereo',
+    '-t', '2.4', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-r', '30', '-c:a', 'aac', '-b:a', '160k', '-ar', '44100', '-ac', '2', out]);
+}
+
+function scene(sc, i, out) {
+  const png = `${tmp}/s${i}.png`, aiff = `${tmp}/s${i}.aiff`, cap = `${tmp}/cap${i}.png`;
+  shot(BASE + sc.url, png);
+  execFileSync('say', ['-v', spec.voice || 'Samantha', '-o', aiff, sc.say]);
+  const d = (dur(aiff) + 0.7).toFixed(2);
+  renderHTML(`<html><body style="margin:0;width:${W}px;height:${CAPH}px;background:rgba(11,14,18,.9);display:flex;align-items:center;font-family:${FONT}">
+    <div style="color:#c8102e;font-weight:800;letter-spacing:.16em;font-size:15px;padding:0 22px 0 40px;white-space:nowrap">RENTV</div>
+    <div style="color:#fff;font-size:29px;font-weight:600">${htmlesc(sc.caption || sc.say)}</div>
+  </body></html>`, cap, W, CAPH);
+  sh('ffmpeg', ['-y', '-loop', '1', '-i', png, '-loop', '1', '-i', cap, '-i', aiff,
+    '-filter_complex', `[0:v][1:v]overlay=0:${H - CAPH}[v];[2:a]apad,atrim=0:${d}[a]`,
+    '-map', '[v]', '-map', '[a]', '-t', d,
+    '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-r', '30', '-c:a', 'aac', '-b:a', '160k', '-ar', '44100', '-ac', '2', out]);
+}
+
+// ── run ──
+fs.mkdirSync(OUTDIR, { recursive: true });
+const srv = spawn('node', ['server.js'], { cwd: ROOT, env: { ...process.env, OPEN: '1', PORT: String(PORT) }, stdio: 'ignore' });
+process.on('exit', () => { try { srv.kill(); } catch {} });
+
+waitUp(() => {
+  try {
+    const clips = [];
+    const tc = `${tmp}/00-title.mp4`; titleCard(tc); clips.push(tc);
+    spec.scenes.forEach((sc, i) => { const o = `${tmp}/${String(i + 1).padStart(2, '0')}.mp4`; scene(sc, i, o); clips.push(o); console.error(`  scene ${i + 1}/${spec.scenes.length} ✓`); });
+    const list = `${tmp}/list.txt`;
+    fs.writeFileSync(list, clips.map((c) => `file '${c}'`).join('\n'));
+    const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+    const outFile = path.join(OUTDIR, `${spec.slug}-${stamp}.mp4`);
+    // Re-encode (not -c copy): stream-copy concat truncates per-segment AAC audio,
+    // silencing later scenes. Re-encoding normalizes timestamps and keeps all audio.
+    sh('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', list,
+      '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-r', '30', '-c:a', 'aac', '-b:a', '160k', '-ar', '44100', '-ac', '2', outFile]);
+    console.log(JSON.stringify({ ok: true, out: outFile, seconds: +dur(outFile).toFixed(1), scenes: spec.scenes.length }));
+  } catch (e) {
+    console.error('FAILED:', e.message, e.stderr ? e.stderr.toString().slice(-700) : '');
+    process.exitCode = 1;
+  } finally {
+    try { srv.kill(); } catch {}
+    try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
+  }
+});
diff --git a/tools/specs/search.json b/tools/specs/search.json
new file mode 100644
index 00000000..1b35a411
--- /dev/null
+++ b/tools/specs/search.json
@@ -0,0 +1,28 @@
+{
+  "slug": "search",
+  "title": "Global Search",
+  "subtitle": "One box across every deal, story & market",
+  "voice": "Samantha",
+  "scenes": [
+    {
+      "url": "/search",
+      "say": "RENTV now has one universal search box. Open it from the menu on any page.",
+      "caption": "One search box — reachable from every page"
+    },
+    {
+      "url": "/search?q=office",
+      "say": "Type a term like office, and results stream in instantly, grouped by news, deals, videos and firms.",
+      "caption": "Type 'office' — results grouped by section, instantly"
+    },
+    {
+      "url": "/search?q=los%20angeles",
+      "say": "Search a market like Los Angeles, and it surfaces the matching L-A commercial firms right alongside the news and deals.",
+      "caption": "Search a market — firms surface next to news & deals"
+    },
+    {
+      "url": "/search?q=multifamily",
+      "say": "Every result links straight into RENTV's own pages. No jumping off-site. It's the fastest way to pull up anything on the platform.",
+      "caption": "Every result stays on RENTV — no off-site jumps"
+    }
+  ]
+}

← 4a4b2d18 search: global site search across news, deals, videos, REvie  ·  back to Rentv 2026  ·  auto-save: 2026-07-23T15:52:21 (4 files) — public/index.html f8414c0d →