← 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">→</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 };