← back to Goodquestion Ai
video-html/generate-video.js
515 lines
#!/usr/bin/env node
/**
* generate-video.js — Master video generation pipeline
*
* Takes a JSON config file and produces narrated videos
* in landscape (1920x1080) and vertical (1080x1920).
*
* Pipeline:
* 1. Load HTML template from templates/ dir, inject data
* 2. Capture each slide as PNG frame sequence with Puppeteer
* 3. Generate narration audio with edge-tts
* 4. Avatar overlay on FIRST slide only (3s max, fade out)
* 5. Concatenate all segments into final video
* 6. Create vertical version by reframing
*
* Usage:
* node generate-video.js <config.json>
* node generate-video.js --sample
*/
const fs = require('fs');
const path = require('path');
const { execFileSync, execFile } = require('child_process');
const { captureFrames } = require('./capture');
const BASE_DIR = __dirname;
const TEMPLATES_DIR = path.join(BASE_DIR, 'templates');
const SLIDES_DIR = path.join(BASE_DIR, 'slides');
const TMP_DIR = path.join(BASE_DIR, 'tmp');
const OUTPUT_DIR = path.join(BASE_DIR, 'output');
[SLIDES_DIR, TMP_DIR, OUTPUT_DIR].forEach(d => fs.mkdirSync(d, { recursive: true }));
// ============================================================
// Template engine — loads HTML files, replaces {{placeholders}}
// and injects dynamic content (number cards, feature items)
// ============================================================
function loadTemplate(templateName) {
const filePath = path.join(TEMPLATES_DIR, `${templateName}.html`);
if (!fs.existsSync(filePath)) {
throw new Error(`Template not found: ${filePath}`);
}
return fs.readFileSync(filePath, 'utf-8');
}
function renderTemplate(templateName, data, avatarConfig, width = 1920, height = 1080) {
let html = loadTemplate(templateName);
// Determine if avatar should show
const showAvatar = data.avatar === true && avatarConfig && avatarConfig.image;
const avatarImage = showAvatar ? avatarConfig.image : '';
const avatarDisplay = showAvatar ? 'block' : 'none';
// Common replacements
const replacements = {
width: String(width),
height: String(height),
avatarImage,
avatarDisplay,
};
// Template-specific data and dynamic content
switch (templateName) {
case 'hook':
replacements.headline = data.headline || 'Untitled';
replacements.subtext = data.subtext || '';
replacements.subtextDisplay = data.subtext ? 'block' : 'none';
break;
case 'big-number': {
const numbers = data.numbers || [];
const count = numbers.length;
replacements.gap = String(count <= 2 ? 200 : 100);
replacements.numberSize = String(count <= 2 ? 120 : 96);
const cardsHTML = numbers.map(n => {
const prefix = n.prefix || '';
const suffix = n.suffix || '';
const color = n.color || '#00D4FF';
return `
<div class="number-card">
<div class="number-value" style="color: ${color};">
<span class="counter" data-target="${n.value}" data-prefix="${prefix}" data-suffix="${suffix}">${prefix}0${suffix}</span>
</div>
<div class="number-label">${n.label}</div>
<div class="number-bar" style="background: ${color};"></div>
</div>`;
}).join('');
html = html.replace('<!-- NUMBERS_CARDS -->', cardsHTML);
break;
}
case 'feature-list': {
replacements.title = data.title || '';
const items = data.items || [];
const itemsHTML = items.map(item => {
const text = typeof item === 'string' ? item : item.text;
const dotColor = (typeof item === 'object' && item.color) ? item.color : '#00D4FF';
return `
<li class="feature-item">
<span class="feature-dot" style="background: ${dotColor};"></span>
<span class="feature-text">${text}</span>
</li>`;
}).join('');
html = html.replace('<!-- FEATURE_ITEMS -->', itemsHTML);
break;
}
case 'comparison':
replacements.title = data.title || '';
replacements.titleDisplay = data.title ? 'block' : 'none';
replacements.beforeValue = (data.before && data.before.value) || '0';
replacements.beforeLabel = (data.before && data.before.label) || 'Before';
replacements.afterValue = (data.after && data.after.value) || '0';
replacements.afterLabel = (data.after && data.after.label) || 'After';
replacements.change = data.change || '';
replacements.badgeDisplay = data.change ? 'block' : 'none';
break;
case 'cta':
replacements.brand = data.brand || 'Agent Abrams';
replacements.handle = data.handle || '@agentabrams';
replacements.url = data.url || 'goodquestion.ai';
replacements.tagline = data.tagline || '';
break;
}
// Apply all {{placeholder}} replacements
for (const [key, value] of Object.entries(replacements)) {
html = html.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value);
}
return html;
}
// ============================================================
// Audio / FFmpeg helpers (all use execFile — no shell injection)
// ============================================================
async function generateNarration(text, outputPath) {
console.log(` TTS: ${text.substring(0, 60)}...`);
return new Promise((resolve, reject) => {
execFile('edge-tts', [
'--voice', 'en-US-AndrewNeural',
'--rate=-10%',
'--text', text,
'--write-media', outputPath
], { maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) {
console.error(` TTS error: ${stderr}`);
reject(error);
} else {
resolve(outputPath);
}
});
});
}
function getAudioDuration(audioPath) {
try {
const result = execFileSync('ffprobe', [
'-v', 'quiet',
'-show_entries', 'format=duration',
'-of', 'csv=p=0',
audioPath
], { encoding: 'utf-8' }).trim();
return parseFloat(result);
} catch (e) {
console.warn(` Could not get duration for ${audioPath}, defaulting to 5s`);
return 5;
}
}
function runFFmpeg(args) {
return new Promise((resolve, reject) => {
execFile('ffmpeg', args, { maxBuffer: 50 * 1024 * 1024 }, (error, stdout, stderr) => {
if (error) reject(new Error(`ffmpeg failed: ${stderr}`));
else resolve(stdout);
});
});
}
async function assembleSegment(framesDir, audioPath, outputPath, fps = 30) {
console.log(` Assembling: ${path.basename(outputPath)}`);
const framePattern = path.join(framesDir, 'frame-%04d.png');
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
await runFFmpeg([
'-y',
'-framerate', String(fps),
'-i', framePattern,
'-i', audioPath,
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
'-c:a', 'aac', '-b:a', '192k',
'-pix_fmt', 'yuv420p',
'-shortest',
'-movflags', '+faststart',
outputPath
]);
}
async function normalizeSegment(inputPath, outputPath) {
await runFFmpeg([
'-y', '-i', inputPath,
'-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
'-c:a', 'aac', '-b:a', '192k', '-ar', '44100', '-ac', '2',
'-pix_fmt', 'yuv420p', '-r', '30',
outputPath
]);
}
async function overlayAvatar(inputVideo, avatarImage, outputVideo, duration = 3, position = 'bottom-right', size = 200) {
const margin = 40;
let overlayPos;
switch (position) {
case 'bottom-left': overlayPos = `x=${margin}:y=H-h-${margin}`; break;
case 'top-right': overlayPos = `x=W-w-${margin}:y=${margin}`; break;
case 'top-left': overlayPos = `x=${margin}:y=${margin}`; break;
default: overlayPos = `x=W-w-${margin}:y=H-h-${margin}`; break;
}
const fadeOutStart = Math.max(0, duration - 0.5);
const filterComplex = [
`[1:v]scale=${size}:${size},format=yuva420p,`,
`geq=lum='lum(X,Y)':cb='cb(X,Y)':cr='cr(X,Y)':`,
`a='if(gt(pow(X-${size / 2},2)+pow(Y-${size / 2},2),pow(${size / 2},2)),0,alpha(X,Y))',`,
`fade=t=in:st=0:d=0.3:alpha=1,`,
`fade=t=out:st=${fadeOutStart}:d=0.5:alpha=1`,
`[avatar];`,
`[0:v][avatar]overlay=${overlayPos}:enable='between(t,0,${duration})'`
].join('');
try {
console.log(` Overlaying avatar for ${duration}s...`);
await runFFmpeg([
'-y', '-i', inputVideo, '-i', avatarImage,
'-filter_complex', filterComplex,
'-c:a', 'copy', '-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
'-pix_fmt', 'yuv420p',
outputVideo
]);
return true;
} catch (e) {
console.warn(` Avatar overlay failed, using segment without avatar`);
fs.copyFileSync(inputVideo, outputVideo);
return false;
}
}
async function generateSilence(outputPath, durationSec) {
await runFFmpeg([
'-y', '-f', 'lavfi',
'-i', 'anullsrc=r=44100:cl=mono',
'-t', String(durationSec),
'-q:a', '9',
outputPath
]);
}
async function createVerticalVersion(landscapeVideo, verticalOutput) {
console.log('\nCreating vertical version (1080x1920)...');
try {
fs.mkdirSync(path.dirname(verticalOutput), { recursive: true });
await runFFmpeg([
'-y', '-i', landscapeVideo,
'-vf', 'scale=1080:-1,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=0x0B0B0F',
'-c:a', 'copy', '-c:v', 'libx264', '-preset', 'medium', '-crf', '18',
'-pix_fmt', 'yuv420p',
verticalOutput
]);
console.log(` Vertical: ${verticalOutput}`);
} catch (e) {
console.error(` Vertical creation failed: ${e.message}`);
}
}
async function concatSegments(concatListPath, outputPath) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
await runFFmpeg([
'-y', '-f', 'concat', '-safe', '0',
'-i', concatListPath,
'-c', 'copy', '-movflags', '+faststart',
outputPath
]);
}
// ============================================================
// Main pipeline
// ============================================================
async function generateVideo(config) {
const startTime = Date.now();
console.log('='.repeat(60));
console.log(`VIDEO PIPELINE: ${config.title || 'Untitled'}`);
console.log('='.repeat(60));
const slides = config.slides || [];
const avatarConfig = config.avatar || null;
const outputConfig = config.output || {};
const runId = Date.now().toString(36);
const runDir = path.join(TMP_DIR, `run-${runId}`);
fs.mkdirSync(runDir, { recursive: true });
const segmentPaths = [];
for (let i = 0; i < slides.length; i++) {
const slide = slides[i];
const slideNum = String(i + 1).padStart(2, '0');
const templateName = slide.template;
console.log(`\n--- Slide ${i + 1}/${slides.length}: ${templateName} ---`);
// 1. Render HTML from template file + data
const showAvatar = slide.avatar === true && avatarConfig;
const html = renderTemplate(templateName, slide, showAvatar ? avatarConfig : null, 1920, 1080);
const htmlPath = path.join(SLIDES_DIR, `slide-${slideNum}-${templateName}.html`);
fs.writeFileSync(htmlPath, html);
console.log(` HTML: ${htmlPath}`);
// 2. Generate narration
let narrationPath = null;
let audioDuration = slide.duration || 5;
if (slide.narration) {
narrationPath = path.join(runDir, `narration-${slideNum}.mp3`);
await generateNarration(slide.narration, narrationPath);
const ttsDuration = getAudioDuration(narrationPath);
audioDuration = Math.max(ttsDuration + 0.5, slide.duration || 0);
console.log(` Audio: ${ttsDuration.toFixed(1)}s TTS, ${audioDuration.toFixed(1)}s total`);
} else {
narrationPath = path.join(runDir, `silence-${slideNum}.mp3`);
await generateSilence(narrationPath, audioDuration);
}
// 3. Capture frames
const framesDir = path.join(runDir, `frames-${slideNum}`);
await captureFrames({
input: htmlPath,
outputDir: framesDir,
duration: audioDuration,
fps: 30,
width: 1920,
height: 1080,
});
// 4. Assemble segment
const segmentPath = path.join(runDir, `segment-${slideNum}.mp4`);
await assembleSegment(framesDir, narrationPath, segmentPath, 30);
// 5. Avatar overlay — FIRST slide only, via ffmpeg
if (i === 0 && avatarConfig && avatarConfig.image && fs.existsSync(avatarConfig.image)) {
const avatarSegmentPath = path.join(runDir, `segment-${slideNum}-avatar.mp4`);
await overlayAvatar(
segmentPath, avatarConfig.image, avatarSegmentPath,
avatarConfig.maxDuration || 3,
avatarConfig.position || 'bottom-right',
avatarConfig.size || 200
);
segmentPaths.push(avatarSegmentPath);
} else {
segmentPaths.push(segmentPath);
}
}
// 6. Normalize & concatenate
console.log('\n--- Final Assembly ---');
const normalizedPaths = [];
for (let i = 0; i < segmentPaths.length; i++) {
const normPath = path.join(runDir, `norm-${String(i + 1).padStart(2, '0')}.mp4`);
console.log(` Normalizing segment ${i + 1}/${segmentPaths.length}...`);
await normalizeSegment(segmentPaths[i], normPath);
normalizedPaths.push(normPath);
}
const concatListPath = path.join(runDir, 'concat-list.txt');
fs.writeFileSync(concatListPath, normalizedPaths.map(p => `file '${p}'`).join('\n'));
const landscapeOutput = path.resolve(outputConfig.landscape || path.join(OUTPUT_DIR, 'landscape.mp4'));
fs.mkdirSync(path.dirname(landscapeOutput), { recursive: true });
await concatSegments(concatListPath, landscapeOutput);
console.log(` Landscape: ${landscapeOutput}`);
// 7. Vertical version
if (outputConfig.vertical) {
const verticalOutput = path.resolve(outputConfig.vertical);
await createVerticalVersion(landscapeOutput, verticalOutput);
}
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('\n' + '='.repeat(60));
console.log(`COMPLETE in ${totalTime}s`);
console.log(` Landscape: ${landscapeOutput}`);
if (outputConfig.vertical) console.log(` Vertical: ${path.resolve(outputConfig.vertical)}`);
console.log('='.repeat(60));
return { landscape: landscapeOutput, vertical: outputConfig.vertical ? path.resolve(outputConfig.vertical) : null };
}
// ============================================================
// Sample config
// ============================================================
function getSampleConfig() {
return {
title: 'What I Built This Week',
slides: [
{
template: 'hook',
headline: 'What One Person Built This Week',
subtext: 'March 2026',
narration: 'Hey, it is Agent Abrams. Here is what I shipped this week with just Claude Code and one server.',
duration: 6,
avatar: true,
},
{
template: 'big-number',
numbers: [
{ value: 122, label: 'Products Processed', color: '#00D4FF' },
{ value: 566, label: 'Data Points Set', color: '#FF6B35' },
{ value: 100, suffix: '%', label: 'Coverage', color: '#00E68C' },
],
narration: 'A brand new luxury catalog, fully onboarded. One hundred twenty-two products. Every single item mapped. Five hundred sixty-six data points enriched.',
duration: 8,
},
{
template: 'feature-list',
title: "This Week's Builds",
items: [
{ text: 'Full product pipeline: scrape, enrich, publish', color: '#00D4FF' },
{ text: 'AI-powered data enrichment with Gemini', color: '#FF6B35' },
{ text: 'Automated narrated video generation', color: '#00E68C' },
{ text: 'Real-time monitoring dashboard', color: '#00D4FF' },
],
narration: 'Here is the breakdown. A complete product pipeline from raw data to published listings. AI powered enrichment using Gemini for every single field. Automated video generation, like this one you are watching right now. And a real-time monitoring dashboard to track it all.',
duration: 10,
},
{
template: 'comparison',
title: 'Manual vs Automated',
before: { value: '40h', label: 'Manual Process' },
after: { value: '12min', label: 'Automated' },
change: '200x faster',
narration: 'What used to take a team forty hours now runs in twelve minutes. That is a two hundred X improvement. One person, one server, production grade.',
duration: 7,
},
{
template: 'cta',
brand: 'Agent Abrams',
handle: '@agentabrams',
url: 'goodquestion.ai',
tagline: 'One person. One server. Production-grade AI.',
narration: 'Follow along at good question dot A I, or find me on X at agent abrams. Until next time.',
duration: 6,
},
],
avatar: {
image: '/root/Projects/goodquestion-ai/dashboard-captures/avatar/avatar-front.png',
maxDuration: 3,
position: 'bottom-right',
size: 200,
},
output: {
landscape: 'output/landscape.mp4',
vertical: 'output/vertical.mp4',
},
};
}
// ============================================================
// CLI
// ============================================================
async function main() {
const args = process.argv.slice(2);
let config;
if (args[0] === '--sample') {
console.log('Using sample config...\n');
config = getSampleConfig();
const samplePath = path.join(BASE_DIR, 'sample-config.json');
fs.writeFileSync(samplePath, JSON.stringify(config, null, 2));
console.log(`Sample config saved to: ${samplePath}\n`);
} else if (args[0]) {
const configPath = path.resolve(args[0]);
if (!fs.existsSync(configPath)) {
console.error(`Config file not found: ${configPath}`);
process.exit(1);
}
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} else {
console.error('Usage:');
console.error(' node generate-video.js <config.json>');
console.error(' node generate-video.js --sample');
process.exit(1);
}
try {
await generateVideo(config);
} catch (err) {
console.error('\nPipeline failed:', err.message);
console.error(err.stack);
process.exit(1);
}
}
module.exports = { generateVideo, renderTemplate };
if (require.main === module) {
main();
}