← back to Goodquestion Ai

video-html/templates.js

545 lines

/**
 * templates.js — HTML slide template generators
 *
 * Each template function returns a complete standalone HTML string
 * at the specified dimensions (default 1920x1080).
 *
 * Design system:
 *   Background: #0B0B0F (near-black)
 *   Primary:    #00D4FF (electric cyan)
 *   Accent:     #FF6B35 (warm orange)
 *   Success:    #00E68C (neon green)
 *   Text:       #E8E8ED (off-white)
 *   Muted:      #6B7280 (steel gray)
 */

const COLORS = {
  bg: '#0B0B0F',
  primary: '#00D4FF',
  accent: '#FF6B35',
  success: '#00E68C',
  text: '#E8E8ED',
  muted: '#6B7280',
};

function baseCSS(width = 1920, height = 1080) {
  return `
    * { margin: 0; padding: 0; box-sizing: border-box; }
    html, body {
      width: ${width}px;
      height: ${height}px;
      overflow: hidden;
      background: ${COLORS.bg};
      color: ${COLORS.text};
      font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
      -webkit-font-smoothing: antialiased;
    }
    .slide {
      width: ${width}px;
      height: ${height}px;
      position: relative;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      overflow: hidden;
    }

    /* Animations */
    @keyframes fadeInUp {
      from { opacity: 0; transform: translateY(30px); }
      to   { opacity: 1; transform: translateY(0); }
    }
    @keyframes fadeIn {
      from { opacity: 0; }
      to   { opacity: 1; }
    }
    @keyframes growWidth {
      from { width: 0; }
    }
    @keyframes gradientShift {
      0%   { background-position: 0% 50%; }
      50%  { background-position: 100% 50%; }
      100% { background-position: 0% 50%; }
    }
    @keyframes pulseGlow {
      0%, 100% { text-shadow: 0 0 20px rgba(0,212,255,0.3); }
      50%      { text-shadow: 0 0 40px rgba(0,212,255,0.6), 0 0 80px rgba(0,212,255,0.2); }
    }
    @keyframes slideInLeft {
      from { opacity: 0; transform: translateX(-40px); }
      to   { opacity: 1; transform: translateX(0); }
    }
    @keyframes avatarFadeIn {
      0%   { opacity: 0; transform: scale(0.8); }
      15%  { opacity: 1; transform: scale(1); }
      85%  { opacity: 1; transform: scale(1); }
      100% { opacity: 0; transform: scale(0.9); }
    }

    .avatar-container {
      position: absolute;
      bottom: 40px;
      right: 40px;
      width: 200px;
      height: 200px;
      border-radius: 50%;
      overflow: hidden;
      border: 3px solid ${COLORS.primary};
      box-shadow: 0 0 30px rgba(0,212,255,0.3);
      animation: avatarFadeIn 3s ease-in-out forwards;
      z-index: 100;
    }
    .avatar-container img {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
  `;
}

function wrapHTML(title, bodyContent, width = 1920, height = 1080, extraCSS = '') {
  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=${width}, height=${height}">
  <title>${title}</title>
  <style>
    ${baseCSS(width, height)}
    ${extraCSS}
  </style>
</head>
<body>
  ${bodyContent}
</body>
</html>`;
}

function avatarHTML(avatarConfig) {
  if (!avatarConfig || !avatarConfig.image) return '';
  const size = avatarConfig.size || 200;
  const position = avatarConfig.position || 'bottom-right';
  const duration = avatarConfig.maxDuration || 3;

  let posCSS = 'bottom: 40px; right: 40px;';
  if (position === 'bottom-left') posCSS = 'bottom: 40px; left: 40px;';
  else if (position === 'top-right') posCSS = 'top: 40px; right: 40px;';
  else if (position === 'top-left') posCSS = 'top: 40px; left: 40px;';

  return `
    <div class="avatar-container" style="${posCSS} width: ${size}px; height: ${size}px; animation-duration: ${duration}s;">
      <img src="${avatarConfig.image}" alt="Avatar">
    </div>`;
}

// ============================================================
// TEMPLATE: hook — Full-screen title with animated gradient bg
// ============================================================
function hookSlide(data, avatarConfig = null, width = 1920, height = 1080) {
  const headline = data.headline || 'Untitled';
  const subtext = data.subtext || '';

  const extraCSS = `
    .slide-hook {
      background: linear-gradient(135deg, #0B0B0F 0%, #0D1B2A 25%, #1B2838 50%, #0D1B2A 75%, #0B0B0F 100%);
      background-size: 400% 400%;
      animation: gradientShift 8s ease infinite;
    }
    .hook-headline {
      font-size: ${width >= 1920 ? 80 : 60}px;
      font-weight: 800;
      text-align: center;
      max-width: 85%;
      line-height: 1.15;
      background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.text});
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      background-clip: text;
      animation: fadeInUp 1s ease-out 0.3s both;
    }
    .hook-subtext {
      font-size: ${width >= 1920 ? 32 : 24}px;
      color: ${COLORS.muted};
      margin-top: 24px;
      animation: fadeInUp 1s ease-out 0.7s both;
    }
    .hook-line {
      width: 120px;
      height: 4px;
      background: ${COLORS.primary};
      border-radius: 2px;
      margin-top: 32px;
      animation: growWidth 0.8s ease-out 1s both;
    }
  `;

  const body = `
    <div class="slide slide-hook">
      <h1 class="hook-headline">${headline}</h1>
      ${subtext ? `<p class="hook-subtext">${subtext}</p>` : ''}
      <div class="hook-line"></div>
      ${avatarConfig ? avatarHTML(avatarConfig) : ''}
    </div>`;

  return wrapHTML(headline, body, width, height, extraCSS);
}

// ============================================================
// TEMPLATE: big-number — 1-3 large animated counters
// ============================================================
function bigNumberSlide(data, avatarConfig = null, width = 1920, height = 1080) {
  const numbers = data.numbers || [];
  const count = numbers.length;
  const gap = count <= 2 ? 200 : 100;

  const extraCSS = `
    .slide-numbers {
      background: radial-gradient(ellipse at 50% 50%, #111827 0%, ${COLORS.bg} 70%);
    }
    .numbers-row {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: ${gap}px;
      flex-wrap: wrap;
    }
    .number-card {
      display: flex;
      flex-direction: column;
      align-items: center;
      opacity: 0;
      animation: fadeInUp 0.8s ease-out both;
    }
    ${numbers.map((_, i) => `.number-card:nth-child(${i + 1}) { animation-delay: ${0.3 + i * 0.3}s; }`).join('\n')}
    .number-value {
      font-size: ${width >= 1920 ? (count <= 2 ? 120 : 96) : 72}px;
      font-weight: 900;
      font-variant-numeric: tabular-nums;
      line-height: 1;
    }
    .number-label {
      font-size: ${width >= 1920 ? 24 : 18}px;
      color: ${COLORS.muted};
      margin-top: 12px;
      text-transform: uppercase;
      letter-spacing: 2px;
    }
    .number-bar {
      width: 80px;
      height: 4px;
      border-radius: 2px;
      margin-top: 16px;
      animation: growWidth 0.6s ease-out both;
    }
    ${numbers.map((_, i) => `.number-card:nth-child(${i + 1}) .number-bar { animation-delay: ${0.8 + i * 0.3}s; }`).join('\n')}
  `;

  const numberCards = numbers.map(n => {
    const prefix = n.prefix || '';
    const suffix = n.suffix || '';
    const color = n.color || COLORS.primary;
    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('');

  const counterScript = `
    <script>
      function animateCounters() {
        document.querySelectorAll('.counter').forEach(el => {
          const target = parseInt(el.dataset.target, 10);
          const prefix = el.dataset.prefix || '';
          const suffix = el.dataset.suffix || '';
          const duration = 1500;
          const start = performance.now();

          function update(now) {
            const elapsed = now - start;
            const progress = Math.min(elapsed / duration, 1);
            // Ease-out cubic
            const eased = 1 - Math.pow(1 - progress, 3);
            const current = Math.round(eased * target);
            el.textContent = prefix + current.toLocaleString() + suffix;
            if (progress < 1) requestAnimationFrame(update);
          }

          // Delay start to match fade-in
          setTimeout(() => requestAnimationFrame(update), 600);
        });
      }
      window.addEventListener('load', animateCounters);
    </script>`;

  const body = `
    <div class="slide slide-numbers">
      <div class="numbers-row">
        ${numberCards}
      </div>
      ${avatarConfig ? avatarHTML(avatarConfig) : ''}
    </div>
    ${counterScript}`;

  return wrapHTML('Numbers', body, width, height, extraCSS);
}

// ============================================================
// TEMPLATE: feature-list — Title + animated bullet points
// ============================================================
function featureListSlide(data, avatarConfig = null, width = 1920, height = 1080) {
  const title = data.title || '';
  const items = data.items || [];
  const titleColor = data.titleColor || COLORS.primary;

  const extraCSS = `
    .slide-features {
      background: radial-gradient(ellipse at 30% 30%, #111827 0%, ${COLORS.bg} 60%);
      padding: 80px 120px;
      align-items: flex-start;
      justify-content: center;
    }
    .features-title {
      font-size: ${width >= 1920 ? 56 : 40}px;
      font-weight: 800;
      color: ${titleColor};
      margin-bottom: 48px;
      animation: fadeInUp 0.7s ease-out 0.2s both;
    }
    .features-list {
      list-style: none;
      width: 100%;
      max-width: 1400px;
    }
    .feature-item {
      display: flex;
      align-items: center;
      gap: 20px;
      font-size: ${width >= 1920 ? 36 : 28}px;
      padding: 16px 0;
      opacity: 0;
      animation: slideInLeft 0.6s ease-out both;
    }
    ${items.map((_, i) => `.feature-item:nth-child(${i + 1}) { animation-delay: ${0.5 + i * 0.3}s; }`).join('\n')}
    .feature-dot {
      width: 14px;
      height: 14px;
      border-radius: 50%;
      flex-shrink: 0;
    }
    .feature-text {
      color: ${COLORS.text};
      line-height: 1.4;
    }
  `;

  const listItems = items.map(item => {
    const dotColor = item.color || COLORS.primary;
    const text = typeof item === 'string' ? item : item.text;
    return `
      <li class="feature-item">
        <span class="feature-dot" style="background: ${dotColor};"></span>
        <span class="feature-text">${text}</span>
      </li>`;
  }).join('');

  const body = `
    <div class="slide slide-features">
      <h2 class="features-title">${title}</h2>
      <ul class="features-list">
        ${listItems}
      </ul>
      ${avatarConfig ? avatarHTML(avatarConfig) : ''}
    </div>`;

  return wrapHTML(title, body, width, height, extraCSS);
}

// ============================================================
// TEMPLATE: comparison — Before/After metric comparison
// ============================================================
function comparisonSlide(data, avatarConfig = null, width = 1920, height = 1080) {
  const title = data.title || '';
  const before = data.before || { value: '0', label: 'Before' };
  const after = data.after || { value: '0', label: 'After' };
  const change = data.change || '';

  const extraCSS = `
    .slide-comparison {
      background: radial-gradient(ellipse at 50% 40%, #111827 0%, ${COLORS.bg} 70%);
    }
    .comparison-title {
      font-size: ${width >= 1920 ? 48 : 36}px;
      font-weight: 700;
      color: ${COLORS.text};
      margin-bottom: 64px;
      animation: fadeIn 0.6s ease-out 0.2s both;
    }
    .comparison-row {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 80px;
    }
    .comparison-side {
      display: flex;
      flex-direction: column;
      align-items: center;
      opacity: 0;
    }
    .comparison-before {
      animation: fadeInUp 0.7s ease-out 0.4s both;
    }
    .comparison-after {
      animation: fadeInUp 0.7s ease-out 0.8s both;
    }
    .comparison-value-before {
      font-size: ${width >= 1920 ? 80 : 60}px;
      font-weight: 800;
      color: ${COLORS.muted};
    }
    .comparison-value-after {
      font-size: ${width >= 1920 ? 100 : 76}px;
      font-weight: 900;
      color: ${COLORS.primary};
    }
    .comparison-label {
      font-size: 22px;
      color: ${COLORS.muted};
      margin-top: 12px;
      text-transform: uppercase;
      letter-spacing: 2px;
    }
    .comparison-arrow {
      font-size: 64px;
      color: ${COLORS.accent};
      opacity: 0;
      animation: fadeIn 0.5s ease-out 0.6s both;
    }
    .comparison-badge {
      margin-top: 48px;
      padding: 12px 32px;
      background: rgba(0, 230, 140, 0.15);
      border: 2px solid ${COLORS.success};
      border-radius: 40px;
      font-size: 28px;
      font-weight: 700;
      color: ${COLORS.success};
      opacity: 0;
      animation: fadeInUp 0.6s ease-out 1.2s both;
    }
  `;

  const body = `
    <div class="slide slide-comparison">
      ${title ? `<h2 class="comparison-title">${title}</h2>` : ''}
      <div class="comparison-row">
        <div class="comparison-side comparison-before">
          <div class="comparison-value-before">${before.value}</div>
          <div class="comparison-label">${before.label}</div>
        </div>
        <div class="comparison-arrow">&#x2192;</div>
        <div class="comparison-side comparison-after">
          <div class="comparison-value-after">${after.value}</div>
          <div class="comparison-label">${after.label}</div>
        </div>
      </div>
      ${change ? `<div class="comparison-badge">${change}</div>` : ''}
      ${avatarConfig ? avatarHTML(avatarConfig) : ''}
    </div>`;

  return wrapHTML(title || 'Comparison', body, width, height, extraCSS);
}

// ============================================================
// TEMPLATE: cta — Closing slide
// ============================================================
function ctaSlide(data, avatarConfig = null, width = 1920, height = 1080) {
  const brand = data.brand || 'Agent Abrams';
  const handle = data.handle || '@agentabrams';
  const tagline = data.tagline || 'One person. One server. Production-grade AI.';
  const url = data.url || 'goodquestion.ai';

  const extraCSS = `
    .slide-cta {
      background: linear-gradient(135deg, #0B0B0F 0%, #0D1B2A 40%, #1B2838 70%, #0B0B0F 100%);
      background-size: 300% 300%;
      animation: gradientShift 6s ease infinite;
    }
    .cta-brand {
      font-size: ${width >= 1920 ? 96 : 72}px;
      font-weight: 900;
      background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.accent});
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      background-clip: text;
      animation: fadeInUp 0.8s ease-out 0.3s both, pulseGlow 3s ease-in-out 1.5s infinite;
      /* pulseGlow won't show on gradient text, but adds to ambient feel */
    }
    .cta-handle {
      font-size: ${width >= 1920 ? 36 : 28}px;
      color: ${COLORS.primary};
      margin-top: 20px;
      animation: fadeInUp 0.7s ease-out 0.7s both;
    }
    .cta-url {
      font-size: ${width >= 1920 ? 28 : 22}px;
      color: ${COLORS.muted};
      margin-top: 12px;
      animation: fadeInUp 0.7s ease-out 0.9s both;
    }
    .cta-line {
      width: 160px;
      height: 3px;
      background: linear-gradient(90deg, ${COLORS.primary}, ${COLORS.accent});
      border-radius: 2px;
      margin-top: 40px;
      animation: growWidth 1s ease-out 1.1s both;
    }
    .cta-tagline {
      font-size: ${width >= 1920 ? 24 : 20}px;
      color: ${COLORS.muted};
      margin-top: 32px;
      font-style: italic;
      animation: fadeIn 1s ease-out 1.4s both;
    }
  `;

  const body = `
    <div class="slide slide-cta">
      <div class="cta-brand">${brand}</div>
      <div class="cta-handle">${handle}</div>
      <div class="cta-url">${url}</div>
      <div class="cta-line"></div>
      <div class="cta-tagline">${tagline}</div>
      ${avatarConfig ? avatarHTML(avatarConfig) : ''}
    </div>`;

  return wrapHTML(brand, body, width, height, extraCSS);
}

// ============================================================
// Template registry
// ============================================================
const TEMPLATES = {
  hook: hookSlide,
  'big-number': bigNumberSlide,
  'feature-list': featureListSlide,
  comparison: comparisonSlide,
  cta: ctaSlide,
};

function generateSlideHTML(templateName, data, avatarConfig = null, width = 1920, height = 1080) {
  const fn = TEMPLATES[templateName];
  if (!fn) {
    throw new Error(`Unknown template: "${templateName}". Available: ${Object.keys(TEMPLATES).join(', ')}`);
  }
  return fn(data, avatarConfig, width, height);
}

module.exports = { generateSlideHTML, TEMPLATES, COLORS };