← back to Rentv 2026
Content Studio: Boomer voice + video buttons — narrate the generated post in Boom's cloned ElevenLabs voice (mp3 or photo+Ken-Burns mp4), reuses async social-video job infra
d1b7891576f5062ef4d560c14687d5dca78e6d1c · 2026-08-05 12:27:51 -0700 · Steve Abrams
Files touched
M public/admin/pr-intelligence/content-studio.htmlM scripts/social-video-gen.mjsM server.js
Diff
commit d1b7891576f5062ef4d560c14687d5dca78e6d1c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 12:27:51 2026 -0700
Content Studio: Boomer voice + video buttons — narrate the generated post in Boom's cloned ElevenLabs voice (mp3 or photo+Ken-Burns mp4), reuses async social-video job infra
---
public/admin/pr-intelligence/content-studio.html | 39 ++++++++++++++++++++++++
scripts/social-video-gen.mjs | 14 ++++++++-
server.js | 32 +++++++++++++++++++
3 files changed, 84 insertions(+), 1 deletion(-)
diff --git a/public/admin/pr-intelligence/content-studio.html b/public/admin/pr-intelligence/content-studio.html
index 2998b090..c83e6584 100644
--- a/public/admin/pr-intelligence/content-studio.html
+++ b/public/admin/pr-intelligence/content-studio.html
@@ -149,6 +149,12 @@
<button class="btn" id="emailboom">📧 Draft to Boom (steve-office)</button>
<span class="cs-meta" id="pubmeta"></span>
</div>
+ <div class="cs-row" style="margin-top:8px;border-top:1px dashed var(--line,#e4e7ea);padding-top:10px">
+ <button class="btn" id="boomervoice" title="Narrate this post in Boomer's cloned ElevenLabs voice (audio mp3)">🔊 Boomer voice</button>
+ <button class="btn" id="boomervideo" title="Boomer voice over his photo, Ken-Burns vertical mp4">🎬 Boomer video</button>
+ <span class="cs-meta">Cloned voice · ElevenLabs (paid) — cost shown per clip</span>
+ </div>
+ <div id="boomermedia" class="cs-meta" style="margin-top:8px"></div>
</div>
<div class="cs-card" id="socialCard" style="display:none">
<h3>Social</h3><div class="sub">Saves to social drafts (feeds the LinkedIn Posting composer). Nothing auto-posts.</div>
@@ -286,6 +292,39 @@
} finally { $('emailboom').disabled = false; }
}, null));
+ // ── Boomer video / voice from the generated post (cloned ElevenLabs voice) ──
+ // Reuses the async social-video job infra; polls until the mp3/mp4 is ready.
+ function boomerGen(mode) {
+ return PR.guard(async () => {
+ if (!$('b_title').value.trim()) { PR.toast('Generate a post first', true); return; }
+ const label = mode === 'video' ? 'video' : 'voice';
+ if (!confirm(`Generate the Boomer ${label} in his cloned voice? This makes a paid ElevenLabs TTS call (~$0.20–0.35 for this clip; cost shown after).`)) return;
+ const btn = $(mode === 'video' ? 'boomervideo' : 'boomervoice'); btn.disabled = true;
+ $('boomermedia').innerHTML = `<span class="spin"></span> Queuing Boomer ${label}…`;
+ try {
+ const hl = [...document.querySelectorAll('#hl li')].map(li => li.textContent.trim()).filter(Boolean);
+ const j = await PR.api('/../content/boomer', { method: 'POST', body: { mode, title: $('b_title').value.trim(), body: $('b_body').value, highlights: hl } });
+ $('boomermedia').innerHTML = `<span class="cs-cost">${PR.esc(j.estCost || '')}</span> <span class="spin"></span> rendering ${label}… (${j.chars} chars)`;
+ const started = Date.now();
+ const tick = async () => {
+ let s; try { s = await PR.api('/../social/video/' + encodeURIComponent(j.jobId)); } catch { return setTimeout(tick, 2500); }
+ if (s.status === 'done') {
+ if (s.audioUrl) $('boomermedia').innerHTML = `<div class="cs-ok">✅ Boomer voice ready</div><audio controls src="${PR.esc(s.audioUrl)}" style="width:100%;margin-top:6px"></audio><div class="cs-meta" style="margin-top:4px"><a href="${PR.esc(s.audioUrl)}" download>⬇ download mp3</a> · <span class="cs-cost">${PR.esc(j.estCost || '')}</span></div>`;
+ else $('boomermedia').innerHTML = `<div class="cs-ok">✅ Boomer video ready</div><video controls src="${PR.esc(s.videoUrl)}" style="width:220px;margin-top:6px;border-radius:8px"></video><div class="cs-meta" style="margin-top:4px"><a href="${PR.esc(s.videoUrl)}" download>⬇ download mp4</a> · <span class="cs-cost">${PR.esc(j.estCost || '')}</span></div>`;
+ btn.disabled = false; return;
+ }
+ if (s.status === 'error') { $('boomermedia').innerHTML = `<span style="color:#b23a1e">⚠ ${PR.esc(s.error || 'render failed')}</span>`; btn.disabled = false; return; }
+ if (Date.now() - started > 180000) { $('boomermedia').innerHTML = '<span style="color:#b23a1e">⚠ Timed out — check the /social video list.</span>'; btn.disabled = false; return; }
+ $('boomermedia').firstChild && ($('boomermedia').querySelector('.cs-status') || 0); // keep spinner
+ setTimeout(tick, 2500);
+ };
+ setTimeout(tick, 2500);
+ } catch (e) { $('boomermedia').innerHTML = `<span style="color:#b23a1e">⚠ ${PR.esc(e.message)}</span>`; btn.disabled = false; }
+ }, null);
+ }
+ $('boomervoice').addEventListener('click', () => boomerGen('voice'));
+ $('boomervideo').addEventListener('click', () => boomerGen('video'));
+
// Copy-to-clipboard on blog body + social drafts (the hand-off step).
document.addEventListener('click', (e) => {
const a = e.target.closest('.cs-copy'); if (!a) return;
diff --git a/scripts/social-video-gen.mjs b/scripts/social-video-gen.mjs
index 9db773f2..e51ba644 100644
--- a/scripts/social-video-gen.mjs
+++ b/scripts/social-video-gen.mjs
@@ -85,7 +85,9 @@ async function main() {
const { title, category = '', duration } = job;
try {
patch({ status: 'scripting' });
- const script = narration(title, category, duration);
+ // Content Studio jobs carry their own first-person narration (job.script); article jobs derive it.
+ const script = (job.script && String(job.script).trim()) || narration(title, category, duration);
+ const voiceOnly = job.mode === 'voice';
const tmp = path.join(JOBDIR, `${jobId}.narration.mp3`);
const frame = path.join(JOBDIR, `${jobId}.frame.png`);
const out = path.join(VIDDIR, `${jobId}.mp4`);
@@ -94,6 +96,16 @@ async function main() {
await tts(script, tmp);
const nd = await probeDur(tmp);
+ // Voice-only (Boomer audio): publish the mp3 straight to the served dir and stop — no frame/ffmpeg.
+ if (voiceOnly) {
+ const mp3 = path.join(VIDDIR, `${jobId}.mp3`);
+ fs.copyFileSync(tmp, mp3);
+ fs.rmSync(tmp, { force: true });
+ patch({ status: 'done', audioUrl: `/social-videos/${jobId}.mp3`, narration_seconds: +nd.toFixed(2), bytes: fs.statSync(mp3).size });
+ console.log(`done(voice) ${jobId} ${nd.toFixed(1)}s`);
+ return;
+ }
+
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]);
diff --git a/server.js b/server.js
index e498b81d..11f208fe 100644
--- a/server.js
+++ b/server.js
@@ -542,6 +542,38 @@ app.post('/api/content/email', adminOnly, async (req, res) => {
} catch (e) { return res.status(502).json({ ok: false, error: String(e.message || e).slice(0, 160) }); }
});
+// ── Content Studio → "Boomer" video/voice ───────────────────────────────────
+// Narrates the GENERATED post in Steve Bloom's ("Boomer") cloned ElevenLabs voice
+// (VOICE_ID EoZqhGRrFYVEnxL4P2So, the VO he recorded 2026-07-31). Reuses the async
+// social-video job infra — poll via GET /api/social/video/:jobId. mode:'voice' → mp3
+// only; mode:'video' → photo + Ken-Burns + voice mp4. Cost: ElevenLabs ~$0.30/1k chars
+// (returned to the client); ffmpeg is local $0. Admin-only; renders only, nothing posts.
+function boomerScript(title, highlights, body) {
+ const t = clean(title, 200).replace(/\s+/g, ' ').trim();
+ let core = '';
+ if (Array.isArray(highlights) && highlights.length) core = highlights.map(h => clean(h, 300)).filter(Boolean).slice(0, 4).join(' ');
+ if (!core) core = clean(body, 800).split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean).slice(0, 3).join(' ');
+ core = core.replace(/\s+/g, ' ').trim();
+ const vo = `This is Steve Bloom with RENTV. ${t}. ${core} For the full story, visit rentv dot com.`;
+ return vo.slice(0, 1100); // cap ~1.1k chars ≈ $0.33 so a single clip's TTS cost stays bounded
+}
+app.post('/api/content/boomer', adminOnly, (req, res) => {
+ const b = req.body || {};
+ const mode = b.mode === 'video' ? 'video' : 'voice';
+ const title = clean(b.title, 200);
+ if (!title) return res.status(400).json({ ok: false, error: 'no title — generate a post first' });
+ const script = boomerScript(title, b.highlights, b.body);
+ try { fs.mkdirSync(SVID_JOBS, { recursive: true }); fs.mkdirSync(SVID_OUT, { recursive: true }); } catch {}
+ const jobId = 'bm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
+ const job = { jobId, source: 'content-studio', mode, title, category: clean(b.category, 40), duration: mode === 'video' ? 30 : 0, script, 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();
+ const estCost = '~$' + (script.length / 1000 * 0.30).toFixed(2) + ' (ElevenLabs TTS' + (mode === 'video' ? ' + ffmpeg $0 local' : '') + ')';
+ res.json({ ok: true, jobId, status: 'queued', mode, chars: script.length, estCost });
+});
+
// ── Deal Desk: find a closed CRE deal RENTV hasn't covered, chat-adjust it, spin social ──
// /find → Gemini (grounded, paid) surfaces a real recent Western-U.S. closed deal + flags
// whether the news archive already covers it. /chat → local LLM ($0) edits the deal
← 64a70630 auto-save: 2026-08-05T12:11:52 (3 files) — data/markets.json
·
back to Rentv 2026
·
auto-save: 2026-08-05T12:42:02 (6 files) — data/markets.json eb29d4a1 →