跳到正文
DEV Community· Wang Xin·· 3 小時前AI 評分61

html-to-video:HTML 動畫頁面只需一個全域函數即可渲染成影片

Your HTML page is already a video renderer. It just needs one global function.

AI 導讀

Wang Xin 發佈 MIT 授權的 html-to-video,讓已有動畫的 HTML 頁面透過 window.__seek(t) 及命令列逐幀渲染成影片。

正文

10-second demo rendered by this tool

I had an HTML page that animated. I wanted an mp4. Every tool I tried wanted me to
rewrite the page first — as React components, as a scene graph, as a timeline project.

So I wrote the renderer instead.

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.
You can re-render it yourself with the command above and compare bytes.


The whole 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

There is no import statement and nothing to install into your page. Open the HTML in a
browser and it plays. Point the CLI at the same file and you get a video.

That constraint — t is the entire input — is what makes the rest work.

Determinism isn't a feature here, it's the architecture

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 three times:

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.

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.

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.

It also ships as an agent skill

The repo root has a SKILL.md, so the tool can be driven by an LLM agent rather than by
you: the agent writes the HTML, edits the CFG block, renders, reads the QA report, and
fixes what the report flagged. The contract is small enough to fit in a system prompt, and
the QA report is structured output rather than a log dump — which is the part that makes
that loop close instead of the agent guessing.

If you're building anything that generates video from text, this part is probably more
interesting than the renderer.

What this is not

  • Not text-to-video. It renders what you wrote. If you don't have the HTML yet, this isn't your tool.
  • 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 exists for the case where you already have a working HTML page and you just want the file.

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 got a page that already animates and you've been
screen-recording it — that's the case this is for.

Questions welcome. I'd especially like to hear from people with a genuine use for
deterministic re-renders, because that's the part I'd like to push further.

來源:DEV Community · dev.to