← 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();
}