← back to Rentv
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
M data/markets.jsonM data/news.jsonM data/ticker.jsonA tools/feature-video.mjsA tools/specs/search.json
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) => ({ '&': '&', '<': '<', '>': '>' }[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
·
auto-save: 2026-07-23T15:52:21 (4 files) — public/index.html f8414c0d →