SKILL.md
---
name: html-video
description: Turn a text brief into a rendered MP4 video by writing one deterministic HTML animation and capturing it frame by frame on the user's own machine with Playwright and FFmpeg. Use when the user asks for an explainer video, kinetic typography, an animated announcement, a data story, or any short video, especially when no video-generation service is available, affordable, or wanted.
license: MIT
---
# HTML Video
Make a real MP4 without any video-generation model. You write a single HTML
file whose animation is a pure function of time, a script photographs every
frame in a headless browser, and FFmpeg stitches the frames into a video.
Everything runs locally and offline.
## The one rule everything depends on
The page MUST expose `window.seek(t)` where `t` is seconds, and calling it
must draw the exact frame for that moment. Rendering jumps from frame to
frame; nothing is ever "played". Therefore, in the frame path:
- No `Date.now()`, no unseeded `Math.random()`, no `requestAnimationFrame`
progression, no CSS `animation`/`transition` for anything that moves.
- All motion is computed inside `seek` from `t` (transforms, opacity, text).
- No network requests. Inline all CSS and JS; images must be local files.
If a frame cannot be reproduced exactly by calling `seek(t)` twice, the
render will jitter or desync. Treat determinism as a correctness bug, not a
style issue.
## Workflow
1. **Pin the format.** Default: vertical 1080x1920 for Shorts/Reels/TikTok.
Landscape 1920x1080 when the user says YouTube/desktop. Ask only if the
destination is genuinely unclear. Default 30 fps, H.264 MP4, no audio
unless the user provides an audio file.
2. **Storyboard first.** Before writing code, write the scene list and show
it in your reply or a `scenes.md`: for each scene, its duration, the exact
on-screen text, and what moves. One idea per scene, 2 to 6 seconds per
scene, on-screen text short enough to read aloud in the scene's duration.
3. **Build `animation.html`** starting from `assets/template.html` in this
skill. Implement each scene as a draw step selected by cumulative scene
durations inside `seek`. Use the easing helpers in the template.
4. **Check prerequisites.** Run `node scripts/check.mjs` from this skill.
If it reports missing pieces, give the user the one install command it
prints for their OS and wait for them, or run it if you have permission.
5. **Render.** Copy `scripts/` next to your animation (or reference it by
path) and run:
`node scripts/render.mjs animation.html --duration <seconds> --fps 30 --size vertical --out video.mp4`
6. **Verify before reporting.** Probe the output (`ffprobe` or the summary
the render script prints): duration and dimensions must match the plan.
Extract 3 thumbnails (start, middle, end) with FFmpeg and look at them.
Only then report the file path, duration, and size to the user.
## Design rules (defaults, override only on request)
- Safe margin 80 px on all sides (vertical) so platform UI never covers text.
- Minimum sizes at 1080 wide: headline 64 px, body 40 px, caption 34 px.
- One accent color plus neutrals. Flat shapes and type, no gradients as a
substitute for an idea.
- Every scene change needs a visual event (something moves, builds, or
transforms); a static slide with a fade is a failed scene.
- End with a 1.5 s end card (title or call to action), included in the total
duration.
## Audio
Out of scope for generation. If the user supplies an audio file, pass it
through: `node scripts/render.mjs animation.html --duration <s> --audio track.mp3`
and the script muxes it, trimming to the shorter of the two.
## Troubleshooting
- **Black or identical frames**: `seek` missing, threw an error, or the page
made a network request that never resolved. Open the page, check the
console, make everything local and synchronous inside `seek`.
- **Jitter between frames**: something in the frame path depends on wall
clock or randomness. Find it and derive it from `t`.
- **Text blurry or cropped**: viewport size and CSS pixels disagree; keep
the stage exactly the output size and `deviceScaleFactor` at 1.
- **Render too slow**: drop to `--fps 24`, or halve the size with
`--size vertical-sm` while drafting; render final quality last.
- **FFmpeg not found**: run `node scripts/check.mjs`; it prints the install
command per OS (winget/brew/apt) and the Playwright browser install step.
scripts/check.mjs
// Prerequisite check for the html-video skill (EN below).
// Cek prasyarat render video lokal: Node, FFmpeg, Playwright + browser-nya.
import { spawnSync } from 'node:child_process';
const isWin = process.platform === 'win32';
let ok = true;
function report(name, good, fix) {
console.log(`${good ? 'OK ' : 'MISS'} ${name}`);
if (!good) {
ok = false;
console.log(` Install / Pasang: ${fix}`);
}
}
// Node itself is running this script, so it exists; show the version.
console.log(`OK Node ${process.version}`);
const ff = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8' });
report(
'FFmpeg',
ff.status === 0,
isWin ? 'winget install Gyan.FFmpeg' : process.platform === 'darwin' ? 'brew install ffmpeg' : 'sudo apt install ffmpeg'
);
let pw = false;
try {
await import('playwright');
pw = true;
} catch { /* handled below */ }
report(
'Playwright (npm package)',
pw,
'npm init -y && npm i playwright (in your project folder / di folder proyekmu)'
);
if (pw) {
const { chromium } = await import('playwright');
let browserOk = false;
try {
const b = await chromium.launch();
await b.close();
browserOk = true;
} catch { /* handled below */ }
report('Playwright Chromium browser', browserOk, 'npx playwright install chromium');
}
console.log(ok
? '\nAll set. Render with / Siap. Render dengan: node render.mjs animation.html --duration 10 --out video.mp4'
: '\nSome pieces are missing / Ada yang belum terpasang. Install them above, then run this check again / Pasang yang kurang di atas, lalu jalankan cek ini lagi.');
process.exit(ok ? 0 : 1);
examples/README.md
# Examples
Finished renders live here, each in its own folder with:
- the rendered `video.mp4`
- the `animation.html` that produced it
- `PROMPT.md`: the exact prompt given to the agent
## Planned first example
**vlan-explainer** — a 30-second vertical video answering "What is a VLAN?" in five scenes, produced end to end by the `html-video` skill from a single prompt. It doubles as the project's proof that the whole pipeline works on an ordinary laptop.
assets/template.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>animation</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0b1220; }
body { font-family: "Segoe UI", Arial, sans-serif; color: #f1f5f9; }
#stage { position: relative; width: 100vw; height: 100vh; }
.scene { position: absolute; inset: 0; display: none; padding: 120px 80px; }
h1 { font-size: 110px; line-height: 1.05; letter-spacing: -1px; }
h1 .accent { color: #2dd4bf; }
p.sub { font-size: 44px; color: #cbd5e1; margin-top: 28px; }
#bar-wrap { position: absolute; left: 80px; right: 80px; bottom: 220px; height: 56px;
background: #1e293b; border-radius: 28px; overflow: hidden; }
#bar { height: 100%; width: 0%; background: #2dd4bf; border-radius: 28px; }
#bar-label { position: absolute; left: 80px; bottom: 300px; font-size: 40px; color: #cbd5e1; }
#endcard { align-items: center; justify-content: center; text-align: center; }
#endcard .big { font-size: 96px; font-weight: 700; }
</style>
</head>
<body>
<div id="stage">
<section class="scene" id="s1">
<h1>Your title<br><span class="accent">goes here</span></h1>
<p class="sub">One short supporting line.</p>
</section>
<section class="scene" id="s2">
<div id="bar-label">Progress is drawn from t, never played</div>
<div id="bar-wrap"><div id="bar"></div></div>
</section>
<section class="scene" id="s3">
<div id="endcard-inner">
<div class="big">The End</div>
<p class="sub">your handle or call to action</p>
</div>
</section>
</div>
<script>
// Easing helpers: pure functions of progress p in [0, 1].
const clamp01 = (p) => Math.min(1, Math.max(0, p));
const easeOut = (p) => 1 - Math.pow(1 - clamp01(p), 3);
const easeInOut = (p) => { p = clamp01(p); return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2; };
const seg = (t, from, to) => clamp01((t - from) / (to - from)); // progress of t inside [from, to]
const $ = (id) => document.getElementById(id);
// Scene table: cumulative durations in seconds. Total must equal the
// --duration you pass to render.mjs.
const SCENES = [
{ id: 's1', dur: 3.0, draw(t) {
// Title slides up and fades in during the first 0.8 s.
const p = easeOut(seg(t, 0, 0.8));
const el = $('s1');
el.style.opacity = p;
el.style.transform = `translateY(${(1 - p) * 60}px)`;
} },
{ id: 's2', dur: 3.0, draw(t) {
// A bar grows across the whole scene.
$('bar').style.width = (easeInOut(seg(t, 0.2, 2.8)) * 100).toFixed(2) + '%';
$('bar-label').style.opacity = easeOut(seg(t, 0, 0.5));
} },
{ id: 's3', dur: 1.5, draw(t) {
const p = easeOut(seg(t, 0, 0.6));
const el = $('s3');
el.style.opacity = p;
el.style.transform = `scale(${0.92 + 0.08 * p})`;
} },
];
const total = SCENES.reduce((a, s) => a + s.dur, 0);
window.__duration = total;
// The contract: draw the exact frame for time t (seconds).
// Exactly one scene owns each t: the one whose window [acc, acc+dur)
// contains it; the last scene also owns everything past its start.
// (An earlier version matched every scene whose window ENDED after t,
// so later scenes drew over the current one. Fixed 2026-11-08, found
// while building the VLAN demo.)
window.seek = function (t) {
let acc = 0;
SCENES.forEach((s, i) => {
const el = $(s.id);
const isLast = i === SCENES.length - 1;
if (t >= acc && (t < acc + s.dur || isLast)) {
el.style.display = s.id === 's3' ? 'flex' : 'block';
s.draw(t - acc);
} else {
el.style.display = 'none';
}
acc += s.dur;
});
};
window.seek(0);
</script>
</body>
</html>
scripts/render.mjs
// Render a deterministic HTML animation to MP4, frame by frame.
// Usage:
// node render.mjs <animation.html> --duration <seconds> [options]
// Options:
// --fps 30 frames per second (default 30)
// --size vertical vertical 1080x1920 | landscape 1920x1080 |
// vertical-sm 540x960 | landscape-sm 960x540 |
// WxH explicit, e.g. --size 1280x720
// --out video.mp4 output file (default: <html name>.mp4)
// --audio track.mp3 optional audio file to mux in
// --keep-frames keep the frames/ folder after encoding
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const args = process.argv.slice(2);
const htmlArg = args.find((a) => !a.startsWith('--'));
const opt = (name, fallback) => {
const i = args.indexOf('--' + name);
return i >= 0 ? args[i + 1] : fallback;
};
const has = (name) => args.includes('--' + name);
if (!htmlArg) {
console.error('Usage: node render.mjs <animation.html> --duration <seconds> [--fps 30] [--size vertical] [--out video.mp4] [--audio file] [--keep-frames]');
process.exit(1);
}
const htmlPath = resolve(htmlArg);
if (!existsSync(htmlPath)) {
console.error(`File not found: ${htmlPath}`);
process.exit(1);
}
const SIZES = {
vertical: [1080, 1920],
landscape: [1920, 1080],
'vertical-sm': [540, 960],
'landscape-sm': [960, 540],
};
const sizeArg = opt('size', 'vertical');
const [width, height] = SIZES[sizeArg] || sizeArg.split('x').map(Number);
if (!width || !height) {
console.error(`Unknown --size "${sizeArg}". Use vertical, landscape, vertical-sm, landscape-sm, or WxH.`);
process.exit(1);
}
const fps = Number(opt('fps', '30'));
const outPath = resolve(opt('out', basename(htmlPath).replace(/\.html?$/i, '') + '.mp4'));
const audioPath = opt('audio', null) ? resolve(opt('audio')) : null;
const framesDir = join(dirname(htmlPath), 'frames-' + basename(htmlPath).replace(/\W+/g, '_'));
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
console.error('Playwright is not installed. Run: node check.mjs (it prints the install steps).');
process.exit(1);
}
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
await page.goto(pathToFileURL(htmlPath).href);
await page.waitForFunction(() => typeof window.seek === 'function', null, { timeout: 10000 });
const pageDuration = await page.evaluate(() => window.__duration);
const duration = Number(opt('duration', pageDuration));
if (!duration) {
console.error('No duration: pass --duration or set window.__duration in the page.');
await browser.close();
process.exit(1);
}
rmSync(framesDir, { recursive: true, force: true });
mkdirSync(framesDir, { recursive: true });
const totalFrames = Math.round(duration * fps);
console.log(`Rendering ${totalFrames} frames at ${fps} fps, ${width}x${height}, ${duration}s`);
for (let i = 0; i < totalFrames; i++) {
const t = i / fps;
await page.evaluate((tt) => window.seek(tt), t);
await page.screenshot({ path: join(framesDir, `frame-${String(i).padStart(5, '0')}.png`) });
if (i % 30 === 0 || i === totalFrames - 1) console.log(` frame ${i + 1}/${totalFrames}`);
}
await browser.close();
const ffmpegArgs = ['-y', '-framerate', String(fps), '-i', join(framesDir, 'frame-%05d.png')];
if (audioPath) ffmpegArgs.push('-i', audioPath);
ffmpegArgs.push('-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart');
if (audioPath) ffmpegArgs.push('-c:a', 'aac', '-shortest');
ffmpegArgs.push(outPath);
console.log('Encoding with FFmpeg...');
const enc = spawnSync('ffmpeg', ffmpegArgs, { stdio: 'inherit' });
if (enc.status !== 0) {
console.error('FFmpeg failed. Run: node check.mjs');
process.exit(enc.status ?? 1);
}
if (!has('keep-frames')) rmSync(framesDir, { recursive: true, force: true });
console.log(`Done: ${outPath}`);