Skip to content
Projects

wx4766/html-to-video

View on GitHub

wx4766/html-to-video has 194 GitHub stars and 14 forks. GitHot measured 0 stars in 24 hours and +7 in 7 days. Last checked 2026-11-15 13:07 UTC. Last push 2026-11-07.

How these counts are measured

Measured star growth

in 24h
0
in 7 days
+7
in 15 days
Collecting history
in 1 month
Collecting history

Net star changes over overlapping periods, not daily totals. Missing measurements are left blank. Stars measure attention, not software quality.

About

用代码逐帧做视频:HTML / Canvas 动画 → MP4 / GIF / 透明层 · Deterministic HTML-to-MP4 renderer: seek to t, screenshot, encode. No framework, no build step, no GPU.

Creative & Media

README.en.md

Original

html-to-video

English · 中文

CI License: MIT Node

10-second demo: HTML in, MP4 out — this clip is itself one HTML file, rendered

That clip is docs/demo-hero.html — 447 lines, zero dependencies. Reproduce it:
node scripts/render-video-seek.js docs/demo-hero.html --duration=10 --fps=60

Render an HTML/Canvas animation to MP4, frame by frame.

Not text-to-video. You write a page whose whole output is a pure function of time — frame(t) — and this tool seeks a headless browser to t, screenshots, and hands the frame sequence to ffmpeg.

animation.html  ──►  headless Chromium  ──►  N × PNG  ──►  ffmpeg  ──►  out.mp4
                     (seek to t, shoot)

Same input, same output. Every time. On every machine that has the same fonts.


Why not Remotion?

Because the thing you already have is an HTML page, not a React app.

Remotion is excellent — if you're building a video editor into a React product, or you want Lambda-scale cloud rendering, use it. But adopting it means rewriting your page as React components, adding a bundler, and respecting a license that is free for individuals and teams of ≤3 but paid above that.

This tool asks for one thing: a page that exposes window.__seek(t). No framework, no build step, no npm package to import in your page. You can literally open the file in a browser and watch it play, then point the renderer at the same file.

That matters most in one specific workflow: an LLM wrote the HTML. Language models produce a working HTML/Canvas file in one shot far more reliably than they produce a correct Remotion composition. So the shortest path from "I described a video" to "I have an MP4" is: LLM → single HTML file → this renderer. Nothing in between to debug.

html-to-video Remotion Motion Canvas
Mental model one HTML file + frame(t) React components + useCurrentFrame() TS generator + scene graph
Start cost copy a template, edit CFG scaffold a React project, learn the API install the editor, learn the API
Build step none — the browser runs it required required
Preview open the file in a browser Remotion Studio editor
License MIT free ≤3 people, commercial above MIT
Best at "turn this page into a video" video editors inside React apps hand-tuning storyboards

Honest summary: Remotion if you're building a video product; Motion Canvas if you're crafting shot by shot; this if you have a page and want an MP4.


60-second start

git clone https://github.com/wx4766/html-to-video.git
cd html-to-video
npm install
npx playwright install chromium      # the browser used for screenshots
# ffmpeg must be on PATH (brew install ffmpeg / apt install ffmpeg / winget install ffmpeg)

cp assets/templates/title-card.html my-video.html
node scripts/render-video-seek.js my-video.html --duration=6 --fps=30
# → my-video.mp4

Edit the CFG block at the top of my-video.html to change the copy and data. That's the whole workflow.

Portrait / any resolution — the templates measure the viewport, so nothing gets cropped:

node scripts/render-video-seek.js my-video.html --duration=6 --fps=30 --width=1080 --height=1920

14 templates you can ship today

Copy one, edit CFG, render. Each template is a single self-contained HTML file.

Template What it does
explainer.html Multi-shot explainer — hook → character + speech → bullet points → bar comparison → outro, with real transitions between shots. Can be driven entirely by a JSON shot list. Exactly three points in landscape switch to a three-column layout; portrait (1080×1920) gets its own stacked layout.
title-card.html Title card — characters pop in, then subtitle and tag
bar-chart.html Bar chart / ranking — bars grow in sequence, numbers count up, leader highlighted
line-chart.html Line trend — line draws itself, points light up, value bubble at the end
number-counter.html Big number / KPI — rolls from 0 to target, delta label, progress bar
timeline.html Timeline — axis grows, nodes light up one by one
compare.html Before / after — two columns plus a verdict strip
flow-steps.html Process diagram — steps light up in order, current step highlighted
subtitle-card.html Sentence-by-sentence explainer card with a segmented progress bar
vertical-card.html 9:16 portrait card for Shorts / Reels / TikTok
vertical-subtitle.html 9:16 caption card with **keyword** highlighting
quote-card.html Quote / pull-quote card — oversized quotation mark, line-by-line quote, attribution rule
chapter-card.html Chapter divider — ghosted number, title, progress dots. Slices a long film into acts
ui-showcase.html Product UI demo — window pushes in, slow push-in, two spotlight callouts. Swap in a real screenshot with CFG.shot

Every template ships in both landscape and portrait layout — the layout picks itself from the viewport, so --width/--height is the only switch.


12 visual styles: same content, different film

Templates decide what goes on screen; styles decide what it looks like — the paper, the ink, the line weight, the type, the entrance, the signature detail. Separate the two and 14 layouts × 12 styles = 168 looks, without writing 168 templates.

bash assets/render.sh assets/style-film.html --duration=13 --theme=chalk
bash assets/render.sh assets/style-film.html --duration=13 --theme=ink --width=1080 --height=1920
Style What makes it Best for
riso Cream paper, three halftone screens at real print angles, misregistered title Opinion, data, indie-magazine feel
cutpaper Torn edges on all four sides, hard shadow, two sheets stacked Stories, people, education
blueprint Deep blue, 20/100px grid, corner crosshairs, monospace Technical, architecture, API
chalk Green board, double-stroke jitter, dust, kai typeface Teaching, explainers
isometric True 2:1 isometric blocks, white faces hiding what's behind Mechanisms, systems, flow
synthwave Striped sun, perspective floor grid, CRT scanlines Music, games, retro tech
swiss Huge type, one signal colour, zero radius Brand, opinion, launch
ink Rice paper, three-layer ink bleed, vermilion seal colour Chinese/ink-wash aesthetics
watercolour Cotton paper, wet-on-wet blooms, pigment pooling at the edges, serif type Human stories, brand films
pencil Sketch paper, 45° hatching, double hand-drawn borders, graphite greys Tutorials, breakdowns, drafts
vox Cream ground, one saturated block, heavy grotesque, halftone dots, hard shadow Explainer / news-style videos
clay Soft lavender ground, rounded slabs, top-lit gradient, inner highlight, soft shadow Product launches, SaaS, youth brands

Chinese names work too (--theme=水墨). Signature elements, taboos and the code API for each style are in assets/STYLES.md (Chinese).

# Contact sheet: one style per second, 12 full-frame stills
node scripts/render-video-seek.js assets/style-gallery.html \
  --stills=0.5,1.5,2.5,3.5,4.5,5.5,6.5,7.5,8.5,9.5,10.5,11.5

# A 13s style film: hook / points / metric / outro
bash assets/render.sh assets/style-film.html --duration=13 --fps=60

Cut on the beat

Every edit that feels professional shares one rule: cuts land on the beat. Instead of nudging keyframes with a stopwatch, measure the beat grid once and let the renderer quantise to it:

bash assets/render.sh style-film.html --audio=bgm.mp3 --beats --snap=beats
# ▸ beats: 120.14 BPM · 20 beats · first 0.49s · confidence 0.85
# ▸ duration snapped: 25.2s → 25.5s (+0.3s)
  • --beats analyses --audio; --beats=beats.json reuses a precomputed grid.
  • --snap=beats snaps the total duration up to the next beat.
  • --snap-shots quantises every shot in the story JSON to whole beats (this is the one that actually puts cuts on the beat), printing each change and warning above 25%.
  • Templates get window.__beats; the style library exposes SV.pulse(t) / SV.beatPhase(t).

The analyser (scripts/beats.js) has zero dependencies: ffmpeg → 22.05 kHz mono → log-energy flux → autocorrelation for tempo → phase scan. Measured on a 120 BPM drum loop: BPM within 2, phase within ~9 ms. Speech-only audio reports low confidence instead of inventing a grid.

One command for everything

npx html-to-video doctor                 # environment check: what's missing, how to fix
npx html-to-video templates              # list 14 templates
npx html-to-video styles                 # list 12 styles
npx html-to-video new my-video --template=quote-card
npx html-to-video preview my-video.html --duration=6 --open
npx html-to-video render my-video.html --duration=6 --fps=60

preview serves the page (so ESM/fetch work), freezes its clock via __seekRender, and gives you a scrubbable timeline — every frame you see is the frame the renderer would capture. Space to play, ←/→ to step one frame.

Storyboard first, render after

A 60-second 1080p film takes 3–10 minutes to render. Finding out that shot 4 has a tiny headline after that is expensive. The storyboard pass samples one still per shot, mid-shot (outside the transition window), and lays them out with shot name, duration, timecode and transition:

node scripts/storyboard.js my.html --cols=3 --theme=riso

Output goes to my.storyboard/: per-shot PNGs, a labelled storyboard.html, and a full-page storyboard.png you can paste into a chat. It takes seconds instead of minutes, and it reads window.__sceneTable (exposed by SC.expose) so it knows the real timeline.

Project status

v1.5.0 — used daily, 109 automated tests, MIT. Not a startup, not a framework, no roadmap theatre. It does one thing and reports its own defects:

  • Deterministic — same input → byte-identical output, verified by re-rendering sampled frames in a fresh page and diffing pixels.
  • Frame-exact — real 30/60 fps, not interpolated. Seek to any t.
  • Text that stays text — CJK fonts render properly. No garbled glyphs, no invented characters.
  • Zero API cost — everything runs locally.
  • A quality gate — scripts/qa.js measures motion, dead time, frame-to-frame spikes, determinism, text framing (clipped / overlapping / inside the platform-UI band) and font fallback.
  • Resumable — --resume continues an interrupted render and reuses finished frames atomically.
  • Multi-shot — several shots with real transitions in one HTML file (see below).
  • Built-in assets — 40+ line icons, parameterised cartoon figures, cards/arrows/ connectors and background textures as plain code. No image packs, no licences, no extra bytes.

The contract: frame(t)

window.__seek = (t) => { /* redraw the whole frame at time t */ };
window.__ready = true;

// The renderer injects window.__seekRender = true.
// Then the page must stop its own clock and be driven purely by __seek.
if (window.__seekRender) { renderAt(0); window.__ready = true; }
else { /* browser preview: requestAnimationFrame loop */ }

Rule: each frame is a pure function of t. No Math.random(), no Date.now(), no state carried from the previous frame. Break this and parallel workers drift apart — the determinism check will say so.

assets/animation-skeleton.html implements the contract; start from it.


Features worth knowing about

One cut, every aspect ratio

Publishing the same clip to TikTok, Xiaohongshu and YouTube means four timelines and four watched exports in an editor. Here the layout is already parametric, so another ratio is just another loop:

node scripts/render-video-seek.js bar-chart.html --duration=8 \
  --presets=douyin,xiaohongshu,square --out=dist/chart.mp4
dist/chart-douyin.mp4       1080×1920   TikTok / Reels / Shorts
dist/chart-xiaohongshu.mp4  1080×1440   Xiaohongshu 3:4
dist/chart-square.mp4       1080×1080   Square 1:1
Preset Size For
douyin / tiktok / vertical 1080×1920 TikTok, Reels, Shorts
xiaohongshu / xhs 1080×1440 Xiaohongshu 3:4
instagram / ig 1080×1350 IG feed 4:5
square 1080×1080 Square 1:1
bilibili / youtube / landscape 1920×1080 Landscape 16:9

A single ratio is --preset=xiaohongshu. A mistyped name fails loudly and lists the valid ones — it never quietly renders a wrong-shaped video at 1920×1080.

Honest limit: --presets guarantees the size, not the composition. 9:16 and 16:9 carry different information density; check the 3:4 cut yourself before shipping.

The empty band at the bottom of a vertical render is deliberate, not a layout bug: platform progress bars and buttons live there, so templates reserve a 15% safe area for burned-in captions.

Voiceover drives the duration

The real workflow is script → voiceover → visuals, not "pick 6 seconds then squeeze the audio in":

node scripts/render-video-seek.js title-card.html --audio=voice.m4a
# ▸ audio: 12.48s, no --duration given → video follows the audio (12.5s, rounded up to a frame)
  • Rounded up to a whole frame: half a frame short clips the last phoneme.
  • If you do pass --duration and it differs by more than 0.35s, you get an explicit warning saying which side gets hurt. The old behaviour silently produced a shorter video — the back half of the picture vanished, with nothing in the log but ✓ Done.
  • --audio-fit=pad pads silence instead, keeping the full picture (default trim follows the audio).

Known edge: with trim the output is not millisecond-exact. The video stream is copied (-c:v copy), so ffmpeg can only cut on a frame boundary; measured overshoot is 0.3–0.5s. Exact alignment would mean re-encoding — quality traded for precision, not worth it.

--json for agents

Humans read ✓ Done: xxx.mp4 (1.2 MB · 30fps · 12s) fine. Agents can only regex it, and paths with spaces, CJK, or parentheses break that. Add --json and stdout carries exactly one line:

node scripts/render-video-seek.js title-card.html --duration=6 --json
{"ok":true,"exitCode":0,"output":"…/title-card.mp4","width":1920,"height":1080,
 "fps":60,"duration":6,"frames":360,"bytes":2411724,"warnings":[],"mode":"video"}
  • Progress and human logs move to stderr — JSON.parse(stdout) just works.
  • warnings[] collects argument conflicts, audio/video length mismatches, unknown theme names.
  • duration is the actual file duration (probed with ffmpeg), not the planned value.
  • With --presets, output becomes an array plus presets: [{name,width,height,output,ok,bytes}].

Transparent output — put the text layer over real footage

The cheapest good-looking combination right now is an AI-generated background plus a code-generated text layer (models still mangle type; code places it to the pixel).

node scripts/render-video-seek.js overlay.html --duration=6 --fps=30 --alpha
# → overlay.mov (ProRes 4444, yuva444p10le, transparent background)

In the template, skip the background including full-screen glows when window.__transparent is set — skipping only the base colour leaves corner alpha at 12/255 and washes out the plate underneath:

const TRANSPARENT = !!window.__transparent;
if (!TRANSPARENT) { /* base colour, gradients, glows, light sweep */ }

Cost, measured on 960×540 / 2 s: H.264 ≈ 118 KB, ProRes 4444 ≈ 760 KB.

ProRes only — no VP9 alpha. Encoding the PNG sequence with -pix_fmt yuva420p -auto-alt-ref 0 does write alpha data (7.8 KB → 16.8 KB) but Chromium plays it back with every alpha value at 0 — even the text disappears. Lossless, constant bitrate and -lag-in-frames 0 behave the same. Since it can't be verified, it isn't offered. Export ProRes, then transcode to webm in your NLE if you need small transparent files.

Subtitles from a file, not from hard-coded timings

node scripts/render-video-seek.js subtitle-card.html --duration=8 --srt=voice.srt --cue-log=cues.csv

SRT, WebVTT and JSON all parse. The renderer injects window.__cues once; templates look up the current cue by t and fall back to their own CFG.lines when no file is given. --cue-log writes frame,t,cue_index,cue_start,cue_end — subtract the SRT start from the first frame of each cue and you have the alignment error as a number. Measured on 4 cues / 80 frames: 0 ms.

Keep text out of the bottom 15% — that's the platform UI band. qa.js flags "inside caption band" when you forget.

Multi-shot: one HTML file, a whole clip

A watchable clip is several shots with transitions, not one long animation. Previously you rendered segments and concatenated them with ffmpeg (and computed the offsets yourself). Now the shot list lives in the page and transitions are composited in-page:

node scripts/render-video-seek.js assets/templates/explainer.html --duration=17.6 --fps=30 \
  --gif --poster=6.0

Transitions: fade · dip (through the base colour) · slide · push · rise · zoom (zoom-through) · wipe (with a scan line) · cut. They overlap: total length is Σ shot durations − Σ transition durations, so adding transitions never makes the clip longer. Only transition frames go through an offscreen composite; everything else is drawn straight to the main canvas.

The shot list can be supplied as JSON — much more reliable for an LLM than a few hundred lines of canvas code:

node scripts/render-video-seek.js assets/templates/explainer.html --duration=17.6 --fps=30 \
  --story=shots.json --scene-log=shots.csv
[{ "kind": "title", "id": "hook", "dur": 3.0, "title": "Why is your build so slow?" },
 { "kind": "talk",  "id": "why",  "dur": 3.5, "say": "Don't blame the tool",
   "who": "dev", "pose": "wave", "trans": { "in": "slide", "dur": 0.5 } }]

--scene-log writes the shot table (index,id,start,end,dur,trans_in,trans_dur) and warns when the shot list length disagrees with --duration — otherwise the tail of the clip is silently dead air.

Built-in assets — icons, figures, camera

SH.icon(g, 'bolt', x, y, 48, { color: TH.accent, width: 2.5 });            // 40+ line icons
SH.card(g, { x, y, w, h }, { fill: TH.panel, radius: 18 });                // cards / arrows / connectors
FIG.draw(g, FIG.preset('dev', { x, y, h, t, pose: 'wave' }));              // parameterised figure + speech bubble
CAM.withCam(g, CAM.at(CAM.breath(), t, W, H), W, H, () => drawContent(g)); // continuous camera drift
Library Provides
shapes.js 40+ icons, cards, pills, arrows, orthogonal connectors, dashed frames, progress arcs, grids, dot fields, glows, vignette, flowing aurora background, film grain (seeded, deterministic)
figures.js Parameterised flat characters: 6 hairstyles × 6 moods × 6 poses, any palette, 4 presets, plus FIG.say() speech bubbles
camera.js breath (subtle scale drift — fixes "the frame looks frozen"), kenburns, handheld (seeded pseudo-noise, not Math.random()), shake (decaying impact)

These are drawn in code on purpose: no licence questions, one visual language shared with the theme palette, sharp at any resolution, zero bytes to download. There is deliberately no bitmap character pack or landmark photo pack — those bring licence risk, clash with whatever look you already have, and turn the repo into a binary blob.

Themes — one flag, or one field

All 11 templates read assets/templates/themes.js: midnight (default, dark), paper (light, for docs and projection) and neon (black/violet).

# override from the CLI — no file edits, and per-job in --batch
node scripts/render-video-seek.js assets/templates/bar-chart.html --duration=6 --theme=paper --out=bar-paper.mp4

--theme beats CFG.theme in the page (the renderer injects window.__h2vTheme). A mistyped name is reported with the list of available names instead of silently falling back — and after loading, the renderer reads back which theme actually took effect. Pass an object to CFG.theme to override individual fields (theme: { bg: '#0B1020', accent: '#22D3EE' }).

One HTML, several colourways: outputs default to <html name>.mp4 next to the HTML, so pair --theme with --out (missing parent directories are created) — otherwise the second render overwrites the first:

[{ "html": "bar-chart.html", "duration": 6, "theme": "midnight", "out": "bar-dark.mp4" },
 { "html": "bar-chart.html", "duration": 6, "theme": "paper",    "out": "bar-light.mp4" }]

Copy themes.js alongside the template. Rendering a single HTML still draws correctly from the built-in fallback palette, but the missing script 404s and page errors fail the render by design — add the file, or pass --allow-errors.

Resume an interrupted render

node scripts/render-video-seek.js animation.html --fps=60 --duration=30 --resume
# ▸ resume: reused 1432 frames, 368 to go

Frames are written to .part and renamed, so a kill never leaves a half-PNG pretending to be done. If the parameters changed since last time it refuses to mix two batches. Frame numbers are verified as contiguous before encoding, because a missing frame makes ffmpeg silently end early.


Honest limitations

  • Not photorealistic. No camera footage, no atmosphere, no lens. For that use a video generation model. This tool is for diagrams, explainers, data animation and titles.
  • Determinism is per-machine. System fonts (Microsoft YaHei, PingFang SC) differ across machines. Embed a font subset if you need cross-machine reproducibility.
  • Rendering is CPU work. Roughly 0.5 s per 1080p frame with 4 workers; 10 s at 30 fps takes about 2.5 minutes. Use --stills while iterating.
  • ffmpeg must be a full build. Playwright's bundled ffmpeg is compiled --disable-everything: no H.264, no GIF, not even PNG decode.
  • QA is a smoke detector, not a proof. Thresholds are empirical. Empty frames, clipped text and non-determinism get caught; "this looks boring" does not.
  • Template layouts are tuned per orientation. Adding a new aspect ratio means checking the portrait branch, not just passing new numbers.

CLI

Flag Meaning
--duration / --from / --to length in seconds, or a sub-range (output name keeps the range)
--stills=0.5,2.0 export PNGs at these timestamps, skip encoding
--fps / --width / --height frame rate and resolution
--preset=<name> platform size preset (douyin / xiaohongshu / instagram / square / bilibili …)
--presets=a,b,c render every listed ratio in one command, one file each (suffixed -<preset>)
--concurrency parallel workers (one Chromium page each); auto picks from CPU count and free memory
--audio=<file> mux audio after encoding; without --duration the video follows the audio length
--audio-fit=trim|pad audio/video length mismatch: trim follows the audio (default), pad adds silence to keep the video length
--gop=<sec> keyframe interval (--gop=1 for frame-accurate scrubbing)
--keep-frames keep the PNG sequence on success
--tmpdir=<dir> where the PNG sequence goes (default: next to the HTML). 1080p runs at ~2.4 GB per minute — point it at another drive when the system disk is tight
--resume continue an interrupted render
--alpha transparent ProRes 4444 output
--srt=<file> / --cues=<file> external subtitle timeline
--cue-log=<file> write the cue alignment CSV
--story=<file> shot list as JSON — replaces the whole script (window.__story)
--scene-log=<file> write the shot table CSV; warns if it disagrees with --duration
--gif[=fps,width] also produce a GIF (default 15 fps / 960 px), two-pass palette, no re-render
--poster=<sec> also grab a still as .jpg for a thumbnail
--batch=<file.json> render a queue of jobs in order, print a summary; one failure doesn't discard the rest
--out=<file> explicit output path (default: <html name>.mp4 beside the HTML); parent dirs are created — needed when one HTML ships several colourways
--json stdout carries a single result JSON (output / duration / frames / warnings); logs move to stderr
--headed render with a visible browser window (concurrency drops to 1)
--allow-errors don't fail on page errors (default: page errors → non-zero exit)
--no-serve load via file:// instead of the built-in local HTTP server

Render once, publish everywhere:

node scripts/render-video-seek.js explainer.html --duration=17.6 --fps=30 --gif=15,960 --poster=6.0
node scripts/render-video-seek.js --batch=jobs.json

Page errors cause a non-zero exit on purpose. A console error usually means a blank frame or missing content, and an earlier version would happily report "done" until you pulled a still and looked.

The renderer starts a 127.0.0.1-only HTTP server to load the page, so <script type="module"> and fetch() work — otherwise every ESM animation dies on CORS under file://.


As an agent skill

The repo doubles as a WorkBuddy / Claude skill. Drop it into the skills directory and the assistant can call the renderer directly:

git clone https://github.com/wx4766/html-to-video.git ~/.workbuddy/skills/html-to-video
cd ~/.workbuddy/skills/html-to-video && bash scripts/install-deps.sh

install-deps.sh picks a Playwright version that matches your existing browser cache, installs a full ffmpeg, and records the workspace path.


Tests

npm test       # 100+ cases, ~4 minutes
npm run bench  # measure your own s/frame → bench/bench.md

The most important ones are the control pairs — a checker that always says "fine" is worse than no checker:

Suite Asserts Guards against
qa-clean a healthy template passes over-strict rules → people stop reading the report
qa-detects a deliberately broken page fails rules quietly relaxed → the gate becomes decoration
qa-offscreen text drawn into an offscreen canvas is still found "invisible = unchecked"
qa-dom-text clipped DOM text is reported, multi-line paragraphs are not fixing false positives by deleting the check
templates-contract template capability table matches source, both ways a capability silently disappearing

Read more

A long-form write-up of the design decisions, the four bugs that cost the most time, and the numbers behind the claims: Your HTML page is already a video renderer — it just needs one global function (dev.to)


License

MIT.

assets/lib/motion.js is adapted from huashu-art-motion (MIT), made self-contained. Credit to the original author.