DEV Community

Wang Xin
Wang Xin

Posted on

Text-to-video is the wrong primitive. HTML is the right one.

10-second demo rendered by this tool

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.html with a CFG block, 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 });                       // ③
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)