[object Object]

← back to Rentv 2026

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

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 2026  ·  fix(social): cross-platform fonts (DejaVu/Liberation fallbac 8bf8de3f →