Build a pixel-perfect animated video (MP4) by writing HTML + CSS + JS animations and recording them with headless Chromium. Use when AI video models (Sora / Veo / Runway / Seedance) fail at tasks requiring precise text rendering (especially CJK), UI mockups (chat / iOS / web app screens), data visualizations, or timeline-driven scripted sequences. Cost per video: $0. Iteration cost: ~20 seconds per re-render.
Scanned 5/27/2026
Install via CLI
openskills install Universaljojo/html-to-mp4---
name: html-to-mp4
description: Build a pixel-perfect animated video (MP4) by writing HTML + CSS + JS animations and recording them with headless Chromium. Use when AI video models (Sora / Veo / Runway / Seedance) fail at tasks requiring precise text rendering (especially CJK), UI mockups (chat / iOS / web app screens), data visualizations, or timeline-driven scripted sequences. Cost per video: $0. Iteration cost: ~20 seconds per re-render.
---
# HTML → MP4 Animation Skill
Record HTML + CSS + JS animations to MP4 via headless Chromium. Use for any
scenario where AI video models can't deliver — precise text, pixel-accurate UI,
data viz, timeline-scripted sequences.
## When to use this (vs. AI video models)
| Need | Tool |
|------|------|
| Real actors, real locations, camera motion | AI video model (Sora / Veo / Runway) |
| Precise text, chat UI mockups, message-by-message reveal | **This skill** |
| Data viz animations (bar growth, number counting) | **This skill** |
| Logo / slogan end cards | **This skill** (or `overlay_slogan.py`) |
| 3D product renders | Dedicated 3D tools |
**Rule of thumb:** AI video models can't hit precise text or pixel-accurate UI.
If the spec says "this line must read exactly X" or "this button must sit at
position Y", use this skill.
---
## Workflow (5 steps)
### 1. Clarify requirements before coding
Ask the user:
- **Canvas size**:
- 9:16 portrait → `1080×1920` (TikTok / Reels / Shorts / YouTube Shorts)
- 16:9 landscape → `1920×1080` (YouTube / web / conference)
- 1:1 square → `1080×1080` (Instagram feed / square ad)
- **Duration**: how many seconds? > 30s → consider segmenting.
- **Frame-by-frame content**: what happens at each time marker?
- **Visual reference**: does the user have a screenshot / mood board? (Strongly
recommended.)
### 2. Copy the template
```bash
mkdir -p /path/to/project && cd /path/to/project
cp -r ~/.claude/skills/html-to-mp4/template/* .
npm install # first time only
npx playwright install chromium # first time only
```
### 3. Write `index.html`
The template gives you three load-bearing pieces:
- **Canvas lock**: `body { width: 1080px; height: 1920px; }` (must match
`record.mjs` VIEWPORT).
- **TIMELINE array**: JS adds `.show` at the right timestamps.
- **CSS transitions**: `.item` has `opacity: 0; transform: translateY(30px)`,
`.show` reverses to final state.
**Common patterns:**
- Messages / cards appearing one by one → `TIMELINE` + CSS transition.
- Content overflowing the viewport → `translateY` on the parent container (see
`examples/01-chat-mockup`).
- Number counter → `requestAnimationFrame` + `lerp`.
- SVG line drawing → animate `stroke-dashoffset`.
- Growing bar chart → animate `height` or `scaleY`.
### 4. Record
```bash
node record.mjs
```
Playwright launches headless Chromium → opens `index.html` → waits
`DURATION_MS` → writes webm → ffmpeg transcodes to MP4 → removes webm.
Output: `out/{OUT_NAME}` — H.264 / yuv420p / libx264 / CRF 20 / faststart.
### 5. Iterate
The user says "make the text bolder" / "change red to deep red" / "delay message
3 by half a second":
- Edit the relevant CSS / TIMELINE in `index.html`
- Re-run `node record.mjs` (~20s)
- Zero cost, zero wait (vs. AI video: ~$0.50 per render + minutes of wait +
possible content moderation hits)
---
## `record.mjs` constants
```js
const VIEWPORT = { width: 1080, height: 1920 }; // canvas size
const DURATION_MS = 16000; // recording length (ms)
const OUT_DIR = './out';
const OUT_NAME = 'my_animation.mp4';
```
**Must edit per project:** `VIEWPORT`, `DURATION_MS`, `OUT_NAME`.
---
## HTML authoring rules (for the agent)
### Lock the canvas
```css
html, body {
margin: 0; padding: 0;
background: #000; /* first-frame color — whatever the user wants as the "before" frame */
overflow: hidden; /* never let scrollbars leak into the recording */
}
body {
width: 1080px; height: 1920px; /* must match record.mjs VIEWPORT */
}
```
### TIMELINE pattern (preferred)
```html
<div class="msg" id="m1">...</div>
<div class="msg" id="m2">...</div>
<style>
.msg { opacity: 0; transform: translateY(30px); transition: opacity .35s, transform .35s; }
.msg.show { opacity: 1; transform: translateY(0); }
</style>
<script>
const TIMELINE = [
{ id: 'm1', at: 500 },
{ id: 'm2', at: 2000 },
{ id: 'm3', at: 4000 },
];
window.addEventListener('load', () => {
setTimeout(() => {
TIMELINE.forEach(t => {
setTimeout(() => document.getElementById(t.id).classList.add('show'), t.at);
});
}, 300); // 300ms initial delay so the recording catches a clean first frame
});
</script>
```
### Auto-scroll when content overflows
See `examples/01-chat-mockup/index.html` — instead of using a native scrollbar
(which would show in the recording), the parent container is translated upward
whenever a new message would overflow the viewport.
### Fonts
Prefer system fonts with fallbacks:
```css
font-family: -apple-system, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
"Noto Sans CJK SC", sans-serif;
```
If you need a specific web font, use `@font-face` with base64-embedded woff2 to
avoid the "first frame started before font loaded" bug.
### Wait for images
Already handled in `record.mjs`:
```js
await page.evaluate(async () => {
await Promise.all(
Array.from(document.images).map(img =>
img.complete ? Promise.resolve()
: new Promise(r => { img.onload = img.onerror = r; })
)
);
});
```
Don't remove that block. Without it you'll get "animation finished but the image
slot is still empty" corruption.
---
## Slogan overlay (no re-record needed)
If an AI video model produced great footage but the on-screen text is blurry,
use `template/overlay_slogan.py`:
1. Pillow renders a transparent PNG (fine control over font / color / stroke /
shadow / rotation).
2. ffmpeg composites it onto the video for a time range, with optional fade-in.
Standalone tool — can be used without the rest of this skill.
---
## Common pitfalls
| Symptom | Fix |
|---------|-----|
| `Cannot find package 'playwright'` | `npm install && npx playwright install chromium` |
| webm generated but MP4 fails | Check `which ffmpeg`; `brew install ffmpeg` if missing |
| First frame is black | Recording caught the middle of the 300ms initial delay — add a `waitForTimeout(100)` after `page.goto` |
| Text renders as boxes | Font stack missing — see the CJK-safe stack above |
| Images missing in output | Don't remove the `Promise.all(document.images)` block |
| "Want to change one word, have to re-record" | That's the point — 20s per iteration |
---
## Project layout
```
your-project/
├── package.json # playwright dependency
├── index.html # the animation (you / agent write this)
├── record.mjs # recorder (usually untouched)
├── overlay_slogan.py # optional: caption overlay
├── assets/ # images / audio / fonts
└── out/ # output (gitignored)
```
---
## Included examples
- **examples/01-chat-mockup/** — Messenger-style chat UI, messages appearing one
by one with auto-scroll. `1080×1920 / 14s`
- **examples/02-data-viz/** — Bar chart growing from 0, numbers counting up.
`1920×1080 / 10s`
- **examples/03-slogan-outro/** — Gold text slogan fade-in on black. `1080×1920 / 5s`
- **examples/04-product-ui/** — App UI interaction mock: button → modal →
loading → close. `1080×1920 / 12s`
---
## Sharing with teammates
Clone this repo into `~/.claude/skills/html-to-mp4/` on any machine with
Node 18+ and ffmpeg — no further setup. Or copy just the `template/` folder
anywhere and run `npm install && node record.mjs` standalone.
No comments yet. Be the first to comment!