← back to Rentv
feat(social): article→video studio — Boomer-voiced 5/15/30s teasers from live rentv.com headlines (v0.13.0)
8336bfb815a00874989fd8aa639c1479d149b6e1 · 2026-07-31 11:50:57 -0700 · Steve
Files touched
M .gitignoreM package.jsonA public/desk-assets/boomer-headshot.jpgA public/desk-assets/boomer-hero.jpgM public/social.htmlA scripts/gen-social-frame.pyA scripts/social-video-gen.mjsM server.js
Diff
commit 8336bfb815a00874989fd8aa639c1479d149b6e1
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 31 11:50:57 2026 -0700
feat(social): article→video studio — Boomer-voiced 5/15/30s teasers from live rentv.com headlines (v0.13.0)
---
.gitignore | 4 ++
package.json | 20 +++++-
public/desk-assets/boomer-headshot.jpg | Bin 0 -> 26633 bytes
public/desk-assets/boomer-hero.jpg | Bin 0 -> 459994 bytes
public/social.html | 127 +++++++++++++++++++++++++++++++++
scripts/gen-social-frame.py | 119 ++++++++++++++++++++++++++++++
scripts/social-video-gen.mjs | 112 +++++++++++++++++++++++++++++
server.js | 39 ++++++++++
8 files changed, 420 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index d0e588a2..60ea807b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,7 @@ ACCESS-USERS.md
# admin video library media — served + deployed, not versioned (too large)
public/press/media/**/*.mp4
public/press/media/*.mp4
+
+# article-to-video studio artifacts (regenerable)
+data/social-jobs/
+public/social-videos/
diff --git a/package.json b/package.json
index 4f1e3f27..4f9d367f 100644
--- a/package.json
+++ b/package.json
@@ -1 +1,19 @@
-{"name":"rentv","version":"0.12.0","private":true,"main":"server.js","scripts":{"start":"node server.js","pull":"node scripts/pull-news.mjs","pr:migrate":"node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"","pr:worker":"node src/pr/worker.js","pr:seed:ca":"node src/pr/seed/ca-seed.js","test":"node --test test/pr/*.test.js","pr:seed:az":"node src/pr/seed/az-seed.js"},"dependencies":{"express":"^4.21.2","pg":"^8.22.0"}}
\ No newline at end of file
+{
+ "name": "rentv",
+ "version": "0.13.0",
+ "private": true,
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "pull": "node scripts/pull-news.mjs",
+ "pr:migrate": "node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"",
+ "pr:worker": "node src/pr/worker.js",
+ "pr:seed:ca": "node src/pr/seed/ca-seed.js",
+ "test": "node --test test/pr/*.test.js",
+ "pr:seed:az": "node src/pr/seed/az-seed.js"
+ },
+ "dependencies": {
+ "express": "^4.21.2",
+ "pg": "^8.22.0"
+ }
+}
diff --git a/public/desk-assets/boomer-headshot.jpg b/public/desk-assets/boomer-headshot.jpg
new file mode 100644
index 00000000..cc4c6c7c
Binary files /dev/null and b/public/desk-assets/boomer-headshot.jpg differ
diff --git a/public/desk-assets/boomer-hero.jpg b/public/desk-assets/boomer-hero.jpg
new file mode 100644
index 00000000..a9856199
Binary files /dev/null and b/public/desk-assets/boomer-hero.jpg differ
diff --git a/public/social.html b/public/social.html
index 8ceef789..dd284340 100644
--- a/public/social.html
+++ b/public/social.html
@@ -121,6 +121,27 @@
.gfacet .fbtns button { padding:6px 9px; font-size:11.5px; }
.gfacet .fbtns .ghost { background:#eef2f7; color:var(--ink); }
@media (max-width:900px){ .grail.open { position:fixed; top:0; bottom:0; height:100vh; width:86vw; max-width:340px; } .grail.open .grail-body { max-height:100vh; } }
+ /* ── Video Studio rail (distinct red tab; article picker → Boomer-voiced teaser) ── */
+ #vgrail.open { width:360px; }
+ #vgrail .grail-toggle { background:#fbeef0; color:var(--red,#c8102e); border-right-color:var(--red,#c8102e); }
+ .art { border:1px solid var(--line); border-radius:11px; padding:10px 11px; margin-bottom:8px; }
+ .art:hover { border-color:#c9d6e4; }
+ .art .cat { display:inline-block; font-size:10px; font-weight:800; letter-spacing:.04em; text-transform:uppercase; color:#fff; background:var(--red,#c8102e); padding:2px 8px; border-radius:20px; }
+ .art h5 { margin:6px 0 8px; font-size:13px; line-height:1.32; color:var(--ink); font-weight:700; }
+ .dbtns { display:flex; gap:6px; }
+ .dbtns button { flex:1; padding:7px 0; font-size:12px; font-weight:700; background:#0d1b2a; color:#fff; border:0; border-radius:7px; cursor:pointer; }
+ .dbtns button:hover { background:var(--red,#c8102e); }
+ .dbtns button:disabled { opacity:.5; cursor:default; }
+ .vjob { border:1px solid #d7e2ee; background:#f7fafd; border-radius:11px; padding:11px 12px; margin-bottom:10px; }
+ .vjob .st { font-size:12px; font-weight:700; color:var(--li); display:flex; align-items:center; gap:7px; }
+ .vjob .st .spin { width:12px; height:12px; border:2px solid #cfe0ef; border-top-color:var(--li); border-radius:50%; animation:vspin .7s linear infinite; }
+ @keyframes vspin { to { transform:rotate(360deg); } }
+ .vjob .when { font-size:10.5px; color:var(--muted); margin-top:3px; }
+ .vjob video { width:100%; border-radius:9px; margin-top:9px; background:#000; }
+ .vjob .vact { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
+ .vjob .vact button, .vjob .vact a { font-size:11.5px; padding:6px 9px; border-radius:7px; text-decoration:none; text-align:center; }
+ .vjob .vact a { background:#eef2f7; color:var(--ink); }
+ .vjob .err { color:var(--red,#c8102e); font-size:11.5px; }
</style>
</head>
<body>
@@ -166,6 +187,19 @@
<div id="grailMsg" class="grail-msgline"></div>
</div>
</aside>
+<aside class="grail" id="vgrail">
+ <button class="grail-toggle" id="vgToggle" title="Video Studio"><span class="grail-vert">🎬 Video Studio</span></button>
+ <div class="grail-body">
+ <div class="grail-top"><strong>🎬 Article → Video Studio</strong><button class="grail-close" id="vgClose" title="Collapse">×</button></div>
+ <p class="grail-note">Pick any live rentv.com article → generate a <b>5 / 15 / 30-second</b> vertical social teaser narrated in <b>Steve Bloom's voice</b> over his photo. Renders locally — <b>draft only, nothing posts</b>.</p>
+ <div class="grail-actions">
+ <button class="ghost" id="vgRefresh">↻ Refresh articles</button>
+ <span id="vgCount" class="grail-meta" style="margin:0"></span>
+ </div>
+ <div id="vgRenders"></div>
+ <div id="vgList"><p class="grail-note">Loading articles…</p></div>
+ </div>
+</aside>
<main>
<div class="col">
<div class="panel">
@@ -582,6 +616,99 @@
gsetOpen(localStorage.getItem(GOPEN)==='1'); // collapsed by default
grender();
+
+ /* ══ Video Studio: rentv.com article → Boomer-voiced 5/15/30s teaser ══ */
+ (function(){
+ var VGOPEN='rentv.videoStudio.open';
+ var vg=document.getElementById('vgrail');
+ function vgSet(o){ vg.classList.toggle('open',!!o); localStorage.setItem(VGOPEN,o?'1':'0'); }
+ document.getElementById('vgToggle').onclick=function(){ vgSet(true); loadArticles(); };
+ document.getElementById('vgClose').onclick=function(){ vgSet(false); };
+ document.getElementById('vgRefresh').onclick=loadArticles;
+ function esc(s){ var d=document.createElement('div'); d.textContent=s==null?'':String(s); return d.innerHTML; }
+ function fmtDT(iso){ try{ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return iso||''; } }
+
+ var articles=[];
+ function loadArticles(){
+ var list=document.getElementById('vgList');
+ fetch('/api/news',{credentials:'same-origin'}).then(function(r){return r.json();}).then(function(d){
+ articles=(d.items||[]).filter(function(a){return a && a.title;});
+ document.getElementById('vgCount').textContent=articles.length+' live articles';
+ if(!articles.length){ list.innerHTML='<p class="grail-note">No articles in the feed yet.</p>'; return; }
+ list.innerHTML=articles.map(function(a,i){
+ return '<div class="art"><span class="cat">'+esc(a.cat||'CRE')+'</span>'+
+ '<h5>'+esc(a.title)+'</h5>'+
+ '<div class="dbtns" data-i="'+i+'">'+
+ '<button data-d="5">▶ 5s</button><button data-d="15">▶ 15s</button><button data-d="30">▶ 30s</button>'+
+ '</div></div>';
+ }).join('');
+ }).catch(function(){ list.innerHTML='<p class="grail-note err" style="color:#c8102e">Could not load articles.</p>'; });
+ }
+
+ // event delegation for the duration buttons
+ document.getElementById('vgList').addEventListener('click',function(ev){
+ var b=ev.target.closest('.dbtns button'); if(!b) return;
+ var wrap=b.closest('.dbtns'); var art=articles[+wrap.dataset.i]; var dur=+b.dataset.d;
+ if(!art) return;
+ Array.prototype.forEach.call(wrap.querySelectorAll('button'),function(x){x.disabled=true;});
+ startJob(art,dur,function(){ Array.prototype.forEach.call(wrap.querySelectorAll('button'),function(x){x.disabled=false;}); });
+ });
+
+ function startJob(art,dur,doneCb){
+ var renders=document.getElementById('vgRenders');
+ var card=document.createElement('div'); card.className='vjob';
+ card.innerHTML='<div class="st"><span class="spin"></span><span class="stt">Starting '+dur+'s render…</span></div>'+
+ '<div class="when">'+esc(art.title).slice(0,80)+'</div>';
+ renders.insertBefore(card,renders.firstChild);
+ var stt=card.querySelector('.stt');
+ fetch('/api/social/video',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({articleId:art.id,duration:dur})}).then(function(r){return r.json();}).then(function(res){
+ if(!res.ok){ fail(card,res.error||'start failed'); doneCb(); return; }
+ poll(res.jobId,card,art,doneCb);
+ }).catch(function(){ fail(card,'network error'); doneCb(); });
+ }
+
+ var LABEL={queued:'Queued…',scripting:'Writing script…',voicing:'Cloning Boomer’s voice…',framing:'Building the frame…',rendering:'Rendering video…'};
+ function poll(jobId,card,art,doneCb){
+ var stt=card.querySelector('.stt'); var tries=0;
+ (function tick(){
+ tries++;
+ fetch('/api/social/video/'+jobId,{credentials:'same-origin'}).then(function(r){return r.json();}).then(function(j){
+ if(j.status==='done'){ finish(card,j,art); doneCb(); return; }
+ if(j.status==='error'){ fail(card,j.error||'render error'); doneCb(); return; }
+ if(stt) stt.textContent=(LABEL[j.status]||j.status);
+ if(tries>60){ fail(card,'timed out'); doneCb(); return; }
+ setTimeout(tick,1000);
+ }).catch(function(){ if(tries>60){ fail(card,'lost job'); doneCb(); } else setTimeout(tick,1500); });
+ })();
+ }
+
+ function fail(card,msg){ card.innerHTML='<div class="err">⚠ '+esc(msg)+'</div>'; }
+
+ function finish(card,j,art){
+ var secs=j.final_seconds||j.duration;
+ card.innerHTML=
+ '<div class="st" style="color:#1a7f37"><span>✓ '+secs+'s teaser ready</span></div>'+
+ '<div class="when">🕓 '+fmtDT(j.created_at)+' · '+esc(art.cat||'CRE')+'</div>'+
+ '<video controls preload="metadata" playsinline src="'+j.videoUrl+'"></video>'+
+ '<div class="vact">'+
+ '<a href="'+j.videoUrl+'" download>⤓ Download</a>'+
+ '<button class="ghost draft">→ Draft social post</button>'+
+ '</div>';
+ card.querySelector('.draft').onclick=function(){ draftPost(art,j); };
+ }
+
+ function draftPost(art,j){
+ var cap='🎥 '+art.title+'\n\nMore Western-U.S. commercial real estate deals at rentv.com — now in our 25th year, home of The Real Estate Insider, CRE Talk and the State of the Market conferences.\n\n#CRE #CommercialRealEstate #RENTV';
+ document.getElementById('pf').value='LinkedIn';
+ document.getElementById('topic').value='RENTV video · '+String(art.title).slice(0,80);
+ document.getElementById('text').value=cap;
+ document.getElementById('text').scrollIntoView({behavior:'smooth',block:'center'});
+ }
+
+ vgSet(localStorage.getItem(VGOPEN)==='1');
+ loadArticles();
+ })();
</script>
</body>
</html>
diff --git a/scripts/gen-social-frame.py b/scripts/gen-social-frame.py
new file mode 100644
index 00000000..5706e5dd
--- /dev/null
+++ b/scripts/gen-social-frame.py
@@ -0,0 +1,119 @@
+#!/usr/bin/env python3
+"""Render a branded 1080x1920 RENTV social-video still.
+
+Typography + branding live here (PIL) so ffmpeg only does motion + audio.
+Usage: gen-social-frame.py --headline "..." --category "Industrial" \
+ --hero path.jpg --avatar path.jpg --out frame.png [--byline "Steve Bloom, RENTV"]
+
+Grounded — renders only the text passed in; invents nothing.
+"""
+import argparse, os, textwrap
+from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageOps
+
+W, H = 1080, 1920
+NAVY = (11, 37, 69)
+RED = (200, 16, 46)
+WHITE = (255, 255, 255)
+FONTDIR = "/System/Library/Fonts/Supplemental"
+
+
+def font(name, size):
+ return ImageFont.truetype(os.path.join(FONTDIR, name), size)
+
+
+def cover(img, w, h):
+ """Scale+crop img to exactly wxh (CSS object-fit: cover)."""
+ return ImageOps.fit(img.convert("RGB"), (w, h), Image.LANCZOS)
+
+
+def circle_avatar(path, d):
+ a = cover(Image.open(path), d, d)
+ mask = Image.new("L", (d, d), 0)
+ ImageDraw.Draw(mask).ellipse((0, 0, d, d), fill=255)
+ out = Image.new("RGBA", (d, d), (0, 0, 0, 0))
+ out.paste(a, (0, 0), mask)
+ return out
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--headline", required=True)
+ ap.add_argument("--category", default="")
+ ap.add_argument("--hero", required=True)
+ ap.add_argument("--avatar", default="")
+ ap.add_argument("--byline", default="Steve Bloom · RENTV")
+ ap.add_argument("--footer", default="rentv.com · Western U.S. Commercial Real Estate")
+ ap.add_argument("--out", required=True)
+ a = ap.parse_args()
+
+ # ---- background: hero photo, cover-fit, darkened with a bottom-weighted scrim ----
+ base = cover(Image.open(a.hero), W, H)
+ scrim = Image.new("L", (1, H), 0)
+ for y in range(H):
+ t = y / H
+ # top ~30% and bottom ~45% dark for legibility; middle lets Boomer show
+ top = max(0, (0.32 - t) / 0.32) * 150
+ bot = max(0, (t - 0.55) / 0.45) * 225
+ scrim.putpixel((0, y), int(min(235, top + bot)))
+ scrim = scrim.resize((W, H))
+ dark = Image.new("RGB", (W, H), (4, 12, 24))
+ base = Image.composite(dark, base, scrim)
+ img = base.convert("RGBA")
+ d = ImageDraw.Draw(img)
+
+ # ---- top wordmark ----
+ wm = font("Arial Black.ttf", 78)
+ d.text((60, 70), "RENTV", font=wm, fill=WHITE)
+ tag = font("Arial.ttf", 30)
+ wmw = d.textlength("RENTV", font=wm)
+ d.text((60 + wmw + 18, 118), "the CRE authority", font=tag, fill=(210, 220, 235))
+ # accent underbar
+ d.rectangle((60, 165, 60 + wmw, 173), fill=RED)
+
+ # ---- category chip ----
+ y = 1000
+ if a.category:
+ cf = font("Arial Bold.ttf", 34)
+ cw = d.textlength(a.category.upper(), font=cf)
+ d.rounded_rectangle((60, y, 60 + cw + 56, y + 62), radius=31, fill=RED)
+ d.text((88, y + 12), a.category.upper(), font=cf, fill=WHITE)
+ y += 92
+
+ # ---- headline (wrapped, large bold) ----
+ hf = font("Arial Bold.ttf", 72)
+ words, lines, cur = a.headline.split(), [], ""
+ for w in words:
+ t = (cur + " " + w).strip()
+ if d.textlength(t, font=hf) <= W - 120:
+ cur = t
+ else:
+ lines.append(cur); cur = w
+ if cur:
+ lines.append(cur)
+ lines = lines[:5]
+ for ln in lines:
+ # soft shadow for legibility over any photo
+ d.text((62, y + 3), ln, font=hf, fill=(0, 0, 0, 180))
+ d.text((60, y), ln, font=hf, fill=WHITE)
+ y += 86
+
+ # ---- presenter avatar + byline (bottom) ----
+ by = H - 150
+ if a.avatar and os.path.exists(a.avatar):
+ av = circle_avatar(a.avatar, 108)
+ ring = Image.new("RGBA", (116, 116), (0, 0, 0, 0))
+ ImageDraw.Draw(ring).ellipse((0, 0, 116, 116), fill=RED)
+ img.paste(ring, (56, by - 4), ring)
+ img.paste(av, (60, by), av)
+ tx = 190
+ else:
+ tx = 60
+ d.text((tx, by + 10), a.byline, font=font("Arial Bold.ttf", 40), fill=WHITE)
+ d.text((tx, by + 62), a.footer, font=font("Arial.ttf", 30), fill=(205, 216, 232))
+
+ img.convert("RGB").save(a.out, "PNG")
+ print(a.out)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/social-video-gen.mjs b/scripts/social-video-gen.mjs
new file mode 100644
index 00000000..c399dc10
--- /dev/null
+++ b/scripts/social-video-gen.mjs
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+/**
+ * RENTV /social article-to-video generator.
+ * Turns a rentv.com news headline into a 5/15/30s vertical social teaser
+ * narrated in Steve Bloom's ("Boomer") cloned ElevenLabs voice over his photo.
+ *
+ * Pipeline (all local + one tiny ElevenLabs TTS call):
+ * article -> grounded narration script -> ElevenLabs TTS (Boomer voice)
+ * -> PIL branded still -> ffmpeg Ken Burns + audio mux -> mp4
+ *
+ * Invoked by the server as: node scripts/social-video-gen.mjs <jobId>
+ * Reads/writes data/social-jobs/<jobId>.json (status machine),
+ * writes video public/social-videos/<jobId>.mp4.
+ *
+ * Grounded: narration uses only the real headline + category + verified RENTV
+ * facts (25th year, ~50k Real Estate Insider readers 2x/week, Western U.S.).
+ */
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+
+const pexec = promisify(execFile);
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const JOBDIR = path.join(ROOT, 'data', 'social-jobs');
+const VIDDIR = path.join(ROOT, 'public', 'social-videos');
+const HERO = path.join(ROOT, 'public', 'desk-assets', 'boomer-hero.jpg');
+const AVATAR = path.join(ROOT, 'public', 'desk-assets', 'boomer-headshot.jpg');
+const VOICE_ID = '6x9fou9Xwqy5FH5seKdC'; // Steve Bloom (Boomer) — RENTV IVC
+
+const jobId = process.argv[2];
+if (!jobId) { console.error('usage: social-video-gen.mjs <jobId>'); process.exit(1); }
+const jobPath = path.join(JOBDIR, `${jobId}.json`);
+
+function readJob() { return JSON.parse(fs.readFileSync(jobPath, 'utf8')); }
+function patch(o) { const j = { ...readJob(), ...o, updated_at: new Date().toISOString() }; fs.writeFileSync(jobPath, JSON.stringify(j, null, 2)); return j; }
+
+function elevenKey() {
+ const env = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
+ const m = env.match(/^ELEVENLABS_API_KEY=(.+)$/m);
+ return m ? m[1].trim().replace(/^["']|["']$/g, '') : '';
+}
+
+/** Grounded, first-person Boomer narration keyed to target length. */
+function narration(title, category, dur) {
+ const cat = (category && category.trim()) || 'commercial real estate';
+ const t = title.replace(/\s+/g, ' ').trim();
+ if (dur <= 5) return `${t}. From RENTV — the Western U.S. commercial real estate authority.`;
+ if (dur <= 15) return `This is Steve Bloom with RENTV. ${t}. It's the kind of deal moving ${cat} across the Western U.S. — the market we've covered for twenty-five years. For the full story, visit rentv dot com.`;
+ return `This is Steve Bloom, and welcome to RENTV — now in our twenty-fifth year covering the deals that move Western U.S. commercial real estate. Today's headline: ${t}. It's exactly the kind of ${cat} activity our fifty-thousand readers follow twice a week in The Real Estate Insider. Whether you're an investor, a broker, or a lender, RENTV keeps you ahead of the Western market. Read the full story at rentv dot com.`;
+}
+
+async function tts(text, out) {
+ const key = elevenKey();
+ if (!key) throw new Error('ELEVENLABS_API_KEY missing');
+ const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}?output_format=mp3_44100_128`, {
+ method: 'POST',
+ headers: { 'xi-api-key': key, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ text, model_id: 'eleven_turbo_v2_5', voice_settings: { stability: 0.5, similarity_boost: 0.8, style: 0.15 } }),
+ });
+ if (!res.ok) throw new Error(`ElevenLabs ${res.status}: ${(await res.text()).slice(0, 200)}`);
+ fs.writeFileSync(out, Buffer.from(await res.arrayBuffer()));
+}
+
+async function probeDur(f) {
+ const { stdout } = await pexec('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', f]);
+ return parseFloat(stdout.trim()) || 0;
+}
+
+async function main() {
+ const job = readJob();
+ const { title, category = '', duration } = job;
+ try {
+ patch({ status: 'scripting' });
+ const script = narration(title, category, duration);
+ const tmp = path.join(JOBDIR, `${jobId}.narration.mp3`);
+ const frame = path.join(JOBDIR, `${jobId}.frame.png`);
+ const out = path.join(VIDDIR, `${jobId}.mp4`);
+
+ patch({ status: 'voicing', script });
+ await tts(script, tmp);
+ const nd = await probeDur(tmp);
+
+ patch({ status: 'framing', narration_seconds: +nd.toFixed(2) });
+ await pexec('python3', [path.join(ROOT, 'scripts', 'gen-social-frame.py'),
+ '--headline', title, '--category', category, '--hero', HERO, '--avatar', AVATAR, '--out', frame]);
+
+ // final length: cover the whole narration, never shorter than the target slot
+ const finalDur = Math.max(duration, Math.ceil(nd + 0.4));
+ const frames = Math.round(finalDur * 30);
+ patch({ status: 'rendering', final_seconds: finalDur });
+
+ // Ken Burns: pre-upscale 1.25x so zoompan doesn't jitter, slow zoom to ~1.12
+ const vf = `scale=1350:2400,zoompan=z='min(zoom+0.0006,1.12)':d=${frames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=1080x1920:fps=30,setsar=1`;
+ await pexec('ffmpeg', ['-y', '-loop', '1', '-framerate', '30', '-i', frame, '-i', tmp,
+ '-filter_complex', `[0:v]${vf}[v]`, '-map', '[v]', '-map', '1:a',
+ '-t', String(finalDur), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
+ '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', out],
+ { maxBuffer: 1 << 26 });
+
+ const size = fs.statSync(out).size;
+ fs.rmSync(tmp, { force: true }); fs.rmSync(frame, { force: true });
+ patch({ status: 'done', videoUrl: `/social-videos/${jobId}.mp4`, bytes: size });
+ console.log(`done ${jobId} ${finalDur}s ${size}b`);
+ } catch (e) {
+ patch({ status: 'error', error: String(e.message || e) });
+ console.error('ERR', e.message);
+ process.exit(1);
+ }
+}
+main();
diff --git a/server.js b/server.js
index dbdce729..46b3b9e9 100644
--- a/server.js
+++ b/server.js
@@ -6,6 +6,7 @@
const fs = require('fs');
const path = require('path');
const { localizeImage } = require('./scripts/lib/localize.cjs'); // never hotlink rentv.com photos
+const { spawn } = require('child_process'); // article-to-video generator jobs (/social studio)
const express = require('express');
const app = express();
app.use(express.json({ limit: '256kb' })); // covers 40kb blog bodies + folded /consulting admin-bucket saves
@@ -857,6 +858,44 @@ app.delete('/api/social/draft/:id', adminOnly, (req, res) => {
res.json({ ok: true, removed: drafts.length - n.length });
});
+// ── Article-to-video studio (/social) ───────────────────────────────────────
+// Turns a live rentv.com headline into a 5/15/30s vertical social teaser
+// narrated in Steve Bloom's ("Boomer") cloned ElevenLabs voice over his photo.
+// Async job model: POST starts a render (spawns scripts/social-video-gen.mjs),
+// GET polls status; the finished mp4 is served by express.static from
+// public/social-videos/. Admin-only; drafts/renders only, nothing auto-posts.
+const SVID_JOBS = path.join(DATA, 'social-jobs');
+const SVID_OUT = path.join(PUB, 'social-videos');
+const SVID_DURATIONS = [5, 15, 30];
+app.post('/api/social/video', adminOnly, (req, res) => {
+ const b = req.body || {};
+ const duration = Number(b.duration);
+ if (!SVID_DURATIONS.includes(duration)) return res.status(400).json({ ok: false, error: 'duration must be 5, 15, or 30' });
+ // resolve the article from the live news feed so title/category are grounded (not client-supplied)
+ const news = readJSON('news.json', { items: [] });
+ const art = asArr(news.items).find(a => String(a.id) === String(b.articleId));
+ if (!art) return res.status(404).json({ ok: false, error: 'article not found' });
+ try { fs.mkdirSync(SVID_JOBS, { recursive: true }); fs.mkdirSync(SVID_OUT, { recursive: true }); } catch {}
+ const jobId = 'sv' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+ const job = { jobId, articleId: String(art.id), title: String(art.title || '').slice(0, 240), category: String(art.cat || '').slice(0, 40), url: art.url || '', duration, status: 'queued', created_at: new Date().toISOString() };
+ fs.writeFileSync(path.join(SVID_JOBS, `${jobId}.json`), JSON.stringify(job, null, 2));
+ const child = spawn('node', [path.join(__dirname, 'scripts', 'social-video-gen.mjs'), jobId], { cwd: __dirname, detached: true, stdio: 'ignore' });
+ child.on('error', () => { try { fs.writeFileSync(path.join(SVID_JOBS, `${jobId}.json`), JSON.stringify({ ...job, status: 'error', error: 'spawn failed' }, null, 2)); } catch {} });
+ child.unref();
+ res.json({ ok: true, jobId, status: 'queued' });
+});
+app.get('/api/social/video/:jobId', adminOnly, (req, res) => {
+ const f = path.join(SVID_JOBS, `${String(req.params.jobId).replace(/[^a-z0-9]/gi, '')}.json`);
+ if (!fs.existsSync(f)) return res.status(404).json({ ok: false, error: 'no such job' });
+ try { res.json(JSON.parse(fs.readFileSync(f, 'utf8'))); } catch { res.status(500).json({ ok: false, error: 'job read failed' }); }
+});
+app.get('/api/social/videos', adminOnly, (_q, res) => {
+ let jobs = [];
+ try { jobs = fs.readdirSync(SVID_JOBS).filter(f => f.endsWith('.json')).map(f => { try { return JSON.parse(fs.readFileSync(path.join(SVID_JOBS, f), 'utf8')); } catch { return null; } }).filter(Boolean); } catch {}
+ jobs.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
+ res.json({ items: jobs.slice(0, 60) });
+});
+
// ── CRE PR Intelligence workspace (/admin/pr-intelligence + /api/pr/*) ──────
// Self-contained module (own Postgres db `rentv_pr`); admin-only end to end. If its
// DB is down, its APIs 503 cleanly and the rest of the site is unaffected.
← 1e7f9942 chore: v0.12.0 (session close — RENTV Growth console + reale
·
back to Rentv
·
fix(social): cross-platform fonts (DejaVu/Liberation fallbac 8bf8de3f →