I asked a video model for a title card. A product name, one line of copy, one brand colour.
It gave me something that looked like a title card until you read it. The text was almost my
text. I asked again. Almost.
What I eventually noticed: the agent I was already talking to can write HTML exactly. The
words are the words I asked for. The number is the number. The colour is a hex value I picked.
And when it's wrong I can point at one line and say "this word", and it changes that word.
What I didn't have was a renderer. So I wrote one.
node scripts/render-video-seek.js animation.html --duration=10 --fps=60
That clip at the top is one HTML file, 447 lines, zero dependencies.
Re-render it with the command above and compare bytes.
The contract is one function
Your page exposes window.__seek(t). The renderer calls it with a time in seconds, your page
redraws that frame, the renderer screenshots it. No import, no build step, no bundler, no
framework.
function drawFrame(t) {
// everything is a pure function of t
}
window.__seek = drawFrame;
window.__ready = true; // ready to be captured
That's the whole API. Nothing gets installed into your page, and there's nothing to import.
It matters that the shape is familiar. An LLM already writes function draw(t) when you ask it
for a canvas animation — this is that same function with a global attached. The renderer isn't
a new thing your agent has to learn; it's the last step of something it already does.
Determinism is what makes it agent-shaped
No Math.random(), no Date.now(), no state carried over from the previous frame. Every frame
computed from scratch. It looks annoying for about ten minutes, then it pays for itself:
The page is text. The agent's change is a diff you can read. Point at a line, change a word,
re-render. There's no scene graph to keep in sync and no keyframe editor to reopen.
--stills before a full render. --stills=1,3,5 exports frames without encoding anything,
so the agent can look at three frames before committing to a six-minute run.
Parallel rendering. Frames have no ordering dependency, so N workers each grab whatever
frame is next. Ten seconds at 60fps is 600 frames; four workers did that in 8m21s at 1080p
on a laptop CPU.
Byte-identical re-renders. Same input, same pixels. Which means you can diff a video in CI.
--resume. Kill the render partway, run it again, and it reuses the frames it already has
and fills in the rest. There is a test that kills a run after 3 of 5 frames, resumes it, and
asserts the MD5 of every frame matches the uninterrupted baseline. (I claim frames, not the
final MP4 — ffmpeg writes container metadata, so the file hash isn't the useful assertion.)
node scripts/render-video-seek.js animation.html --duration=10 --fps=60 --resume
The trap nobody warns you about: determinism is per machine. Same fonts, same Chromium
build. Ship a design that depends on a font only your laptop has and the layout shifts on
everyone else's. The QA pass checks for font fallback specifically, because that failure is
completely silent.
Built to be driven by an agent
The repo root has a SKILL.md, so this ships as an agent skill rather than as a CLI you
remember flags for. The loop is: the agent writes a page, edits the CFG block at the top of a
template, renders, reads the QA report, and fixes whatever the report flagged.
Two things make that loop close instead of the agent guessing:
- The QA report is structured output. Motion, dead time, frame spikes, determinism, text framing, font fallback — six dimensions, each with findings an agent can act on, not a log dump it has to interpret.
-
The templates constrain the page. An agent handed a blank canvas invents a layout every
time. Handed
title-card.htmlwith aCFGblock, it fills in fields. Fewer decisions means fewer ways to be wrong.
The contract is small enough to fit in a system prompt. That's the point — a video tool an
agent can hold in its head.
If you're building anything that turns a request into a video, this part is probably more
interesting than the renderer.
Four things that cost more time than the renderer
Transparent output needs three things, not one
I wanted text-only frames to lay over footage someone else shot. Playwright's
omitBackground sounds like the whole answer. It isn't — you need all three:
await context.addInitScript(() => { window.__transparent = true; }); // ① tell the page
await page.addStyleTag({ content: 'html,body{background:transparent!important}' }); // ②
await page.screenshot({ omitBackground: true }); // ③
Miss any one and you get white. And a subtlety that cost me an hour: passing
omitBackground: false explicitly still switches the PNG encoding path. My comparison
test dropped from infinite PSNR to 83 dB. The non-alpha path must not pass the key at all —
not pass it as false.
Then the template side. I only skipped the background fill and kept a 5% opacity glow.
Result: corner alpha was 12 instead of 0. Composited over real footage that reads as
grey haze, and an editor's first reaction is "this footage is dirty." Skip the glow too —
corner alpha 0, and only 3.7% of pixels are non-transparent, all of them text.
VP9 alpha: the file was right, the playback wasn't
I wanted a small transparent format, so I encoded yuva420p -auto-alt-ref 0. The file grew
from 7.8 KB to 16.8 KB, which is what you'd expect if alpha data went in. ffmpeg decoded it
back correctly.
Chromium played every frame at alpha 0. Entirely invisible. Lossless, fixed bitrate,
-lag-in-frames 0 — identical result.
I couldn't self-verify it, so I didn't ship it. --alpha produces ProRes 4444 only, and
--alpha-codec=vp9 errors out with the reason. Shipping an option you can't prove works is
worse than not having the option.
My QA checker was 83% false positives
The quality gate looks for text painted outside the canvas. For DOM text, the first version
took the union of all line boxes per text node.
A paragraph with an inline <span> — the union of the paragraph's line boxes contains the
span. So the paragraph and its own emphasised word always overlapped. Six findings on my
control page, five of them fake.
The fix was to stop thinking in text nodes and start thinking in visual lines: merge
fragments that overlap vertically by more than half the shorter one's height, and don't merge
across a horizontal gap wider than 2.5× line height (or you weld a left column to a right
column). Same page: six findings became one — and the one left was real.
The merge function lives in lib/qa-rules.cjs and is injected into the page with
mergeLines.toString(), so the browser and node --test run the same function body. Two
implementations of one rule is how you end up with a gate that lies to you.
Portrait is not landscape stretched
Templates scale from a 1920×1080 reference: SX = W/1920, SY = H/1080, SF = √(SX·SY).
At 1920×1080 all three are 1.
Flip to 9:16 and SY becomes 1.78. Use it for line spacing and the column comes apart. The
fix is a fourth variable — VSY = PORTRAIT ? SF : SY — plus a portrait font step. The four
newest templates were rewritten around that, not resized.
A checker that always passes is worse than no checker
Every QA check has a deliberately broken counterpart in test/fixtures/, and the test
asserts that QA fails on it: random values in the render, text clipped at the edge,
overlapping text, a dead middle section, console errors, a page that never signals ready.
If QA passes the broken page, the build goes red. That's the only way a quality gate earns
its keep.
Command line
| Flag | What it does |
|---|---|
--duration=10 |
seconds to render |
--fps=60 |
native frame rate, not interpolated |
--width / --height
|
any viewport; templates reflow (1920×1080, 1080×1920, 1280×720…) |
--resume |
fill in missing frames from a killed run |
--stills=1,3,5 |
export stills only, no encode — fast look while iterating |
--alpha |
transparent ProRes 4444 (.mov) |
--srt=voice.srt |
drive captions from an external SRT / VTT / JSON timeline |
--cue-log=cues.csv |
dump per-frame cue timing so you can measure caption drift |
--audio=track.mp3 |
mux audio without re-encoding video |
Ten templates
Copy one, edit the CFG block at the top, render. An agent does the same thing, which is
why they exist: a starting point with named fields beats a blank file.
| Template | For |
|---|---|
title-card |
opening title, subtitle, tag chips |
bar-chart |
ranked bars, values counting up |
line-chart |
trend line drawing in, end-point callout |
number-counter |
big KPI number, delta badge |
timeline |
process or milestone axis lighting up |
compare |
before/after, two columns, verdict bar |
flow-steps |
step diagram lighting up in sequence |
subtitle-card |
one sentence at a time, SRT-capable |
vertical-card |
9:16 card for Shorts / Reels / TikTok |
vertical-subtitle |
9:16 caption card with **keyword** highlighting |
Three built-in themes (midnight, paper, neon) switch with one field: CFG.theme.
What this is not
- Not text-to-video, and it doesn't want to be. A video model is the right tool when you want footage that doesn't exist yet — "a fox skating through Tokyo at night." This is the right tool when the words, the numbers, or the layout are the point, because it prints them exactly. Nobody writes the page for you: you do, or your agent does, in about a minute.
- Not fast. A frame costs anywhere from a fraction of a second to several seconds at 1080p, depending on how much your page actually draws. The demo above rebuilds a full-screen gradient and a 37-line grid every frame; that cost 600 frames in 8m21s on four workers. There is no GPU path.
- Not a motion design app. No timeline UI, no keyframe editor. It's a CLI and ten templates.
- Not bundling ffmpeg. You need a full ffmpeg build. Playwright ships a stripped one without the encoders.
- Not a Remotion replacement. If you're building a video editor into a React product, or you want Lambda-scale cloud rendering, use Remotion. This is for the case where the page is cheap to produce and the file is what you actually wanted.
Try it
git clone https://github.com/wx4766/html-to-video
cd html-to-video
npm install
npx playwright install chromium
node scripts/render-video-seek.js assets/templates/bar-chart.html --duration=6 --fps=30
MIT, 55 tests, CI on every PR. If you've been screen-recording browser tabs, or prompting a
video model four times to get one word right — that's the case this is for.
Questions welcome. I'd especially like to hear from people building agent loops that need
video, because that's the part I'd like to push further.

Top comments (0)