← back to Norma Sdcc Pitch
scripts/make-explainer-video.js
267 lines
#!/usr/bin/env node
/**
* make-explainer-video.js — record a narrated explainer walkthrough of the pitch viewer.
*
* Walks through every section of the lecture-style viewer, scrolls + interacts
* (clicks 1 agent modal, takes 1 quiz, opens the questions cheat-sheet), narrating
* each segment via macOS `say` (default) or Microsoft Azure Neural TTS (if AZURE_TTS_KEY set).
*
* Output: ~/Videos/norma-sdcc-pitch/explainer-<ISO>.mp4
*
* Run: node scripts/make-explainer-video.js
* Env: TARGET_URL default http://localhost:9876
* LOGIN_USER default sdcc
* LOGIN_PASS default Pulse-2026
* VOICE default 'Samantha' (macOS say) or any installed voice
* OUT_DIR default ~/Videos/norma-sdcc-pitch
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, spawnSync } = require('child_process');
const { chromium } = require('playwright');
const TARGET = process.env.TARGET_URL || 'http://localhost:9876';
const USER = process.env.LOGIN_USER || 'sdcc';
const PASS = process.env.LOGIN_PASS || 'Pulse-2026';
const VOICE = process.env.VOICE || 'Samantha'; // macOS fallback
const ELEVEN_KEY = process.env.ELEVENLABS_API_KEY || (() => {
try {
const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf-8');
const m = env.match(/^ELEVENLABS_API_KEY=(.*)$/m);
return m ? m[1].trim() : null;
} catch { return null; }
})();
const STEVE_VOICE_ID = (() => {
try {
const j = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude/skills/clone-voice/active.json'), 'utf-8'));
return j.engines?.elevenlabs?.voice_id || null;
} catch { return null; }
})();
const OUT_DIR = process.env.OUT_DIR || path.join(os.homedir(), 'Videos', 'norma-sdcc-pitch');
fs.mkdirSync(OUT_DIR, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
// Reuse cached narration if present (saves ElevenLabs $$)
const REUSE_TMP = process.env.REUSE_TMP || null;
const tmpDir = REUSE_TMP || path.join(OUT_DIR, '_tmp-' + stamp);
fs.mkdirSync(tmpDir, { recursive: true });
// ── narration script — section, scroll target, narration text, pause-after ms
const SEGMENTS = [
{
sel: '.hero',
say: "Welcome to Norma. This is a proposal for the Student Debt Crisis Center. A custom CRM, built to fit SDCC's work — not the other way around.",
pause: 1500,
},
{
sel: '#priorities',
say: "Here are the five priorities. Email coordination across the team. Inbound email triage. Graphics for campaigns. Your Wix website. And building UX and UI capability directly into Wix.",
pause: 1500,
},
{
sel: '#priorities .priority-card:first-child',
say: "Priority one: email coordination. Three staffers, one info inbox. With Norma, every conversation has a single owner, every reply is logged, and the whole team can see who's handling what.",
pause: 2500,
},
{
sel: '#reluctant',
say: "If you're skeptical of AI, this section is for you. Your Tuesday at two PM, with and without Norma. Three hundred eighty seven unread becomes twenty two priority items. Five hours becomes five minutes.",
pause: 2500,
},
{
sel: '#db-shift',
say: "The big mental shift. Every organization is now a database — your knowledge, your stories, your coalition map. Not a website. Not a Google Doc. A queryable structured database that humans and AI agents can act on.",
pause: 2500,
},
{
sel: '#wix',
say: "Wix is fine. It's where SDCC's site lives. Here are five concrete integration paths — forms to Norma webhook, live data via Velo, embedded Pulse dashboard, structured borrower-story intake, and CMS write-back.",
pause: 2500,
},
{
sel: '#why-custom',
say: "Why custom CRM, instead of Salesforce or EveryAction? Because off-the-shelf CRMs model sales pipelines. SDCC's work is borrower in crisis, story tagged, policy lever, press hit, coalition activation. Norma was built for that shape.",
pause: 3000,
},
{
sel: '#why',
say: "Why AI for a nonprofit? Smaller staff, bigger reach, faster cycles. AI absorbs the time-consuming drafting and research work, so each staffer's hour goes further. Humans approve every outbound message.",
pause: 2500,
},
{
sel: '#loops',
say: "Loops keep Norma working while you sleep. Inbox loop every five minutes. News scan every thirty. Petition velocity every fifteen. Coalition watch daily. Every loop has a kill switch and logs every action.",
pause: 2500,
},
{
sel: '#cron',
say: "Cron jobs run on a calendar. Monday seven AM, the weekly intelligence brief. First of the month, donor renewal radar. Tuesday ten AM, grants deadline sweep. Quarterly, the board impact report.",
pause: 2500,
},
{
sel: '#agents',
say: "Twelve agents SDCC would actually use. Petition generator, advocacy statement drafter, grants matcher, inbox triage, news intelligence, borrower story classifier, audit trail, and more. Each one click-runnable.",
pause: 2500,
},
{
sel: '#example',
say: "A real example, using SDCC's data. The Department of Education announces one million PSLF discharges. Norma drafts the response in ninety seconds. Total time, news drop to press-ready statement, about seven minutes. Without Norma, three to five hours.",
pause: 3000,
},
{
sel: '#glossary',
say: "A full onboarding glossary. Eighty terms across ten categories — terminal, Claude install, MCP, LLM, the SDCC LLM demystified. For anyone new to the stack.",
pause: 2000,
},
{
sel: 'footer',
say: "That's Norma. Built to fit SDCC's work. The decision is whether to bend the work to fit a generic tool, or build the tool to fit the work. Thank you.",
pause: 2500,
},
];
async function ttsEleven(text, outFile) {
// ElevenLabs streaming TTS with Steve's cloned voice.
const url = `https://api.elevenlabs.io/v1/text-to-speech/${STEVE_VOICE_ID}`;
const r = await fetch(url, {
method: 'POST',
headers: {
'xi-api-key': ELEVEN_KEY,
'Content-Type': 'application/json',
'Accept': 'audio/mpeg',
},
body: JSON.stringify({
text,
model_id: 'eleven_turbo_v2_5',
voice_settings: { stability: 0.45, similarity_boost: 0.85, style: 0.25, use_speaker_boost: true },
}),
});
if (!r.ok) throw new Error(`ElevenLabs ${r.status}: ${await r.text()}`);
const buf = Buffer.from(await r.arrayBuffer());
const mp3 = outFile.replace(/\.wav$/, '.mp3');
fs.writeFileSync(mp3, buf);
spawnSync('ffmpeg', ['-y', '-i', mp3, '-ar', '44100', '-ac', '2', outFile], { stdio: 'pipe' });
fs.unlinkSync(mp3);
}
function ttsSayFallback(text, outFile) {
const aiff = outFile.replace(/\.wav$/, '.aiff');
spawnSync('say', ['-v', VOICE, '-o', aiff, text], { stdio: 'inherit' });
spawnSync('ffmpeg', ['-y', '-i', aiff, '-ar', '44100', '-ac', '2', outFile], { stdio: 'pipe' });
fs.unlinkSync(aiff);
}
async function tts(text, outFile) {
if (ELEVEN_KEY && STEVE_VOICE_ID) {
try {
await ttsEleven(text, outFile);
const probe = spawnSync('ffprobe', ['-i', outFile, '-show_entries', 'format=duration', '-v', 'quiet', '-of', 'csv=p=0']);
return parseFloat(probe.stdout.toString().trim()) || 3;
} catch (err) {
console.error(' ! ElevenLabs failed:', err.message, '— falling back to macOS say');
}
}
ttsSayFallback(text, outFile);
const probe = spawnSync('ffprobe', ['-i', outFile, '-show_entries', 'format=duration', '-v', 'quiet', '-of', 'csv=p=0']);
return parseFloat(probe.stdout.toString().trim()) || 3;
}
(async () => {
console.log('[explainer] target:', TARGET);
console.log('[explainer] tmp:', tmpDir);
// 1. record narration audio per segment + measure durations
console.log('[explainer] narration engine:', ELEVEN_KEY && STEVE_VOICE_ID ? `ElevenLabs (Steve Abrams, ${STEVE_VOICE_ID})` : `macOS say (${VOICE})`);
console.log('[explainer] generating narration audio…');
const durations = [];
const audioFiles = [];
for (let i = 0; i < SEGMENTS.length; i++) {
const wav = path.join(tmpDir, `narr-${String(i).padStart(2, '0')}.wav`);
let d;
if (fs.existsSync(wav) && fs.statSync(wav).size > 1000) {
const probe = spawnSync('ffprobe', ['-i', wav, '-show_entries', 'format=duration', '-v', 'quiet', '-of', 'csv=p=0']);
d = parseFloat(probe.stdout.toString().trim()) || 3;
console.log(` [${i}] ${d.toFixed(1)}s (CACHED)`);
} else {
d = await tts(SEGMENTS[i].say, wav);
console.log(` [${i}] ${d.toFixed(1)}s — "${SEGMENTS[i].say.slice(0, 70)}…"`);
}
durations.push(d + (SEGMENTS[i].pause || 1500) / 1000);
audioFiles.push(wav);
}
// 2. concat narration into one wav
const concatList = path.join(tmpDir, 'concat.txt');
fs.writeFileSync(concatList, audioFiles.map((f) => `file '${f}'`).join('\n'));
const narrationWav = path.join(tmpDir, 'narration.wav');
spawnSync('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', concatList, '-c', 'copy', narrationWav], { stdio: 'pipe' });
const totalDur = durations.reduce((a, b) => a + b, 0);
console.log(`[explainer] total narration: ${totalDur.toFixed(1)}s`);
// 3. record screen with Playwright at the right pace
console.log('[explainer] launching browser + recording…');
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
});
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: { dir: tmpDir, size: { width: 1440, height: 900 } },
});
const page = await ctx.newPage();
// login — submit via JS to avoid viewport-position issues
await page.goto(TARGET + '/login');
await page.waitForSelector('input[name="username"]');
await page.fill('input[name="username"]', USER);
await page.fill('input[name="password"]', PASS);
await page.evaluate(() => document.querySelector('form').submit());
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1500);
// walk segments
for (let i = 0; i < SEGMENTS.length; i++) {
const s = SEGMENTS[i];
try {
await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, s.sel);
} catch (err) { /* selector missing — skip */ }
await page.waitForTimeout(Math.max(500, durations[i] * 1000));
}
await page.close();
const videoPath = await ctx._currentPage?.video?.()?.path() || null;
await ctx.close();
await browser.close();
// find the .webm just recorded
const webms = fs.readdirSync(tmpDir).filter((f) => f.endsWith('.webm')).map((f) => path.join(tmpDir, f));
if (!webms.length) {
console.error('[explainer] no .webm recorded — abort');
process.exit(1);
}
const webm = webms[0];
console.log('[explainer] webm:', webm);
// 4. mux video + audio → mp4
const outMp4 = path.join(OUT_DIR, `explainer-${stamp}.mp4`);
spawnSync('ffmpeg', [
'-y', '-i', webm, '-i', narrationWav,
'-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
'-c:a', 'aac', '-b:a', '160k',
'-shortest', outMp4,
], { stdio: 'inherit' });
// cleanup tmp
try { execSync(`rm -rf "${tmpDir}"`); } catch {}
console.log('\n══════════════════════════════════════════════════════');
console.log(' DONE → ' + outMp4);
console.log('══════════════════════════════════════════════════════');
})();