
Present
- 85 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Generate a self-contained HTML presentation with dual article/slides mode, ElevenLabs voiceover narration, optional images, and scroll-reveal animations.
About
Turns research or notes into one index.html with a readable article mode and a navigable narrated slides mode, using ElevenLabs TTS and configurable detail levels. A developer uses it to produce a shareable narrated briefing or deck from content.
- Dual article/slides output, deployable as a static folder
- Configurable slide count, detail level, voice, and images
Present by the numbers
- 85 all-time installs (skills.sh)
- Ranked #339 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill presentAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 85 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Generate a self-contained HTML presentation with dual article/slides mode, ElevenLabs voiceover narration, optional images, and scroll-reveal animations.
Files
Present — Narrated Interactive Presentations
Generate a self-contained HTML presentation with dual article/slides mode, ElevenLabs narration, optional GPT Image 2 illustrations, and scroll-reveal animations.
What This Skill Produces
A single index.html file (plus audio and optional image assets) that can be:
- Opened locally in a browser
- Deployed to Vercel, Netlify, or any static host
- Shared as a folder
The output has two modes the viewer can toggle between: 1. Article mode — long-form scrollable report with Tufte-inspired typography 2. Slides mode — navigable presentation with keyboard/click navigation and narrated audio playback
Quick Start
/present "AI adoption research for Arseny" --slides 12 --voice daniel --images risographOr with a file:
/present path/to/research.md --detail detailed --voice aliceParameters
| Parameter | Values | Default | Description |
|---|---|---|---|
--slides | 5-20 | 12 | Number of slides |
--detail | executive, standard, detailed | standard | Content depth |
--voice | ElevenLabs voice name | daniel | Narrator voice |
--images | style name or none | none | Image generation style |
--image-prompt | custom string | auto | Override image prompt prefix |
--output | path | ./presentation/ | Output directory |
--deploy | vercel project or none | none | Auto-deploy target |
--title | string | auto | Presentation title |
--no-audio | flag | false | Skip audio generation |
Detail Levels
- `executive` (5-7 slides): Key findings only. One stat slide, one recommendation slide, sources. Best for busy stakeholders who need the bottom line.
- `standard` (10-14 slides): Full narrative arc. Problem, evidence, analysis, recommendations, sources. The default for most presentations.
- `detailed` (15-20 slides): Deep dive. Includes methodology, multiple evidence sections, case studies, detailed recommendations with implementation steps.
Voice Options
Uses ElevenLabs API. The key must be available in ~/claude-skills/elevenlabs-tts/.env as ELEVENLABS_API_KEY.
Recommended voices for presentations:
- daniel — Steady Broadcaster, British, formal (default)
- alice — Clear Educator, British, professional
- matilda — Knowledgeable, American, upbeat
- brian — Deep Resonant, American, comforting
- george — Warm Storyteller, British, mature
Image Styles
When --images is set, the skill generates illustrations for key slides using GPT Image 2 (~/.claude/skills/gpt-image-2/scripts/gpt_image_2.py). Available styles:
risograph— Gerd Arntz isotype style, muted colors, sand textureeditorial— Magazine photography style, dramatic lightingblueprint— Technical drawing aesthetic, white on blueink— Black ink illustration, hand-drawn feelconstellation— Data visualization aesthetic, dots and lines- Custom: pass
--image-prompt "your style description"to override
Images are generated in --draft mode first (~$0.006/image). The skill decides which slides benefit from illustration (typically 3-5 out of 12).
Workflow
Step 1: Content Analysis
Read the input content (a topic description, a markdown file, vault notes, meeting transcript, or research). Identify:
- The core argument or narrative
- Key data points and statistics
- Natural section breaks
- Quotable findings with sources
Step 2: Slide Planning
Based on --detail and --slides, create a slide plan. Each slide needs:
Slide N: [Type] — [Title]
Content: [what appears on screen]
Narration: [what the voice says — always more than what's on screen]
Read time: [seconds for an average reader to absorb the visual content]
Image: [yes/no, with prompt if yes]Slide types: title, summary, stat, evidence, comparison, quote, framework, recommendation, case-study, sources
The narration script should be conversational and add context beyond what's displayed. It should NOT just read the slide text aloud — it should explain, connect, and elaborate. Target 15-30 seconds of narration per slide.
Step 3: Generate Audio
For each slide, generate narration using ElevenLabs:
python3 ~/.claude/skills/elevenlabs-tts/scripts/elevenlabs_tts.py \
--voice <voice_name> \
--text "<narration>" \
--output <output_dir>/audio/slide-<N>.mp3Or use the direct API via the script at scripts/generate_audio.py in this skill.
Also generate a transition sound (Rhodes chord) for slide-to-slide transitions.
After generation, get durations with ffprobe to calculate slide timing.
Step 4: Generate Images (if enabled)
For slides that benefit from illustration, generate images using GPT Image 2:
python3 ~/.claude/skills/gpt-image-2/scripts/gpt_image_2.py --draft --size 1536x1024 \
"<style prefix> <slide-specific prompt>" \
<output_dir>/images/<name>.pngTypically generate 3-5 images for a 12-slide deck. Choose slides where a visual metaphor strengthens the point — stat slides, concept slides, and the title slide are good candidates. Don't illustrate every slide.
Step 5: Build HTML
Use the template at assets/template.html as the base. The template includes:
- Typography: EB Garamond (body) + DM Sans (labels/numbers)
- Color palette: Configurable via CSS variables in
:root - Article mode: Tufte-inspired layout with executive summary box, stat cards, two-column sections, data tables
- Slides mode: Full-viewport slides with fade transitions, keyboard navigation (arrows, space), dot indicators
- Audio engine: Single reusable
<audio>element, slide-synced playback with progress bar, transition sounds between slides - Auto-hide controls: Top bar (mode switcher + audio) appears when cursor enters top 20% of viewport. Bottom nav appears in bottom 20%. Shift+. toggles always-show/always-hide/zone mode.
- Scroll-reveal animations: Intersection Observer-based fade-up for sections, staggered stat cards, animated counters, h2 rule-draw effect
- `prefers-reduced-motion`: All animations disabled when user prefers reduced motion
Populate the template by replacing placeholder sections with the actual slide and article content.
Step 6: Test
Open in browser using /real-browser or open <path>. Verify:
- [ ] Article mode renders correctly, images load
- [ ] Slides mode: all slides navigable, text fits within viewport
- [ ] Audio plays when play button is clicked
- [ ] Audio syncs to slide advancement (each slide waits for narration + read time)
- [ ] Transition sounds play between slides
- [ ] Auto-hide works for top and bottom bars
- [ ] Keyboard navigation (arrows, space) works in slide mode
Step 7: Deploy (if requested)
If --deploy is set, copy output to the target project's public/ folder and deploy:
cp -r <output_dir>/* <project_path>/public/<slug>/
cd <project_path> && vercel deploy --prod --yesHTML Architecture
Audio Sync Model
Each slide has three timing properties:
data-audio="slide-name"— maps to audio filedata-read-time="N"— seconds for reading the visual content
The audio engine calculates: slide_duration = max(audio_duration, read_time) + 2s. After narration ends, it waits for any remaining read time plus a 2-second buffer, plays a transition sound (1.8s), then advances to the next slide.
Avoiding AI-Looking Formatting
The following patterns read as AI-generated and should be avoided:
- Colored left-bar + bold heading + description blocks (finding cards)
- Large italic pull quotes with colored left border
- Uniform card grids with icon + heading + description
- Gradient text on metrics
Instead use:
- Natural prose paragraphs with inline emphasis
- Definition lists (
<dl>) for structured points - Tables for comparisons
- Direct statements woven into flowing text
Image Paths
Use absolute paths from the deployment root: /slug/images/name.png, not relative paths. Relative paths break when URLs load without trailing slashes.
Files
SKILL.md— This filescripts/generate_audio.py— ElevenLabs TTS batch generatorassets/template.html— Base HTML template with all CSS/JSreferences/slide-types.md— Detailed slide type specifications and examples
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{TITLE}}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--sand: #f5f0e8;
--ink: #2c2c2c;
--ink-light: #5a5a5a;
--accent-1: #c4756e;
--accent-2: #6b7c4e;
--accent-3: #5b7b8a;
--accent-4: #c9a84c;
--rule: #d4cfc4;
--bg: #faf8f3;
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 20px; -webkit-font-smoothing: antialiased; }
body { font-family: 'EB Garamond', Georgia, serif; background: var(--bg); color: var(--ink); line-height: 1.55; padding-top: 40px; }
/* ===== HERO ENTRANCE ===== */
.hero-label, .hero h1, .hero .subtitle, .hero-meta {
opacity: 0; transform: translateY(18px);
animation: heroIn 0.7s var(--ease-out-quart) forwards;
}
.hero-label { animation-delay: 0.1s; }
.hero h1 { animation-delay: 0.25s; }
.hero .subtitle { animation-delay: 0.45s; }
.hero-meta { animation-delay: 0.6s; }
@keyframes heroIn { to { opacity: 1; transform: translateY(0); } }
@keyframes slideIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
/* ===== SCROLL REVEAL ===== */
.reveal {
opacity: 0; transform: translateY(24px);
transition: opacity 0.6s var(--ease-out-quart), transform 0.6s var(--ease-out-quart);
}
.reveal.visible { opacity: 1; transform: translateY(0); }
.reveal-stagger > .reveal:nth-child(1) { transition-delay: 0s; }
.reveal-stagger > .reveal:nth-child(2) { transition-delay: 0.08s; }
.reveal-stagger > .reveal:nth-child(3) { transition-delay: 0.16s; }
.reveal-stagger > .reveal:nth-child(4) { transition-delay: 0.24s; }
/* ===== H2 RULE DRAW ===== */
h2 {
background-image: linear-gradient(var(--ink), var(--ink));
background-size: 0% 2px; background-position: bottom left; background-repeat: no-repeat;
padding-bottom: 0.5rem; transition: background-size 0.5s var(--ease-out-quart);
}
h2.visible { background-size: 100% 2px; }
/* ===== FIGURE FADE ===== */
.figure img { transition: opacity 0.5s var(--ease-out-quart), transform 0.5s var(--ease-out-quart); }
.figure:not(.visible) img { opacity: 0.4; transform: scale(0.97); }
.figure.visible img { opacity: 1; transform: scale(1); }
/* ===== REDUCED MOTION ===== */
@media (prefers-reduced-motion: reduce) {
.hero-label, .hero h1, .hero .subtitle, .hero-meta { animation: none; opacity: 1; transform: none; }
.reveal { opacity: 1; transform: none; transition: none; }
h2 { background-size: 100% 2px; transition: none; }
.figure img { opacity: 1; transform: none; transition: none; }
.slide { transition: none; }
}
/* ===== MODE BAR ===== */
.mode-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
background: var(--ink); color: white;
display: flex; align-items: center; justify-content: center; gap: 1rem;
padding: 0.5rem 1rem; font-family: 'DM Sans', sans-serif; font-size: 0.6rem;
letter-spacing: 0.1em; text-transform: uppercase;
transition: transform 0.35s var(--ease-out-quart), opacity 0.35s var(--ease-out-quart);
}
.mode-bar.hidden { transform: translateY(-100%); opacity: 0; pointer-events: none; }
.mode-bar button { background: none; border: 1px solid rgba(255,255,255,0.3); color: white; padding: 0.3em 1em; font-family: inherit; font-size: inherit; letter-spacing: inherit; text-transform: uppercase; cursor: pointer; border-radius: 2px; transition: all 0.2s; }
.mode-bar button.active { background: white; color: var(--ink); border-color: white; }
.mode-bar button:hover { border-color: white; }
.audio-controls { display: flex; align-items: center; gap: 0.6rem; margin-left: 1.5rem; padding-left: 1.5rem; border-left: 1px solid rgba(255,255,255,0.2); }
.audio-controls button { background: none; border: 1px solid rgba(255,255,255,0.3); color: white; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 2px; font-size: 14px; transition: all 0.2s; }
.audio-controls button:hover { border-color: white; }
.audio-controls button.playing { background: var(--accent-1); border-color: var(--accent-1); }
.audio-progress { width: 100px; height: 3px; background: rgba(255,255,255,0.2); border-radius: 2px; overflow: hidden; }
.audio-progress-fill { height: 100%; background: var(--accent-1); width: 0%; transition: width 0.3s; }
.audio-time { font-variant-numeric: tabular-nums; min-width: 3em; }
/* ===== ARTICLE MODE ===== */
.hero { min-height: 65vh; display: flex; flex-direction: column; justify-content: flex-end; padding: 4rem 2rem 3rem; background: var(--sand); border-bottom: 3px solid var(--ink); position: relative; overflow: hidden; }
.hero::before { content: ''; position: absolute; inset: 0; background: url("data:image/svg+xml,%3Csvg width='400' height='400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E"); pointer-events: none; }
.hero-inner { max-width: 1100px; margin: 0 auto; width: 100%; position: relative; z-index: 1; }
.hero-label { font-family: 'DM Sans', sans-serif; font-size: 0.65rem; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: var(--accent-3); margin-bottom: 1.5rem; }
.hero h1 { font-size: 3.8rem; line-height: 1.05; font-weight: 700; letter-spacing: -0.02em; max-width: 14em; margin-bottom: 1.2rem; }
.hero .subtitle { font-size: 1.35rem; font-style: italic; color: var(--ink-light); max-width: 32em; line-height: 1.4; }
.hero-meta { margin-top: 2rem; font-family: 'DM Sans', sans-serif; font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-light); display: flex; gap: 2rem; flex-wrap: wrap; }
.container { max-width: 1100px; margin: 0 auto; padding: 3rem 2rem; }
.exec-summary { background: var(--sand); border: 2px solid var(--ink); padding: 2.5rem; margin-bottom: 4rem; position: relative; }
.exec-summary::before { content: 'Executive Summary'; position: absolute; top: -0.6em; left: 1.5rem; background: var(--sand); padding: 0 0.6rem; font-family: 'DM Sans', sans-serif; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--accent-3); }
.exec-summary p { font-size: 1.15rem; line-height: 1.6; margin-bottom: 1rem; }
.exec-summary p:last-child { margin-bottom: 0; }
.section { margin-bottom: 4rem; }
.section-number { font-family: 'DM Sans', sans-serif; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--accent-1); margin-bottom: 0.5rem; }
h2 { font-size: 2.4rem; font-weight: 700; line-height: 1.1; letter-spacing: -0.015em; margin-bottom: 1.5rem; }
h3 { font-size: 1.4rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.8rem; }
p { font-size: 1rem; line-height: 1.6; margin-bottom: 1rem; max-width: 38em; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; margin: 2rem 0; }
.two-col p { max-width: none; }
.stat-card { background: var(--sand); padding: 1.8rem; border-left: 4px solid var(--accent-1); }
.stat-card .number { font-family: 'DM Sans', sans-serif; font-size: 3.2rem; font-weight: 700; line-height: 1; color: var(--accent-1); letter-spacing: -0.03em; }
.stat-card .label { font-size: 0.85rem; line-height: 1.35; margin-top: 0.5rem; color: var(--ink-light); }
.stat-card .source { font-family: 'DM Sans', sans-serif; font-size: 0.55rem; letter-spacing: 0.05em; text-transform: uppercase; color: var(--accent-3); margin-top: 0.6rem; }
.stat-card:nth-child(2) { border-left-color: var(--accent-2); }
.stat-card:nth-child(2) .number { color: var(--accent-2); }
.stat-card:nth-child(3) { border-left-color: var(--accent-3); }
.stat-card:nth-child(3) .number { color: var(--accent-3); }
.stat-card:nth-child(4) { border-left-color: var(--accent-4); }
.stat-card:nth-child(4) .number { color: var(--accent-4); }
.figure { margin: 2.5rem 0; }
.figure img { width: 100%; max-width: 600px; display: block; border: 1px solid var(--rule); }
.figure.full img { max-width: 100%; }
.figure.float-right { float: right; margin: 0 0 1.5rem 2.5rem; max-width: 340px; }
.figure.float-right img { max-width: 100%; }
.figure-caption { font-family: 'DM Sans', sans-serif; font-size: 0.6rem; letter-spacing: 0.05em; color: var(--ink-light); margin-top: 0.4rem; text-transform: uppercase; }
.data-table { width: 100%; border-collapse: collapse; margin: 2rem 0; font-size: 0.85rem; }
.data-table th { font-family: 'DM Sans', sans-serif; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; text-align: left; padding: 0.8rem 1rem; border-bottom: 2px solid var(--ink); color: var(--ink-light); }
.data-table td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--rule); vertical-align: top; line-height: 1.4; }
.data-table tr:last-child td { border-bottom: 2px solid var(--ink); }
.rec-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin: 2rem 0; }
.rec-card { padding: 1.8rem; border: 1px solid var(--rule); background: white; }
.rec-card .rec-number { font-family: 'DM Sans', sans-serif; font-size: 2rem; font-weight: 700; color: var(--rule); line-height: 1; margin-bottom: 0.5rem; }
.rec-card h4 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.6rem; }
.rec-card p { font-size: 0.85rem; color: var(--ink-light); margin-bottom: 0; max-width: none; }
.prose-list { margin: 1.5rem 0; }
.prose-list dt { font-weight: 600; font-size: 1rem; margin-top: 1rem; }
.prose-list dt:first-child { margin-top: 0; }
.prose-list dd { font-size: 0.9rem; color: var(--ink-light); margin-left: 0; margin-top: 0.2rem; }
.badge { display: inline-block; font-family: 'DM Sans', sans-serif; font-size: 0.5rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; padding: 0.2em 0.6em; border-radius: 2px; }
.badge-accent { background: var(--accent-2); color: white; }
.divider { border: none; border-top: 1px solid var(--rule); margin: 3rem 0; }
.footer { border-top: 2px solid var(--ink); padding: 2rem 0; margin-top: 4rem; }
.footer-inner { max-width: 1100px; margin: 0 auto; padding: 0 2rem; display: flex; justify-content: space-between; align-items: flex-start; gap: 2rem; flex-wrap: wrap; }
.footer-col h4 { font-family: 'DM Sans', sans-serif; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: var(--ink-light); margin-bottom: 0.8rem; }
.footer-col a { display: block; font-size: 0.75rem; color: var(--accent-3); text-decoration: none; line-height: 1.8; }
.footer-col a:hover { text-decoration: underline; }
/* ===== SLIDE MODE ===== */
body.slide-mode .article-content { display: none; }
body.slide-mode .slide-deck { display: flex; }
body:not(.slide-mode) .slide-deck { display: none; }
.slide-deck { position: fixed; inset: 0; top: 40px; flex-direction: column; align-items: center; justify-content: center; background: var(--bg); overflow: hidden; }
.slide {
position: absolute; inset: 0; top: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 3rem 5rem;
opacity: 0; display: none; transform: translateY(12px);
pointer-events: none;
}
.slide.active {
opacity: 1; display: flex; transform: translateY(0); pointer-events: auto;
animation: slideIn 0.4s var(--ease-out-quart);
}
.slide-inner { max-width: 1000px; width: 100%; }
.slide .slide-label { font-family: 'DM Sans', sans-serif; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--accent-3); margin-bottom: 1rem; }
.slide h2 { font-size: 3rem; line-height: 1.08; background: none; padding: 0; margin-bottom: 1.2rem; }
.slide p { font-size: 1.15rem; line-height: 1.6; max-width: 34em; }
.slide img { max-height: 380px; border: 1px solid var(--rule); }
.slide .slide-stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 2rem; margin: 1.5rem 0; }
.slide .slide-stat .number { font-family: 'DM Sans', sans-serif; font-size: 3.5rem; font-weight: 700; line-height: 1; }
.slide .slide-stat .label { font-size: 0.8rem; color: var(--ink-light); margin-top: 0.3rem; line-height: 1.3; }
.slide .slide-two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 2.5rem; margin-top: 1.5rem; }
.slide .slide-finding { margin-bottom: 1rem; }
.slide .slide-finding h4 { font-size: 1rem; font-weight: 600; margin-bottom: 0.2rem; }
.slide .slide-finding p { font-size: 0.85rem; color: var(--ink-light); margin-bottom: 0; }
.slide .slide-grid-3x2 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.2rem; margin-top: 1.2rem; }
.slide .slide-card { padding: 1.2rem; border: 1px solid var(--rule); background: white; }
.slide .slide-card .card-num { font-family: 'DM Sans', sans-serif; font-size: 1.5rem; font-weight: 700; color: var(--rule); line-height: 1; }
.slide .slide-card h4 { font-size: 0.9rem; font-weight: 600; margin: 0.3rem 0; }
.slide .slide-card p { font-size: 0.72rem; color: var(--ink-light); margin: 0; max-width: none; line-height: 1.4; }
.slide .sources-table { font-size: 0.78rem; width: 100%; }
.slide .sources-table td { padding: 0.4rem 0.8rem; border-bottom: 1px solid var(--rule); }
.slide .sources-table a { color: var(--accent-3); }
/* Slide nav */
.slide-nav {
position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); z-index: 100;
display: none; align-items: center; gap: 1.5rem;
font-family: 'DM Sans', sans-serif;
transition: transform 0.35s var(--ease-out-quart), opacity 0.35s var(--ease-out-quart);
}
body.slide-mode .slide-nav { display: flex; }
.slide-nav.hidden { transform: translateX(-50%) translateY(120%); opacity: 0; pointer-events: none; }
.slide-nav button { background: none; border: none; color: var(--ink); cursor: pointer; font-size: 2rem; line-height: 1; padding: 0.3em 0.5em; border-radius: 3px; transition: background 0.2s, color 0.2s; }
.slide-nav button:hover { background: var(--sand); }
.slide-dots { display: flex; gap: 7px; }
.slide-dots span { width: 9px; height: 9px; border-radius: 50%; background: var(--rule); transition: all 0.3s var(--ease-out-quart); cursor: pointer; }
.slide-dots span:hover { background: var(--ink-light); }
.slide-dots span.active { background: var(--accent-1); transform: scale(1.4); }
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
html { font-size: 17px; }
.hero h1 { font-size: 2.4rem; }
.two-col, .rec-grid { grid-template-columns: 1fr; }
.figure.float-right { float: none; max-width: 100%; margin: 2rem 0; }
.hero-meta { flex-direction: column; gap: 0.5rem; }
.footer-inner { flex-direction: column; }
.stat-card .number { font-size: 2.4rem; }
.slide { padding: 2rem 1.5rem; }
.slide .slide-stat-row { grid-template-columns: 1fr 1fr; }
.slide .slide-grid-3x2 { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<!-- MODE BAR -->
<div class="mode-bar hidden">
<button id="btn-article" class="active" onclick="setMode('article')" aria-label="Switch to article mode">Article</button>
<button id="btn-slides" onclick="setMode('slides')" aria-label="Switch to slides mode">Slides</button>
<div class="audio-controls">
<button id="btn-play" onclick="toggleAudio()" title="Play narration">▶</button>
<div class="audio-progress"><div class="audio-progress-fill" id="progress-fill"></div></div>
<span class="audio-time" id="audio-time">0:00</span>
</div>
</div>
<!-- ===== ARTICLE CONTENT ===== -->
<div class="article-content">
{{ARTICLE_CONTENT}}
</div>
<!-- ===== SLIDE DECK ===== -->
<div class="slide-deck">
{{SLIDE_CONTENT}}
</div>
<!-- Slide navigation -->
<div class="slide-nav hidden">
<button onclick="prevSlide()" title="Previous slide">←</button>
<div class="slide-dots" id="slide-dots"></div>
<button onclick="nextSlide()" title="Next slide">→</button>
</div>
<script>
// ===== ZONE-BASED BAR VISIBILITY =====
let barVisMode = 'zone';
const modeBar = document.querySelector('.mode-bar');
const slideNav = document.querySelector('.slide-nav');
let barHideTimer = null;
let navHideTimer = null;
function updateBarVis(mouseY) {
if (barVisMode === 'show') { modeBar.classList.remove('hidden'); slideNav.classList.remove('hidden'); return; }
if (barVisMode === 'hide') { modeBar.classList.add('hidden'); slideNav.classList.add('hidden'); return; }
const vh = window.innerHeight;
if (mouseY <= vh * 0.2) {
modeBar.classList.remove('hidden');
clearTimeout(barHideTimer);
barHideTimer = setTimeout(() => modeBar.classList.add('hidden'), 2000);
}
if (mouseY >= vh * 0.8 && document.body.classList.contains('slide-mode')) {
slideNav.classList.remove('hidden');
clearTimeout(navHideTimer);
navHideTimer = setTimeout(() => slideNav.classList.add('hidden'), 2000);
}
}
document.addEventListener('mousemove', e => updateBarVis(e.clientY));
document.addEventListener('touchstart', e => updateBarVis(e.touches[0].clientY));
document.addEventListener('focusin', () => { modeBar.classList.remove('hidden'); clearTimeout(barHideTimer); barHideTimer = setTimeout(() => { if (barVisMode === 'zone') modeBar.classList.add('hidden'); }, 3000); });
modeBar.addEventListener('mouseenter', () => { clearTimeout(barHideTimer); modeBar.classList.remove('hidden'); });
modeBar.addEventListener('mouseleave', () => { barHideTimer = setTimeout(() => { if (barVisMode === 'zone') modeBar.classList.add('hidden'); }, 1500); });
slideNav.addEventListener('mouseenter', () => { clearTimeout(navHideTimer); slideNav.classList.remove('hidden'); });
slideNav.addEventListener('mouseleave', () => { navHideTimer = setTimeout(() => { if (barVisMode === 'zone') slideNav.classList.add('hidden'); }, 1500); });
document.addEventListener('keydown', e => {
if (e.shiftKey && e.key === '>') { e.preventDefault(); barVisMode = barVisMode === 'zone' ? 'show' : barVisMode === 'show' ? 'hide' : 'zone'; updateBarVis(-1); }
});
// ===== MODE =====
function setMode(mode) {
document.body.classList.toggle('slide-mode', mode === 'slides');
document.getElementById('btn-article').classList.toggle('active', mode === 'article');
document.getElementById('btn-slides').classList.toggle('active', mode === 'slides');
if (mode === 'slides') goToSlide(currentSlide);
}
// ===== SLIDES =====
const slides = document.querySelectorAll('.slide');
const totalSlides = slides.length;
let currentSlide = 0;
const dotsContainer = document.getElementById('slide-dots');
for (let i = 0; i < totalSlides; i++) {
const dot = document.createElement('span');
if (i === 0) dot.classList.add('active');
dot.addEventListener('click', () => goToSlide(i));
dotsContainer.appendChild(dot);
}
function goToSlide(n, fromAudio) {
slides.forEach(s => s.classList.remove('active'));
slides[n].classList.add('active');
document.querySelectorAll('.slide-dots span').forEach((d, i) => d.classList.toggle('active', i === n));
currentSlide = n;
if (!fromAudio && isPlaying) stopAudio();
}
function nextSlide() { if (currentSlide < totalSlides - 1) goToSlide(currentSlide + 1); }
function prevSlide() { if (currentSlide > 0) goToSlide(currentSlide - 1); }
document.addEventListener('keydown', e => {
if (!document.body.classList.contains('slide-mode')) return;
if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); nextSlide(); }
if (e.key === 'ArrowLeft') { e.preventDefault(); prevSlide(); }
});
// ===== AUDIO ENGINE =====
const audioBase = '{{AUDIO_BASE}}';
const audioDurations = {{AUDIO_DURATIONS}};
const hasAudio = Object.keys(audioDurations).length > 0;
const totalAudioDur = Object.values(audioDurations).reduce((a, b) => a + b, 0);
if (!hasAudio) document.querySelector('.audio-controls').style.display = 'none';
const slideData = [];
slides.forEach(s => { slideData.push({ audioId: s.dataset.audio, readTime: parseInt(s.dataset.readTime) || 10 }); });
let narrationEl = null;
let transitionEl = null;
let audioUnlocked = false;
let isPlaying = false;
let autoplayTimer = null;
let progressInterval = null;
function unlockAudio() {
if (audioUnlocked) return;
narrationEl = new Audio();
narrationEl.preload = 'auto';
transitionEl = new Audio();
transitionEl.preload = 'auto';
transitionEl.volume = 0.35;
transitionEl.src = audioBase + 'transition.mp3';
const ctx = new (window.AudioContext || window.webkitAudioContext)();
ctx.resume().then(() => { audioUnlocked = true; });
}
function toggleAudio() { unlockAudio(); if (isPlaying) stopAudio(); else startAudio(); }
function startAudio() {
if (!narrationEl) unlockAudio();
isPlaying = true;
document.getElementById('btn-play').classList.add('playing');
document.getElementById('btn-play').innerHTML = '▮▮';
if (!document.body.classList.contains('slide-mode')) setMode('slides');
playSlideAudio(currentSlide);
}
function stopAudio() {
isPlaying = false;
document.getElementById('btn-play').classList.remove('playing');
document.getElementById('btn-play').innerHTML = '▶';
if (narrationEl) narrationEl.pause();
if (autoplayTimer) { clearTimeout(autoplayTimer); autoplayTimer = null; }
if (progressInterval) { clearInterval(progressInterval); progressInterval = null; }
}
function playSlideAudio(slideIndex) {
if (!isPlaying || slideIndex >= totalSlides || !narrationEl) { stopAudio(); return; }
goToSlide(slideIndex, true);
const entry = slideData[slideIndex];
const audioDur = audioDurations[entry.audioId] || 20;
const src = audioBase + entry.audioId + '.mp3';
narrationEl.pause();
narrationEl.src = src;
narrationEl.load();
let elapsed = 0;
for (let i = 0; i < slideIndex; i++) elapsed += audioDurations[slideData[i].audioId] || 20;
if (progressInterval) clearInterval(progressInterval);
progressInterval = setInterval(() => {
if (!isPlaying || !narrationEl) { clearInterval(progressInterval); return; }
const cur = elapsed + (narrationEl.currentTime || 0);
document.getElementById('progress-fill').style.width = (cur / totalAudioDur * 100) + '%';
const mins = Math.floor(cur / 60);
const secs = Math.floor(cur % 60);
document.getElementById('audio-time').textContent = mins + ':' + (secs < 10 ? '0' : '') + secs;
}, 250);
narrationEl.onended = () => {
clearInterval(progressInterval);
const extraRead = Math.max(0, entry.readTime - audioDur);
autoplayTimer = setTimeout(() => {
if (!isPlaying) return;
if (slideIndex < totalSlides - 1) {
transitionEl.currentTime = 0;
transitionEl.play().catch(() => {});
autoplayTimer = setTimeout(() => { if (isPlaying) playSlideAudio(slideIndex + 1); }, 1800);
} else stopAudio();
}, (extraRead + 2) * 1000);
};
narrationEl.onerror = () => {
console.error('Audio load error:', src, narrationEl.error);
document.getElementById('audio-time').textContent = 'ERR';
};
narrationEl.oncanplay = function oncp() {
narrationEl.oncanplay = null;
narrationEl.play().then(() => {
console.log('Playing:', entry.audioId);
}).catch(err => {
console.error('Play failed:', err.name, err.message);
document.getElementById('audio-time').textContent = 'ERR';
autoplayTimer = setTimeout(() => {
if (isPlaying && slideIndex < totalSlides - 1) playSlideAudio(slideIndex + 1);
else stopAudio();
}, (audioDur + 2) * 1000);
});
};
setTimeout(() => {
if (narrationEl.readyState < 2 && isPlaying) {
console.warn('Audio not ready after 5s, skipping:', src);
if (slideIndex < totalSlides - 1) playSlideAudio(slideIndex + 1);
else stopAudio();
}
}, 5000);
}
// ===== SCROLL REVEAL =====
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelectorAll('.article-content .exec-summary, .article-content .section, .article-content .stat-card, .article-content .figure, .article-content .rec-card, .article-content .data-table').forEach(el => el.classList.add('reveal'));
document.querySelectorAll('.three-col, .rec-grid').forEach(el => el.classList.add('reveal-stagger'));
document.querySelectorAll('.article-content h2').forEach(el => el.classList.add('reveal'));
const revealObserver = new IntersectionObserver(entries => {
entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('visible'); revealObserver.unobserve(entry.target); } });
}, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' });
document.querySelectorAll('.reveal').forEach(el => revealObserver.observe(el));
function animateCounter(el, target, suffix) {
const dur = 1200, start = performance.now();
const isApprox = target.toString().startsWith('~');
const num = parseFloat(target.toString().replace(/[^0-9.]/g, ''));
function tick(now) {
const t = Math.min((now - start) / dur, 1);
const eased = 1 - Math.pow(1 - t, 4);
el.textContent = (isApprox ? '~' : '') + Math.round(eased * num) + suffix;
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
const statObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target, text = el.textContent.trim(), match = text.match(/^(~?)(\d+)(%?)$/);
if (match) { el.textContent = match[1] + '0' + match[3]; setTimeout(() => animateCounter(el, match[1] + match[2], match[3]), 200); }
statObserver.unobserve(el);
}
});
}, { threshold: 0.5 });
document.querySelectorAll('.article-content .stat-card .number').forEach(el => statObserver.observe(el));
}
</script>
</body>
</html>
Present
Generate interactive HTML presentations with professional voiceover narration synced to slides.
What it does
Takes content (research, notes, a topic) and produces a self-contained HTML presentation with:
- Dual mode — Article (scrollable long-form) and Slides (navigable deck)
- ElevenLabs narration — Professional voiceover synced to slide transitions
- GPT Image 2 illustrations — Optional risograph/editorial/custom style images
- Scroll-reveal animations — Intersection Observer-based, counter animations, rule-draw effects
- Auto-hide controls — Zone-triggered UI (top 20% / bottom 20%), Shift+. toggle
- Keyboard navigation — Arrow keys, spacebar, dot indicators
- Reduced motion — Full
prefers-reduced-motionsupport
Usage
/present "Topic or content" --slides 12 --voice daniel --images risographParameters
| Parameter | Values | Default | Description |
|---|---|---|---|
--slides | 5-20 | 12 | Number of slides |
--detail | executive, standard, detailed | standard | Content depth |
--voice | ElevenLabs voice name | daniel | Narrator voice |
--images | style name or none | none | Image generation style |
--image-prompt | custom string | auto | Override image prompt prefix |
--output | path | ./presentation/ | Output directory |
--deploy | vercel project or none | none | Auto-deploy target |
--title | string | auto | Presentation title |
--no-audio | flag | false | Skip audio generation |
Detail levels
- executive (5-7 slides) — Key findings only
- standard (10-14 slides) — Full narrative arc
- detailed (15-20 slides) — Deep dive with methodology and case studies
Voices
daniel— British broadcaster (default)alice— British educatormatilda— American professionalbrian— American, deep and resonantgeorge— British storyteller
Image styles
risograph— Gerd Arntz isotype, muted colors, sand textureeditorial— Magazine photographyblueprint— Technical drawing, white on blueink— Black ink illustrationconstellation— Data viz aesthetic- Custom:
--image-prompt "your style"
Requirements
- ElevenLabs API key in
~/claude-skills/elevenlabs-tts/.env - Python
requestslibrary - GPT Image 2 skill (for
--images) - ffprobe (for audio duration detection)
Architecture
present/
├── SKILL.md — Skill instructions
├── README.md — This file
├── scripts/
│ └── generate_audio.py — ElevenLabs batch TTS + transition sound generator
├── assets/
│ └── template.html — Base HTML template (CSS + JS + placeholders)
└── references/
└── slide-types.md — Slide type specs with HTML examplesTemplate placeholders
The template uses these placeholders that get replaced during generation:
{{TITLE}}— Page title{{ARTICLE_CONTENT}}— Full article HTML (hero + sections){{SLIDE_CONTENT}}— All slide divs{{AUDIO_BASE}}— Path prefix for audio files{{AUDIO_DURATIONS}}— JSON object of slide-id → duration in seconds
Audio sync model
Each slide has data-audio (file ID) and data-read-time (seconds). The engine calculates slide_duration = max(audio_duration + 2s, read_time), ensuring the viewer has enough time to read even if narration finishes early.
License
MIT
Slide Types Reference
Each slide type has a specific HTML structure within the <div class="slide"> wrapper.
title
The opening slide. Large title, subtitle, meta info.
<div class="slide active" data-slide="0" data-audio="slide-title" data-read-time="8">
<div class="slide-inner">
<div class="slide-label">Category Label</div>
<h2 style="font-size:3.8rem;">Main Title Here</h2>
<p style="font-style:italic;color:var(--ink-light);font-size:1.3rem;margin-top:1rem;">Subtitle or tagline</p>
<p style="font-family:'DM Sans';font-size:0.6rem;letter-spacing:0.1em;text-transform:uppercase;color:var(--ink-light);margin-top:2rem;">Date • Source count • Key sources</p>
</div>
</div>summary
Executive summary with key takeaway. Large text, highlighted numbers.
<div class="slide" data-slide="1" data-audio="slide-exec" data-read-time="18">
<div class="slide-inner">
<div class="slide-label">Executive Summary</div>
<p style="font-size:1.5rem;line-height:1.45;max-width:30em;">Main finding with <strong style="color:var(--accent-1)">highlighted stat</strong> inline.</p>
<p style="font-size:1.2rem;margin-top:1.5rem;max-width:34em;color:var(--ink-light)">Supporting context paragraph.</p>
</div>
</div>stat
Big numbers in a row. 2-4 statistics with labels.
<div class="slide" data-slide="2" data-audio="slide-numbers" data-read-time="15">
<div class="slide-inner">
<div class="slide-label">Section Label</div>
<h2>Section Title</h2>
<div class="slide-stat-row">
<div class="slide-stat"><div class="number" style="color:var(--accent-1)">39%</div><div class="label">Description</div></div>
<div class="slide-stat"><div class="number" style="color:var(--accent-2)">67%</div><div class="label">Description</div></div>
<!-- more stats... -->
</div>
</div>
</div>evidence
Text + image side by side. For presenting findings with supporting visuals.
<div class="slide" data-slide="3" data-audio="slide-gap" data-read-time="14">
<div class="slide-inner" style="display:grid;grid-template-columns:1fr 1fr;gap:3rem;align-items:center;">
<div>
<div class="slide-label">Label</div>
<h2 style="font-size:2.2rem;">Heading</h2>
<p>Body text with <em>emphasis</em> as needed.</p>
</div>
<img src="/slug/images/image.png" alt="Description" style="width:100%;max-height:400px;object-fit:contain;">
</div>
</div>comparison
Two images or concepts side by side.
<div class="slide" data-slide="4" data-audio="slide-compare" data-read-time="16">
<div class="slide-inner">
<div class="slide-label">Section Label</div>
<h2>Comparison Title</h2>
<div class="slide-two-col">
<img src="/slug/images/left.png" alt="Option A" style="width:100%;max-height:280px;object-fit:contain;border:1px solid var(--rule);">
<img src="/slug/images/right.png" alt="Option B" style="width:100%;max-height:280px;object-fit:contain;border:1px solid var(--rule);">
</div>
<p style="font-size:1.2rem;font-weight:600;margin-top:1rem;">Key takeaway statement.</p>
</div>
</div>framework
Two-column layout with parallel lists (problems/solutions, before/after, etc.)
<div class="slide" data-slide="5" data-audio="slide-framework" data-read-time="16">
<div class="slide-inner">
<div class="slide-label">Section Label</div>
<h2>Framework Title</h2>
<p style="margin-bottom:1.5rem;">Brief context.</p>
<div class="slide-two-col">
<div>
<h3 style="font-size:1.1rem;margin-top:0;">Column A</h3>
<div class="slide-finding"><h4>Point 1</h4><p>Detail</p></div>
<div class="slide-finding"><h4>Point 2</h4><p>Detail</p></div>
</div>
<div>
<h3 style="font-size:1.1rem;margin-top:0;">Column B</h3>
<div class="slide-finding"><h4>Point 1</h4><p>Detail</p></div>
<div class="slide-finding"><h4>Point 2</h4><p>Detail</p></div>
</div>
</div>
</div>
</div>quote
A single powerful statement. Large text, attribution.
<div class="slide" data-slide="6" data-audio="slide-quote" data-read-time="14">
<div class="slide-inner">
<div class="slide-label">Key Insight</div>
<h2 style="font-size:2.6rem;max-width:18em;">The quote or key statement goes here.</h2>
<p style="font-size:1.2rem;margin-top:1.5rem;max-width:30em;color:var(--ink-light)">Supporting context. — Attribution</p>
</div>
</div>recommendation
Grid of 4-6 action items.
<div class="slide" data-slide="9" data-audio="slide-recs" data-read-time="18">
<div class="slide-inner">
<div class="slide-label">Playbook</div>
<h2 style="font-size:2rem;">Recommendations Title</h2>
<div class="slide-grid-3x2">
<div class="slide-card"><div class="card-num">01</div><h4>Title</h4><p>Description</p></div>
<div class="slide-card"><div class="card-num">02</div><h4>Title</h4><p>Description</p></div>
<!-- 4-6 cards total -->
</div>
</div>
</div>case-study
Narrative with supporting detail. Text-heavy with optional image.
Use the evidence layout with longer text in the left column.
sources
Grid of source links. Final slide.
<div class="slide" data-slide="11" data-audio="slide-sources" data-read-time="12">
<div class="slide-inner">
<div class="slide-label">Section Label</div>
<h2 style="font-size:2rem;">Sources</h2>
<table class="sources-table">
<tr><td><a href="url">Source 1</a></td><td><a href="url">Source 2</a></td></tr>
<!-- more rows... -->
</table>
<p style="font-family:'DM Sans';font-size:0.65rem;color:var(--ink-light);margin-top:2rem;text-transform:uppercase;letter-spacing:0.1em;">Attribution line</p>
</div>
</div>Data Attributes
Every slide MUST have:
data-slide="N"— zero-indexed slide numberdata-audio="slide-id"— matches the audio filename (without .mp3)data-read-time="N"— seconds for an average English reader to absorb the visual content
The first slide MUST have class="slide active". All others are just class="slide".
#!/usr/bin/env python3
"""
Generate narration audio for presentation slides using ElevenLabs API.
Also generates a Rhodes chord transition sound.
Usage:
python3 generate_audio.py slides.json output_dir/ [--voice daniel]
slides.json format:
[
{"id": "slide-title", "narration": "Text to speak for this slide"},
{"id": "slide-exec", "narration": "Another slide's narration"},
...
]
Output: MP3 files in output_dir/ named <id>.mp3, plus transition.mp3
Also writes durations.json with timing data for the HTML template.
"""
import argparse
import json
import os
import subprocess
import sys
import time
VOICE_MAP = {
"daniel": "onwK4e9ZLuTAKqWW03F9",
"alice": "Xb7hH8MSUJpSbSDYk0k2",
"matilda": "XrExE9yKIg1WjnnlVkGX",
"brian": "nPczCjzI2devNBz1zQrb",
"george": "JBFqnCBsd6RMkjVDRZzb",
"sarah": "EXAVITQu4vr4xnSDxMaL",
"roger": "CwhRBWXzGAHq8TQ4Fs17",
"eric": "cjVigY5qzO86Huf0OWal",
"bella": "hpp4J3VqNfWAUOO0d1Us",
"lily": "pFZP5JQG7iQjIQuC4Bku",
}
def get_api_key():
key = os.environ.get("ELEVENLABS_API_KEY")
if key:
return key
env_path = os.path.expanduser("~/claude-skills/elevenlabs-tts/.env")
if os.path.exists(env_path):
with open(env_path) as f:
for line in f:
line = line.strip()
if line.startswith("ELEVENLABS_API_KEY="):
return line.split("=", 1)[1].strip().strip("'\"")
raise ValueError("ELEVENLABS_API_KEY not found in environment or ~/claude-skills/elevenlabs-tts/.env")
def generate_tts(text, voice_id, output_path, api_key, retries=2):
import requests
for attempt in range(retries + 1):
try:
r = requests.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
headers={"xi-api-key": api_key, "Content-Type": "application/json"},
json={
"text": text,
"model_id": "eleven_turbo_v2_5",
"voice_settings": {"stability": 0.65, "similarity_boost": 0.75, "style": 0.15},
},
timeout=60,
)
if r.status_code == 200:
with open(output_path, "wb") as f:
f.write(r.content)
return len(r.content)
elif r.status_code == 429 and attempt < retries:
wait = 2 ** (attempt + 1)
print(f" Rate limited, waiting {wait}s...", file=sys.stderr)
time.sleep(wait)
continue
else:
print(f" ERROR: {r.status_code} {r.text[:200]}", file=sys.stderr)
return 0
except requests.exceptions.Timeout:
if attempt < retries:
print(f" Timeout, retrying ({attempt + 1}/{retries})...", file=sys.stderr)
continue
print(f" ERROR: Request timed out after {retries + 1} attempts", file=sys.stderr)
return 0
return 0
def generate_transition(output_path, api_key):
import requests
r = requests.post(
"https://api.elevenlabs.io/v1/sound-generation",
headers={"xi-api-key": api_key, "Content-Type": "application/json"},
json={
"text": "A warm Rhodes electric piano chord with gentle reverb, smooth and professional, two seconds",
"duration_seconds": 2.0,
},
)
if r.status_code == 200:
with open(output_path, "wb") as f:
f.write(r.content)
return len(r.content)
return 0
def get_duration(path):
try:
r = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", path],
capture_output=True, text=True,
)
return float(json.loads(r.stdout)["format"]["duration"])
except Exception:
return 20.0
def main():
parser = argparse.ArgumentParser(description="Generate presentation narration audio")
parser.add_argument("slides_json", help="Path to slides JSON file")
parser.add_argument("output_dir", help="Output directory for audio files")
parser.add_argument("--voice", default="daniel", help="Voice name (default: daniel)")
parser.add_argument("--skip-existing", action="store_true", help="Skip slides that already have audio")
args = parser.parse_args()
api_key = get_api_key()
voice_id = VOICE_MAP.get(args.voice.lower())
if not voice_id:
print(f"Unknown voice '{args.voice}'. Available: {', '.join(VOICE_MAP.keys())}")
sys.exit(1)
os.makedirs(args.output_dir, exist_ok=True)
with open(args.slides_json) as f:
slides = json.load(f)
errors = []
for slide in slides:
if "id" not in slide or "narration" not in slide:
print(f"ERROR: Each slide must have 'id' and 'narration' keys. Got: {list(slide.keys())}", file=sys.stderr)
sys.exit(1)
if "/" in slide["id"] or "\\" in slide["id"] or ".." in slide["id"]:
print(f"ERROR: Slide id '{slide['id']}' contains path separators", file=sys.stderr)
sys.exit(1)
print(f"Generating {len(slides)} narration tracks with voice '{args.voice}'...")
for i, slide in enumerate(slides):
outpath = os.path.join(args.output_dir, f"{slide['id']}.mp3")
if args.skip_existing and os.path.exists(outpath):
print(f" [{i+1}/{len(slides)}] skip {slide['id']} (exists)")
continue
print(f" [{i+1}/{len(slides)}] {slide['id']}...")
size = generate_tts(slide["narration"], voice_id, outpath, api_key)
if size:
print(f" -> {size} bytes")
time.sleep(0.3)
# Transition sound
transition_path = os.path.join(args.output_dir, "transition.mp3")
if not os.path.exists(transition_path):
print("Generating transition sound...")
generate_transition(transition_path, api_key)
# Get durations
print("Getting durations...")
durations = {}
for slide in slides:
path = os.path.join(args.output_dir, f"{slide['id']}.mp3")
if os.path.exists(path):
durations[slide["id"]] = round(get_duration(path), 1)
dur_path = os.path.join(args.output_dir, "durations.json")
with open(dur_path, "w") as f:
json.dump(durations, f, indent=2)
print(f"Durations saved to {dur_path}")
total = sum(durations.values())
print(f"\nTotal narration: {total:.0f}s ({total/60:.1f}m)")
if __name__ == "__main__":
main()