← back to Goodquestion Ai

scripts/generate-thumbnails.cjs

268 lines

/**
 * YouTube Thumbnail Generator for Agent Abrams
 * Generates professional cover images styled like top Claude Code YouTube videos
 * Uses sharp + SVG overlays for programmatic image creation
 *
 * Usage: node generate-thumbnails.cjs [--all | --video <name>]
 */
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');

const OUTDIR = path.join(__dirname, '..', 'dashboard-captures');
if (!fs.existsSync(OUTDIR)) fs.mkdirSync(OUTDIR, { recursive: true });

const WIDTH = 1280;
const HEIGHT = 720;

// Video thumbnail configs
const THUMBNAILS = {
  'parallel-agents': {
    title: 'PARALLEL AGENTS',
    subtitle: 'AT SCALE',
    stats: ['94K → 118K Records', '6 Agents • 15 Scrapers', '25% Growth in 4 Hours'],
    accentColor: '#FF6B35',
    gradientStart: '#0a0a1a',
    gradientMid: '#1a0a3a',
    gradientEnd: '#0a1a2a',
    icon: 'terminal',
    badge: 'LIVE DEMO'
  },
  'dashboard-walkthrough': {
    title: 'COMMAND CENTER',
    subtitle: 'DASHBOARD TOUR',
    stats: ['95 Vendors Tracked', '118K+ Products', 'Real-time Monitoring'],
    accentColor: '#00D4AA',
    gradientStart: '#0a0a1a',
    gradientMid: '#0a2a2a',
    gradientEnd: '#0a1a1a',
    icon: 'dashboard',
    badge: 'FULL TOUR'
  },
  'remote-control': {
    title: 'AUTONOMOUS',
    subtitle: 'AGENT CONTROL',
    stats: ['105 PM2 Processes', 'Zero Downtime', 'Self-Healing Agents'],
    accentColor: '#7B68EE',
    gradientStart: '#0a0a1a',
    gradientMid: '#1a0a2a',
    gradientEnd: '#0a0a2a',
    icon: 'remote',
    badge: 'AGENTS'
  },
  'blog-tour': {
    title: 'AI BUILDS',
    subtitle: 'ITS OWN BLOG',
    stats: ['Astro 5 + Node.js', 'Auto-publish Pipeline', 'goodquestion.ai'],
    accentColor: '#FFD700',
    gradientStart: '#0a0a1a',
    gradientMid: '#2a1a0a',
    gradientEnd: '#1a1a0a',
    icon: 'blog',
    badge: 'META'
  },
  'tracking-progress': {
    title: 'DATA PIPELINE',
    subtitle: 'FROM ZERO TO 118K',
    stats: ['100 Vendor Tables', '29 Cron Jobs', 'Fully Automated'],
    accentColor: '#00BFFF',
    gradientStart: '#0a0a1a',
    gradientMid: '#0a1a3a',
    gradientEnd: '#0a0a2a',
    icon: 'chart',
    badge: 'DEEP DIVE'
  }
};

function createTerminalIcon(color) {
  return `
    <g transform="translate(80, 80)">
      <rect x="0" y="0" width="200" height="140" rx="12" fill="#1a1a2e" stroke="${color}" stroke-width="2"/>
      <rect x="0" y="0" width="200" height="28" rx="12" fill="${color}" opacity="0.3"/>
      <circle cx="18" cy="14" r="5" fill="#ff5f57"/>
      <circle cx="36" cy="14" r="5" fill="#ffbd2e"/>
      <circle cx="54" cy="14" r="5" fill="#28ca42"/>
      <text x="20" y="55" font-family="monospace" font-size="14" fill="${color}">$ agent deploy</text>
      <text x="20" y="75" font-family="monospace" font-size="14" fill="#666">Running 6 agents...</text>
      <text x="20" y="95" font-family="monospace" font-size="14" fill="#28ca42">✓ 15 scrapers built</text>
      <text x="20" y="115" font-family="monospace" font-size="14" fill="#28ca42">✓ 118,902 records</text>
      <rect x="20" y="122" width="8" height="14" fill="${color}" opacity="0.8">
        <animate attributeName="opacity" values="0.8;0.2;0.8" dur="1.2s" repeatCount="1"/>
      </rect>
    </g>`;
}

function createDashboardIcon(color) {
  return `
    <g transform="translate(80, 80)">
      <rect x="0" y="0" width="200" height="140" rx="12" fill="#1a1a2e" stroke="${color}" stroke-width="2"/>
      <rect x="10" y="10" width="85" height="55" rx="6" fill="${color}" opacity="0.15" stroke="${color}" stroke-width="1"/>
      <text x="52" y="35" font-family="monospace" font-size="11" fill="${color}" text-anchor="middle">VENDORS</text>
      <text x="52" y="55" font-family="monospace" font-size="20" fill="white" text-anchor="middle" font-weight="bold">95</text>
      <rect x="105" y="10" width="85" height="55" rx="6" fill="${color}" opacity="0.15" stroke="${color}" stroke-width="1"/>
      <text x="147" y="35" font-family="monospace" font-size="11" fill="${color}" text-anchor="middle">PRODUCTS</text>
      <text x="147" y="55" font-family="monospace" font-size="18" fill="white" text-anchor="middle" font-weight="bold">118K</text>
      <rect x="10" y="75" width="180" height="55" rx="6" fill="${color}" opacity="0.1"/>
      <rect x="20" y="85" width="30" height="35" fill="${color}" opacity="0.6"/>
      <rect x="55" y="95" width="30" height="25" fill="${color}" opacity="0.4"/>
      <rect x="90" y="80" width="30" height="40" fill="${color}" opacity="0.8"/>
      <rect x="125" y="90" width="30" height="30" fill="${color}" opacity="0.5"/>
      <rect x="160" y="75" width="20" height="45" fill="${color}" opacity="0.7"/>
    </g>`;
}

function createChartIcon(color) {
  return `
    <g transform="translate(80, 80)">
      <rect x="0" y="0" width="200" height="140" rx="12" fill="#1a1a2e" stroke="${color}" stroke-width="2"/>
      <polyline points="20,120 50,100 80,110 110,70 140,50 170,30" fill="none" stroke="${color}" stroke-width="3"/>
      <circle cx="20" cy="120" r="4" fill="${color}"/>
      <circle cx="50" cy="100" r="4" fill="${color}"/>
      <circle cx="80" cy="110" r="4" fill="${color}"/>
      <circle cx="110" cy="70" r="4" fill="${color}"/>
      <circle cx="140" cy="50" r="4" fill="${color}"/>
      <circle cx="170" cy="30" r="4" fill="${color}"/>
      <text x="100" y="25" font-family="monospace" font-size="12" fill="white" text-anchor="middle">94K → 118K</text>
      <line x1="20" y1="130" x2="180" y2="130" stroke="#333" stroke-width="1"/>
      <line x1="15" y1="20" x2="15" y2="130" stroke="#333" stroke-width="1"/>
    </g>`;
}

function getIcon(type, color) {
  switch (type) {
    case 'terminal': return createTerminalIcon(color);
    case 'dashboard': return createDashboardIcon(color);
    case 'chart': return createChartIcon(color);
    default: return createTerminalIcon(color);
  }
}

function generateSVG(config) {
  const { title, subtitle, stats, accentColor, gradientStart, gradientMid, gradientEnd, icon, badge } = config;

  return `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${WIDTH}" height="${HEIGHT}" xmlns="http://www.w3.org/2000/svg">
  <defs>
    <!-- Main gradient -->
    <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" style="stop-color:${gradientStart}"/>
      <stop offset="50%" style="stop-color:${gradientMid}"/>
      <stop offset="100%" style="stop-color:${gradientEnd}"/>
    </linearGradient>

    <!-- Glow effect -->
    <radialGradient id="glow" cx="70%" cy="40%">
      <stop offset="0%" style="stop-color:${accentColor};stop-opacity:0.15"/>
      <stop offset="70%" style="stop-color:${accentColor};stop-opacity:0"/>
    </radialGradient>

    <!-- Grid pattern -->
    <pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
      <path d="M 40 0 L 0 0 0 40" fill="none" stroke="${accentColor}" stroke-width="0.3" opacity="0.15"/>
    </pattern>

    <!-- Scanline effect -->
    <pattern id="scanlines" width="4" height="4" patternUnits="userSpaceOnUse">
      <rect width="4" height="2" fill="white" opacity="0.02"/>
    </pattern>
  </defs>

  <!-- Background -->
  <rect width="${WIDTH}" height="${HEIGHT}" fill="url(#bg)"/>
  <rect width="${WIDTH}" height="${HEIGHT}" fill="url(#glow)"/>
  <rect width="${WIDTH}" height="${HEIGHT}" fill="url(#grid)"/>
  <rect width="${WIDTH}" height="${HEIGHT}" fill="url(#scanlines)"/>

  <!-- Accent line top -->
  <rect x="0" y="0" width="${WIDTH}" height="4" fill="${accentColor}"/>

  <!-- Icon area (left side) -->
  ${getIcon(icon, accentColor)}

  <!-- Badge -->
  <g transform="translate(90, 240)">
    <rect x="0" y="0" width="${badge.length * 14 + 24}" height="30" rx="4" fill="${accentColor}"/>
    <text x="${(badge.length * 14 + 24) / 2}" y="21" font-family="Arial, Helvetica, sans-serif" font-size="14" fill="white" text-anchor="middle" font-weight="bold" letter-spacing="2">${badge}</text>
  </g>

  <!-- Main title (right side) -->
  <text x="380" y="200" font-family="Arial, Helvetica, sans-serif" font-size="72" fill="white" font-weight="bold" letter-spacing="-1">${title}</text>
  <text x="380" y="270" font-family="Arial, Helvetica, sans-serif" font-size="56" fill="${accentColor}" font-weight="bold" letter-spacing="-1">${subtitle}</text>

  <!-- Divider line -->
  <line x1="380" y1="295" x2="1180" y2="295" stroke="${accentColor}" stroke-width="2" opacity="0.5"/>

  <!-- Stats -->
  ${stats.map((stat, i) => `
  <g transform="translate(395, ${340 + i * 55})">
    <rect x="0" y="0" width="6" height="35" rx="3" fill="${accentColor}" opacity="${1 - i * 0.2}"/>
    <text x="22" y="26" font-family="'Courier New', monospace" font-size="22" fill="#cccccc" letter-spacing="0.5">${stat}</text>
  </g>`).join('')}

  <!-- Agent Abrams branding -->
  <g transform="translate(80, 620)">
    <rect x="0" y="0" width="260" height="40" rx="8" fill="white" opacity="0.08"/>
    <text x="20" y="28" font-family="monospace" font-size="18" fill="white" opacity="0.9" font-weight="bold">AGENT ABRAMS</text>
  </g>

  <!-- goodquestion.ai URL -->
  <text x="1180" y="655" font-family="monospace" font-size="16" fill="${accentColor}" opacity="0.7" text-anchor="end">goodquestion.ai</text>

  <!-- Corner accents -->
  <line x1="${WIDTH - 60}" y1="20" x2="${WIDTH - 20}" y2="20" stroke="${accentColor}" stroke-width="2" opacity="0.6"/>
  <line x1="${WIDTH - 20}" y1="20" x2="${WIDTH - 20}" y2="60" stroke="${accentColor}" stroke-width="2" opacity="0.6"/>
  <line x1="20" y1="${HEIGHT - 20}" x2="60" y2="${HEIGHT - 20}" stroke="${accentColor}" stroke-width="2" opacity="0.6"/>
  <line x1="20" y1="${HEIGHT - 60}" x2="20" y2="${HEIGHT - 20}" stroke="${accentColor}" stroke-width="2" opacity="0.6"/>

  <!-- Decorative dots -->
  ${Array.from({length: 8}, (_, i) =>
    `<circle cx="${1100 + (i % 4) * 20}" cy="${50 + Math.floor(i / 4) * 20}" r="3" fill="${accentColor}" opacity="${0.2 + Math.random() * 0.3}"/>`
  ).join('')}
</svg>`;
}

async function generateThumbnail(name, config) {
  const svg = generateSVG(config);
  const svgBuffer = Buffer.from(svg);

  const outPath = path.join(OUTDIR, `thumbnail-${name}.png`);

  await sharp(svgBuffer)
    .resize(WIDTH, HEIGHT)
    .png({ quality: 95 })
    .toFile(outPath);

  const stats = fs.statSync(outPath);
  console.log(`  Generated: ${outPath} (${(stats.size / 1024).toFixed(0)} KB)`);
  return outPath;
}

async function main() {
  const args = process.argv.slice(2);
  const target = args.includes('--video') ? args[args.indexOf('--video') + 1] : null;
  const all = args.includes('--all') || !target;

  console.log('=== YouTube Thumbnail Generator ===\n');

  const targets = all ? Object.keys(THUMBNAILS) : [target];
  const generated = [];

  for (const name of targets) {
    if (!THUMBNAILS[name]) {
      console.error(`  Unknown video: ${name}`);
      continue;
    }
    const outPath = await generateThumbnail(name, THUMBNAILS[name]);
    generated.push(outPath);
  }

  console.log(`\nGenerated ${generated.length} thumbnails in ${OUTDIR}/`);
  console.log('Files:');
  generated.forEach(p => console.log(`  ${path.basename(p)}`));
}

main().catch(err => {
  console.error('Error:', err.message);
  process.exit(1);
});