presentation-generator
Generate interactive HTML presentations with neobrutalism styling, ASCII art decorations, and Agency brand colors. Outputs HTML (interactive with navigation), PNG (individual slides via Playwright), and PDF. References brand-agency skill for colors and typography. Use when creating presentations, slide decks, pitch materials, or visual summaries.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install glebis-claude-skills-presentation-generator
Repository
Skill path: presentation-generator
Generate interactive HTML presentations with neobrutalism styling, ASCII art decorations, and Agency brand colors. Outputs HTML (interactive with navigation), PNG (individual slides via Playwright), and PDF. References brand-agency skill for colors and typography. Use when creating presentations, slide decks, pitch materials, or visual summaries.
Open repositoryBest for
Primary workflow: Design Product.
Technical facets: Full Stack, Designer, Testing.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: glebis.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install presentation-generator into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/glebis/claude-skills before adding presentation-generator to shared team environments
- Use presentation-generator for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: presentation-generator
description: Generate interactive HTML presentations with neobrutalism styling, ASCII art decorations, and Agency brand colors. Outputs HTML (interactive with navigation), PNG (individual slides via Playwright), and PDF. References brand-agency skill for colors and typography. Use when creating presentations, slide decks, pitch materials, or visual summaries.
---
# Presentation Generator
## Overview
Create stunning presentations in neobrutalism style with Agency brand colors. Generate interactive HTML presentations with smooth scrolling navigation, export individual slides as PNG, or create PDF documents.
**Output formats:**
- **HTML** - Interactive presentation with navigation dots, keyboard support, smooth scrolling
- **PNG** - Individual slide images via Playwright (1920x1080)
- **PDF** - Multi-page document via Playwright
## Quick Start
### 1. Create presentation from JSON/YAML content
```bash
node scripts/generate-presentation.js --input content.json --output presentation.html
```
### 2. Export to PNG slides
```bash
node scripts/export-slides.js presentation.html --format png --output ./slides/
```
### 3. Export to PDF
```bash
node scripts/export-slides.js presentation.html --format pdf --output presentation.pdf
```
## Brand Integration
This skill references `brand-agency` for consistent styling:
### Colors (from brand-agency)
| Color | Hex | Usage |
|-------|-----|-------|
| Primary (Orange) | `#e85d04` | Title slides, CTAs, accents |
| Secondary (Yellow) | `#ffd60a` | Highlights, accent slides |
| Accent (Blue) | `#3a86ff` | Info slides, links |
| Success (Green) | `#38b000` | Positive content |
| Error (Red) | `#d62828` | Warnings, emphasis |
| Foreground | `#000000` | Text, borders |
| Background | `#ffffff` | Light slides |
### Typography
- **Headings**: Geist ExtraBold (800)
- **Body**: EB Garamond
- **Code/ASCII**: Geist Mono
## Slide Types
### 1. Title Slide (`--title`)
Full-screen title with subtitle, colored background (primary/secondary/accent/dark).
### 2. Content Slide (`--content`)
Heading + body text + optional bullet list.
### 3. Two-Column Slide (`--two-col`)
Split layout for comparisons, text + image, before/after.
### 4. Code Slide (`--code`)
Dark background, syntax-highlighted code block with title.
### 5. Stats Slide (`--stats`)
Big numbers with labels (e.g., "14 templates | 4 formats | 1 skill").
### 6. Task Grid Slide (`--grid`)
Grid of cards with numbers, titles, descriptions.
### 7. ASCII Art Slide (`--ascii`)
Decorative slide with ASCII box-drawing characters.
### 8. Image Slide (`--image`)
Full-bleed or contained image with optional caption.
## ASCII Decorations
Use ASCII box-drawing characters for tech aesthetic:
```
Frames: ┌─────┐ ╔═════╗ ┏━━━━━┓
│ │ ║ ║ ┃ ┃
└─────┘ ╚═════╝ ┗━━━━━┛
Lines: ─ │ ═ ║ ━ ┃ ━━━ ───
Arrows: → ← ↑ ↓ ▶ ◀ ▲ ▼
Shapes: ● ○ ■ □ ▲ △ ★ ☆ ◆ ◇
Blocks: █ ▓ ▒ ░
```
## Content Format
### JSON format:
```json
{
"title": "Presentation Title",
"footer": "Company / Date",
"slides": [
{
"type": "title",
"bg": "primary",
"title": "Main Title",
"subtitle": "Subtitle text"
},
{
"type": "content",
"title": "Section Title",
"body": "Introduction paragraph",
"bullets": ["Point 1", "Point 2", "Point 3"]
},
{
"type": "code",
"title": "Code Example",
"language": "javascript",
"code": "const x = 42;"
},
{
"type": "stats",
"items": [
{"value": "14", "label": "templates"},
{"value": "4", "label": "formats"},
{"value": "∞", "label": "possibilities"}
]
}
]
}
```
### YAML format:
```yaml
title: Presentation Title
footer: Company / Date
slides:
- type: title
bg: primary
title: Main Title
subtitle: Subtitle text
- type: content
title: Section Title
body: Introduction paragraph
bullets:
- Point 1
- Point 2
```
## Interactive Features
Generated HTML includes:
- **Navigation dots** - Fixed right sidebar with clickable dots
- **Keyboard navigation** - Arrow keys, Page Up/Down, Home/End
- **Smooth scrolling** - CSS scroll-snap and smooth behavior
- **Intersection Observer** - Active slide highlighting
- **Responsive** - Works on various screen sizes (optimized for 16:9)
## Usage Examples
### Create workshop summary:
```bash
# Generate from today's session
node scripts/generate-presentation.js \
--title "Claude Code Lab — Day Summary" \
--footer "29.11.2025" \
--slides slides-content.json \
--output workshop-summary.html
```
### Quick presentation from markdown:
```bash
# Convert markdown outline to presentation
node scripts/md-to-slides.js notes.md --output presentation.html
```
### Batch export:
```bash
# Export all slides as PNGs
node scripts/export-slides.js presentation.html --format png --output ./export/
# Result: slide-01.png, slide-02.png, etc.
```
## File Structure
```
presentation-generator/
├── SKILL.md # This file
├── templates/
│ ├── base.html # Base HTML template
│ ├── slides/ # Slide type partials
│ │ ├── title.html
│ │ ├── content.html
│ │ ├── code.html
│ │ ├── stats.html
│ │ ├── two-col.html
│ │ ├── grid.html
│ │ └── ascii.html
│ └── styles.css # Neobrutalism styles
├── scripts/
│ ├── generate-presentation.js # Main generator
│ ├── export-slides.js # PNG/PDF export
│ └── md-to-slides.js # Markdown converter
└── output/ # Generated files
```
## Dependencies
- Node.js 18+
- Playwright (`npm install playwright`)
## Tips
1. **Use ASCII sparingly** - Great for tech/dev presentations, can feel dated otherwise
2. **Stick to brand colors** - Don't mix custom colors, use the 5-color palette
3. **Big text on title slides** - h1 should be 4-5rem minimum
4. **One idea per slide** - Neobrutalism works best with focused content
5. **Test interactivity** - Always preview HTML before exporting
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### scripts/generate-presentation.js
```javascript
#!/usr/bin/env node
/**
* Presentation Generator
*
* Generates interactive HTML presentations from JSON/YAML content
* with neobrutalism styling from brand-agency skill.
*/
const fs = require('fs');
const path = require('path');
// Template directory
const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
// Read CSS styles
const styles = fs.readFileSync(path.join(TEMPLATES_DIR, 'styles.css'), 'utf-8');
// Slide type renderers
const slideRenderers = {
// Title slide - big title with optional subtitle
title: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'primary'}">
${slide.label ? `<div class="label">${slide.label}</div>` : ''}
<h1>${slide.title}</h1>
${slide.subtitle ? `<p class="subtitle">${slide.subtitle}</p>` : ''}
${slide.footer ? `<div class="footer">${slide.footer}</div>` : ''}
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// Content slide - heading + body + optional bullets
content: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'light'}">
${slide.label ? `<div class="label">${slide.label}</div>` : ''}
<h2>${slide.title}</h2>
${slide.body ? `<p style="font-size: 1.3rem; max-width: 800px;">${slide.body}</p>` : ''}
${slide.bullets ? `
<ul style="margin-top: 1.5rem; font-size: 1.2rem;">
${slide.bullets.map(b => `<li>${b}</li>`).join('\n ')}
</ul>` : ''}
${slide.tags ? `
<div style="margin-top: 2rem;">
${slide.tags.map(t => `<span class="tag tag--${t.type || ''}">${t.text}</span>`).join('')}
</div>` : ''}
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// Two-column slide
'two-col': (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'light'}">
${slide.label ? `<div class="label">${slide.label}</div>` : ''}
${slide.title ? `<h2>${slide.title}</h2>` : ''}
<div class="two-col" style="margin-top: 2rem;">
<div>
${slide.left.title ? `<h3>${slide.left.title}</h3>` : ''}
${slide.left.body ? `<p>${slide.left.body}</p>` : ''}
${slide.left.bullets ? `
<ul>
${slide.left.bullets.map(b => `<li>${b}</li>`).join('\n ')}
</ul>` : ''}
${slide.left.code ? `<pre><code>${escapeHtml(slide.left.code)}</code></pre>` : ''}
</div>
<div>
${slide.right.title ? `<h3>${slide.right.title}</h3>` : ''}
${slide.right.body ? `<p>${slide.right.body}</p>` : ''}
${slide.right.bullets ? `
<ul>
${slide.right.bullets.map(b => `<li>${b}</li>`).join('\n ')}
</ul>` : ''}
${slide.right.code ? `<pre><code>${escapeHtml(slide.right.code)}</code></pre>` : ''}
${slide.right.ascii ? `<div class="ascii-box">${slide.right.ascii}</div>` : ''}
</div>
</div>
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// Code slide - dark background with code block
code: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--dark">
${slide.label ? `<div class="label" style="color: var(--color-success);">${slide.label}</div>` : ''}
<h2>${slide.title}</h2>
${slide.description ? `<p style="opacity: 0.8; margin-bottom: 1.5rem;">${slide.description}</p>` : ''}
<pre style="max-width: 900px;"><code>${highlightCode(slide.code, slide.language)}</code></pre>
${slide.tags ? `
<div style="margin-top: 1.5rem;">
${slide.tags.map(t => `<span class="tag tag--${t.type || ''}">${t.text}</span>`).join('')}
</div>` : ''}
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// Stats slide - big numbers with labels
stats: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'light'} centered">
${slide.label ? `<div class="label">${slide.label}</div>` : ''}
${slide.title ? `<h2>${slide.title}</h2>` : ''}
<div class="stats-row">
${slide.items.map(item => `
<div class="stat">
<div class="stat-value">${item.value}</div>
<div class="stat-label">${item.label}</div>
</div>`).join('')}
</div>
${slide.subtitle ? `<p class="subtitle" style="margin-top: 2rem;">${slide.subtitle}</p>` : ''}
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// Grid slide - task/feature cards
grid: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'light'}">
${slide.label ? `<div class="label">${slide.label}</div>` : ''}
<h2>${slide.title}</h2>
${slide.body ? `<p style="font-size: 1.2rem; max-width: 700px;">${slide.body}</p>` : ''}
<div class="task-grid">
${slide.items.map(item => `
<div class="task-card">
<div class="task-number">${item.number || ''}</div>
<div class="task-title">${item.title}</div>
<div class="task-desc">${item.desc || ''}</div>
${item.tags ? `
<div>
${item.tags.map(t => `<span class="tag tag--${t.type || ''}">${t.text}</span>`).join('')}
</div>` : ''}
</div>`).join('')}
</div>
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// ASCII art slide
ascii: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'dark'}">
${slide.label ? `<div class="label" style="color: var(--color-secondary);">${slide.label}</div>` : ''}
${slide.title ? `<h2>${slide.title}</h2>` : ''}
<div class="ascii-box" style="margin-top: 2rem; ${slide.bg === 'dark' ? 'background: rgba(255,255,255,0.1); color: var(--color-background);' : ''}">${slide.ascii}</div>
${slide.caption ? `<p style="margin-top: 1.5rem; font-family: var(--font-mono);">${slide.caption}</p>` : ''}
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// Terminal slide
terminal: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'muted'}">
${slide.label ? `<div class="label">${slide.label}</div>` : ''}
${slide.title ? `<h2>${slide.title}</h2>` : ''}
<div class="terminal" style="max-width: 800px; margin-top: 2rem;">
<div class="terminal-header">
<span class="terminal-btn terminal-btn--close"></span>
<span class="terminal-btn terminal-btn--minimize"></span>
<span class="terminal-btn terminal-btn--maximize"></span>
</div>
<div class="terminal-content">
${slide.lines.map(line => {
if (line.type === 'prompt') {
return `<div><span class="terminal-prompt">$ </span>${escapeHtml(line.text)}</div>`;
} else if (line.type === 'output') {
return `<div class="terminal-output">${escapeHtml(line.text)}</div>`;
} else if (line.type === 'comment') {
return `<div style="color: #888;"># ${escapeHtml(line.text)}</div>`;
}
return `<div>${escapeHtml(line.text || line)}</div>`;
}).join('\n ')}
</div>
</div>
${slide.note ? `<p style="margin-top: 1.5rem; font-family: var(--font-mono); opacity: 0.7;">${slide.note}</p>` : ''}
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// Image slide
image: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'light'}">
${slide.label ? `<div class="label">${slide.label}</div>` : ''}
${slide.title ? `<h2>${slide.title}</h2>` : ''}
<div class="image-container" style="max-width: ${slide.maxWidth || '800px'}; margin-top: 2rem;">
<img src="${slide.src}" alt="${slide.alt || slide.title || ''}" />
</div>
${slide.caption ? `<p style="margin-top: 1rem; font-family: var(--font-mono); font-size: 0.9rem; opacity: 0.7;">${slide.caption}</p>` : ''}
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// Quote slide
quote: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'secondary'} centered">
<div class="ascii-border" style="font-size: 2rem; margin-bottom: 1rem;">╔══════════════════════════════════════╗</div>
<blockquote style="font-size: 2rem; font-style: italic; max-width: 800px; line-height: 1.4;">
"${slide.quote}"
</blockquote>
<div class="ascii-border" style="font-size: 2rem; margin-top: 1rem;">╚══════════════════════════════════════╝</div>
${slide.author ? `<p style="margin-top: 2rem; font-family: var(--font-mono);">— ${slide.author}</p>` : ''}
<div class="slide-number">${index + 1} / ${total}</div>
</section>`,
// Comparison slide (before/after, pros/cons)
comparison: (slide, index, total) => `
<section id="slide-${index + 1}" class="slide slide--${slide.bg || 'muted'}">
${slide.label ? `<div class="label">${slide.label}</div>` : ''}
<h2>${slide.title}</h2>
<div class="two-col" style="margin-top: 2rem;">
<div class="card" style="border-color: ${slide.leftColor || 'var(--color-error)'};">
<h3 style="color: ${slide.leftColor || 'var(--color-error)'};">${slide.leftTitle || 'Before'}</h3>
${slide.left.map(item => `<p style="margin-top: 0.5rem;">- ${item}</p>`).join('')}
</div>
<div class="card" style="border-color: ${slide.rightColor || 'var(--color-success)'};">
<h3 style="color: ${slide.rightColor || 'var(--color-success)'};">${slide.rightTitle || 'After'}</h3>
${slide.right.map(item => `<p style="margin-top: 0.5rem;">+ ${item}</p>`).join('')}
</div>
</div>
<div class="slide-number">${index + 1} / ${total}</div>
</section>`
};
// Helper: escape HTML
function escapeHtml(str) {
if (!str) return '';
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Helper: basic syntax highlighting
function highlightCode(code, language) {
if (!code) return '';
let escaped = escapeHtml(code);
// Comments
escaped = escaped.replace(/(\/\/.*$|#.*$)/gm, '<span class="code-comment">$1</span>');
escaped = escaped.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="code-comment">$1</span>');
// Strings
escaped = escaped.replace(/(".*?"|'.*?'|`.*?`)/g, '<span class="code-string">$1</span>');
// Keywords
const keywords = ['const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'import', 'export', 'from', 'class', 'extends', 'async', 'await', 'try', 'catch', 'throw', 'new', 'this', 'true', 'false', 'null', 'undefined'];
keywords.forEach(kw => {
escaped = escaped.replace(new RegExp(`\\b(${kw})\\b`, 'g'), '<span class="code-keyword">$1</span>');
});
// Numbers
escaped = escaped.replace(/\b(\d+)\b/g, '<span class="code-number">$1</span>');
return escaped;
}
// Generate navigation dots
function generateNavigation(slideCount) {
let nav = '';
for (let i = 1; i <= slideCount; i++) {
nav += ` <a href="#slide-${i}" class="nav-dot${i === 1 ? ' active' : ''}"></a>\n`;
}
return nav;
}
// Main generator function
function generatePresentation(content, outputPath) {
const { title, lang, footer, slides } = content;
// Render all slides
const renderedSlides = slides.map((slide, index) => {
const renderer = slideRenderers[slide.type];
if (!renderer) {
console.warn(`Unknown slide type: ${slide.type}`);
return '';
}
// Add global footer to slides if not specified
if (!slide.footer && footer && slide.type === 'title') {
slide.footer = footer;
}
return renderer(slide, index, slides.length);
}).join('\n\n');
// Generate navigation
const navigation = generateNavigation(slides.length);
// Read base template
const baseTemplate = fs.readFileSync(path.join(TEMPLATES_DIR, 'base.html'), 'utf-8');
// Replace placeholders
const html = baseTemplate
.replace('{{title}}', title || 'Presentation')
.replace('{{lang}}', lang || 'en')
.replace('{{styles}}', styles)
.replace('{{navigation}}', navigation)
.replace('{{slides}}', renderedSlides);
// Write output
fs.writeFileSync(outputPath, html);
console.log(`Generated: ${outputPath}`);
return outputPath;
}
// CLI handling
if (require.main === module) {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Presentation Generator
======================
Usage:
node generate-presentation.js --input content.json --output presentation.html
node generate-presentation.js -i content.json -o presentation.html
Options:
--input, -i Input JSON/YAML file with presentation content
--output, -o Output HTML file path
--help, -h Show this help
Content format (JSON):
{
"title": "Presentation Title",
"lang": "en",
"footer": "Company / Date",
"slides": [
{ "type": "title", "bg": "primary", "title": "...", "subtitle": "..." },
{ "type": "content", "title": "...", "body": "...", "bullets": [...] },
{ "type": "code", "title": "...", "code": "...", "language": "javascript" },
{ "type": "stats", "items": [{ "value": "10", "label": "items" }] }
]
}
Slide types: title, content, two-col, code, stats, grid, ascii, terminal, image, quote, comparison
`);
process.exit(0);
}
const inputIndex = args.findIndex(a => a === '--input' || a === '-i');
const outputIndex = args.findIndex(a => a === '--output' || a === '-o');
if (inputIndex === -1) {
console.error('Error: --input is required');
process.exit(1);
}
const inputPath = args[inputIndex + 1];
const outputPath = outputIndex !== -1 ? args[outputIndex + 1] : inputPath.replace(/\.(json|yaml|yml)$/, '.html');
// Read input
let content;
try {
const inputContent = fs.readFileSync(inputPath, 'utf-8');
if (inputPath.endsWith('.yaml') || inputPath.endsWith('.yml')) {
// Simple YAML parsing (for basic cases)
// For full YAML support, use js-yaml package
console.error('YAML support requires js-yaml package. Please use JSON for now.');
process.exit(1);
} else {
content = JSON.parse(inputContent);
}
} catch (err) {
console.error(`Error reading input: ${err.message}`);
process.exit(1);
}
generatePresentation(content, outputPath);
}
module.exports = { generatePresentation };
```
### scripts/export-slides.js
```javascript
#!/usr/bin/env node
/**
* Export Slides
*
* Export presentation HTML to PNG slides, PDF, or video using Playwright.
*/
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
async function exportSlides(htmlPath, options = {}) {
const { format = 'png', output, width = 1920, height = 1080, duration = 6 } = options;
if (!fs.existsSync(htmlPath)) {
console.error(`File not found: ${htmlPath}`);
process.exit(1);
}
const absolutePath = path.resolve(htmlPath);
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 1,
});
const page = await context.newPage();
await page.goto(`file://${absolutePath}`, { waitUntil: 'networkidle' });
// Wait for fonts to load
await page.waitForTimeout(1500);
if (format === 'pdf') {
// Export as PDF
const pdfPath = output || htmlPath.replace('.html', '.pdf');
await page.pdf({
path: pdfPath,
width: `${width}px`,
height: `${height}px`,
printBackground: true,
preferCSSPageSize: true,
});
console.log(`Exported PDF: ${pdfPath}`);
} else if (format === 'png') {
// Export individual slides as PNGs
const outputDir = output || path.dirname(htmlPath);
const baseName = path.basename(htmlPath, '.html');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Get all slides
const slides = await page.$$('.slide');
console.log(`Found ${slides.length} slides`);
for (let i = 0; i < slides.length; i++) {
// Scroll to slide
await page.evaluate((index) => {
const slides = document.querySelectorAll('.slide');
slides[index].scrollIntoView({ behavior: 'instant' });
}, i);
await page.waitForTimeout(300);
// Screenshot the slide
const slideNum = String(i + 1).padStart(2, '0');
const pngPath = path.join(outputDir, `${baseName}-slide-${slideNum}.png`);
await page.screenshot({
path: pngPath,
clip: {
x: 0,
y: i * height,
width: width,
height: height,
},
});
console.log(`Exported: ${pngPath}`);
}
} else if (format === 'video' || format === 'webm') {
// Export as video with auto-scrolling slides
await browser.close();
const videoDir = path.dirname(output || htmlPath.replace('.html', '.webm'));
if (!fs.existsSync(videoDir)) {
fs.mkdirSync(videoDir, { recursive: true });
}
const videoBrowser = await chromium.launch();
// First, create a prep page WITHOUT video recording to pre-load fonts
const prepContext = await videoBrowser.newContext({
viewport: { width, height },
deviceScaleFactor: 1,
});
const prepPage = await prepContext.newPage();
await prepPage.goto(`file://${absolutePath}`, { waitUntil: 'networkidle' });
await prepPage.waitForTimeout(2000); // Wait for fonts
const slideCount = await prepPage.evaluate(() => {
return document.querySelectorAll('.slide').length;
});
console.log(`Recording video: ${slideCount} slides, ${duration}s per slide`);
console.log(`Total duration: ~${slideCount * duration}s`);
await prepPage.close();
await prepContext.close();
// Create the video recording context
const videoContext = await videoBrowser.newContext({
viewport: { width, height },
deviceScaleFactor: 1,
recordVideo: {
dir: videoDir,
size: { width, height },
},
});
const videoPage = await videoContext.newPage();
// Set video recording mode flag before loading
await videoPage.addInitScript(() => {
window.videoRecordingMode = true;
});
// Load presentation - elements are hidden by CSS in the template itself
await videoPage.goto(`file://${absolutePath}`, { waitUntil: 'networkidle' });
// Set up for video mode
await videoPage.evaluate(() => {
// Disable scroll-snap for smooth programmatic scrolling
document.documentElement.style.scrollSnapType = 'none';
document.documentElement.style.scrollBehavior = 'auto';
// Mark all slides as not animated
document.querySelectorAll('.slide').forEach(slide => {
slide.dataset.animated = 'false';
});
// Set first nav dot as active
const dots = document.querySelectorAll('.nav-dot');
if (dots[0]) dots[0].classList.add('active');
});
// Wait for fonts to fully load
await videoPage.waitForTimeout(500);
// Auto-scroll through slides with animation triggers
for (let i = 0; i < slideCount; i++) {
console.log(`Recording slide ${i + 1}/${slideCount}...`);
// Scroll to slide using window.scrollTo for reliable positioning
await videoPage.evaluate(({ index, viewportHeight }) => {
window.scrollTo({
top: index * viewportHeight,
behavior: 'instant'
});
// Update navigation dots manually
const dots = document.querySelectorAll('.nav-dot');
dots.forEach((dot, j) => {
dot.classList.toggle('active', j === index);
});
}, { index: i, viewportHeight: height });
// Brief pause to ensure scroll completed
await videoPage.waitForTimeout(200);
// Trigger animations for current slide
await videoPage.evaluate((index) => {
const slides = document.querySelectorAll('.slide');
const slide = slides[index];
// Call the animateSlide function if available
if (typeof animateSlide === 'function') {
animateSlide(slide);
}
}, i);
// Wait for animations to play + remaining slide duration
await videoPage.waitForTimeout(duration * 1000);
}
// Small pause at the end
await videoPage.waitForTimeout(1000);
// Close page to finalize video
await videoPage.close();
// Get the recorded video path
const video = videoPage.video();
if (video) {
const tempVideoPath = await video.path();
const finalVideoPath = output || htmlPath.replace('.html', '.webm');
// Move video to final destination
fs.renameSync(tempVideoPath, finalVideoPath);
console.log(`Exported video: ${finalVideoPath}`);
}
await videoBrowser.close();
return;
} else {
console.error(`Unknown format: ${format}. Use 'png', 'pdf', or 'video'.`);
}
await browser.close();
}
// CLI handling
if (require.main === module) {
const args = process.argv.slice(2);
if (args.includes('--help')) {
console.log(`
Export Slides
=============
Export presentation HTML to PNG slides, PDF, or video.
Usage:
node export-slides.js presentation.html --format png --output ./slides/
node export-slides.js presentation.html --format pdf --output output.pdf
node export-slides.js presentation.html --format video --output output.webm
Options:
--format, -f Output format: png, pdf, or video (default: png)
--output, -o Output path (directory for PNG, file for PDF/video)
--width, -w Slide width in pixels (default: 1920)
--height Slide height in pixels (default: 1080)
--duration, -d Seconds per slide for video (default: 6)
--help Show this help
Examples:
node export-slides.js deck.html -f png -o ./export/
node export-slides.js deck.html -f pdf -o deck.pdf
node export-slides.js deck.html -f video -o deck.webm -d 5
`);
process.exit(0);
}
const htmlPath = args.find(a => !a.startsWith('-'));
if (!htmlPath) {
console.error('Error: HTML file path is required');
process.exit(1);
}
const formatIndex = args.findIndex(a => a === '--format' || a === '-f');
const outputIndex = args.findIndex(a => a === '--output' || a === '-o');
const widthIndex = args.findIndex(a => a === '--width' || a === '-w');
const heightIndex = args.findIndex(a => a === '--height');
const durationIndex = args.findIndex(a => a === '--duration' || a === '-d');
const options = {
format: formatIndex !== -1 ? args[formatIndex + 1] : 'png',
output: outputIndex !== -1 ? args[outputIndex + 1] : null,
width: widthIndex !== -1 ? parseInt(args[widthIndex + 1]) : 1920,
height: heightIndex !== -1 ? parseInt(args[heightIndex + 1]) : 1080,
duration: durationIndex !== -1 ? parseInt(args[durationIndex + 1]) : 6,
};
exportSlides(htmlPath, options).catch(console.error);
}
module.exports = { exportSlides };
```