I needed a 52-second explainer for Pageree, the product I'm working on. It has no screen recording and was never opened in a video editor. It's a web page: HTML and CSS for the scenes, a tiny timeline script, headless Chrome to render the frames, and ffmpeg to put picture and sound together. An AI voice reads it, and the music and sound effects are generated too.
You can make one like it for anything: your app, a feature launch, an internal process, a course. Your part takes about five minutes: fill in a few blanks in the prompt below, approve the storyboard, and look at the stills. Your AI coding assistant writes the code, and the render takes a couple of minutes on a laptop.
What you need
- An AI coding assistant that can run commands on your machine: Claude Code, Cursor, Codex or similar.
- Node 22 or newer, Google Chrome, and ffmpeg (
brew install ffmpegon a Mac). - An ElevenLabs API key for the voice, sound effects and music. Without one you still get the video, just silent. Commercial use needs a paid ElevenLabs plan.
Step 1: paste this prompt
Fill in the five things in [brackets], then give the whole prompt to your assistant in an empty folder.
Build me a short animated explainer video, rendered from a web page. THE VIDEO - Topic: [what it explains, e.g. "how our app sends invoice reminders"] - Audience: [who watches it] - Length: about [45] seconds, 16:9, 1920x1080, 30 fps - Look: flat cards with thick dark borders and hard offset shadows, one accent colour [#693bda], big bold headlines, plenty of empty space. No stock photos, no people. - End card: [product name] and [URL] THE STORY Write 5 to 7 scenes, each 4 to 9 seconds with one idea: a hook that names the viewer's problem, three or four scenes that show how it works, and the end card. For each scene give an on-screen headline (8 words at most) and one voiceover line (about 2 words a second at most). Show me the storyboard as a table and wait for my OK before building. HOW TO BUILD IT 1. Make a folder video/ with: - video.json: the scenes (id, minimum seconds, voice line, when the line starts inside the scene), the voice, the sound effects (name, prompt, seconds), the cues (sound, scene, second) and a music prompt. - index.html: every scene in HTML and CSS on a 1600x900 CSS-pixel canvas, absolutely positioned. No CSS animations or transitions. - timeline.js: a tiny engine. at(selector, start, duration, {prop: [from, to]}, easing) adds a track; seek(t) sets every element to its state at second t: opacity, translate x and y, scale, rotate, width in %, and typed text taken from data-text. Before its first track starts, an element sits at that track's "from" value. Times are scene-relative: t(scene, offset) and end(scene, offset) read each scene's start and end from timing.js. index.html?t=12.5 shows that moment and index.html?play plays it. - make.mjs: plain Node 22, no dependencies, three commands: a) audio: if ELEVENLABS_API_KEY is set, generate one MP3 per voice line (POST https://api.elevenlabs.io/v1/text-to-speech/{voice_id} with model_id eleven_multilingual_v2, sending the neighbouring lines as previous_text and next_text), one per sound effect (POST /v1/sound-generation with duration_seconds) and an instrumental music bed (POST /v1/music with music_length_ms and force_instrumental true). Name each file after a hash of what made it, so unchanged lines are never generated again. Measure where each line's speech ends with ffmpeg silencedetect, ignoring trailing silence. A scene lasts max(its minimum, voice start + speech length + 0.5 s). Write the scene times to timing.js. b) stills 2,8,14: PNG frames at those seconds, so we can both check the layout. c) render: start Chrome headless with --remote-debugging-port=0, read the port from DevToolsActivePort in its profile folder, and drive it over the DevTools protocol with Node's built-in WebSocket. Set the viewport to 1600x900 at deviceScaleFactor 1.2. For every frame call seek(frame / 30), wait two requestAnimationFrames, and save a JPEG screenshot. Then one ffmpeg pass: the frames at 30 fps; each voice line and sound effect placed with adelay; amix with normalize=0; the music under everything, ducked with sidechaincompress whenever the voice speaks and faded out at the end; loudnorm to -14 LUFS; H.264 and AAC with faststart. Also write an .srt caption file from the voice lines and their measured times. 2. Animate like a motion designer. Something moves every half second to a second. Entrances take 0.3 to 0.6 s, staggers 0.12 s, pops have a small overshoot. Each scene holds its finished state for a second before it leaves. The voice may lead the picture by up to half a second, never trail it. A soft pop when a card lands, a whoosh on scene changes, typing sounds while text types. 3. Before rendering, make stills of every scene, look at them yourself, and fix overlaps, cut-off text and anything touching the edges. Then render and give me the path to the MP4. If there is no ElevenLabs key, render without sound and tell me. Step 2: check it before it renders
The assistant shows you a storyboard first. Changing the story costs nothing at this point, so read it. After it builds the page, open index.html?play in Chrome to watch it silently, or ?t=12.5 to look at one moment. Ask for changes in plain words: "the second scene is too busy", "make the hook faster", "use a warmer voice".
Step 3: render
The assistant runs node make.mjs audio, then node make.mjs render, and you get an MP4 and an .srt for YouTube. If a voice line changes, only that line is generated again, and its scene stretches to fit.
How it works, briefly
A timeline you can seek. CSS animations run in real time. A renderer needs to ask what second 12.4 looks like, so every animation is data, and seek(t) computes each element's state:
function seek(t) { const state = new Map(); for (const tr of tracks) { // sorted by start const s = state.get(tr.el) ?? {}; state.set(tr.el, s); for (const [p, [from, to]] of Object.entries(tr.props)) { if (t < tr.start) { if (!(p in s)) s[p] = from; continue; } const k = tr.dur ? Math.min(1, (t - tr.start) / tr.dur) : 1; s[p] = from + (to - from) * tr.ease(k); } } for (const [el, s] of state) { if ('o' in s) el.style.opacity = s.o; el.style.transform = `translate(${s.x ?? 0}px, ${s.y ?? 0}px) scale(${s.s ?? 1})`; } } The voice sets the timing. My first version had hard-coded times, and the voiceover came back longer than my guesses. With scene-relative times and scene lengths measured from the speech, a longer line stretches its scene instead of being cut off. Generated speech ends with 0.2 to 0.6 seconds of silence, so measure where the speech really stops (ffmpeg -i line.mp3 -af silencedetect=noise=-45dB:d=0.1 -f null -).
Frames from Chrome, sound from ffmpeg. Chrome renders 1,557 frames for 52 seconds. One ffmpeg filter graph places every line and sound, ducks the music under the voice and sets the loudness. Watch for amix: without normalize=0 it turns every input down and your voice comes out quiet.
What I'd tell myself before starting
- The silent version looked finished. It wasn't. The voice, the music and a few pops and whooshes did more than any visual change.
- Make it scrubbable.
?t=was the most useful thing in the whole setup. - Let the voice decide the pace, not your guesses.
If you make a video with this prompt, I'd love to see it in the comments.
This post was drafted with help from an AI assistant.


