Communitygithub.com

HTML 逐帧成片

把 HTML 或 Canvas 动画逐帧渲染成成片 MP4、GIF 或透明层。自带竖屏 9:16、多镜头、字幕卡和本地渲染器。

HTML 逐帧成片 是什么?

确定性视频:用 HTML 或 Canvas 写 frame(t),无头浏览器逐帧截图,再用 ffmpeg 编成 H.264。同样的输入得到同样的成片。中文依然清晰,按次渲染不另收费。模板含多镜头、9:16、前后对比、流程图和字幕卡。这是画出来的文件,不是对着邀请页录屏。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/wx4766/html-to-video/tree/main

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

html-to-video · Create Videos Frame by Frame with Code

What This Is

It's not AI-generated video; it's drawing videos frame by frame with code:

Write HTML/Canvas as a pure function frame(t)
   → Headless browser seeks to `t` frame by frame, takes a screenshot
   → ffmpeg encodes it into H.264 MP4
Subtitles / camera moves / transitions are all in the code – just modify the code and re-render.

Advantages: Deterministic (same input = same output), frame-level control, perfect Chinese text rendering, zero cost. Best for: Explainers, dynamic infographics, product demos, data visualizations, title sequences. Not suitable for: Live-action feeling or atmospheric shots (use VideoGen for that).

What you can do with it (11 templates in assets/templates/ – just change the text and render):

TemplateUse CaseOutput
explainer.htmlMulti-shot explainer: Intro / person+bubble / key points / bar comparison / outro, with transitions between shots. Accepts a JSON shot list. In landscape mode, if there are exactly 3 key points, they are automatically arranged side-by-side. In portrait mode (1080×1920), there's a separate top-to-bottom layout.--duration=17.6
title-card.htmlVideo intro: Main title pops in letter by letter + subtitle--duration=6
bar-chart.htmlBar chart / ranking: Bars grow sequentially, numbers scroll simultaneously--duration=6
line-chart.htmlLine chart / trend: Line grows + data points light up + floating marker at the end--duration=8
number-counter.htmlBig numbers / KPIs: Counts up from 0 to the target value + up/down label--duration=7
timeline.htmlTimeline / process: Central axis grows, nodes light up one by one--duration=7
vertical-card.htmlPortrait 9:16 card: Large title + big number, for Douyin/Video Account/Reels--duration=6 --width=1080 --height=1920
compare.htmlBefore/After comparison: Left/right columns side-by-side, with a conclusion bar at the bottom--duration=7
flow-steps.htmlFlow chart animation: Steps light up sequentially, the current step is highlighted--duration=7
subtitle-card.htmlSentence-by-sentence explainer card: One sentence appears at a time, with a progress bar at the bottom--duration=8 + --srt
vertical-subtitle.htmlPortrait subtitle card: Keywords highlighted, native 9:16 layout--duration=8 --width=1080 --height=1920 + --srt

Each template is a single, self-contained file with a prominent CFG configuration block at the top. Just change the text/data – you don't need to know Canvas to render a video. All 11 templates support theme switching (see "Changing Colors" section). To create something entirely new, use assets/animation-skeleton.html as a starting point.

Create "a whole video" instead of "a single animation": Use assets/lib/scenes.js to write multi-shot animations with transitions. The shot list can also be externalized with --story=shots.json – it's much more reliable for an LLM to write JSON than hundreds of lines of Canvas code. See the "Multi-Shot and Transitions" section below.

Aspect Ratio: Templates Follow the Viewport, No Manual Adjustment

All 11 templates set the canvas based on window.innerWidth/innerHeight, so they work with any aspect ratio without cropping:

# Landscape 1080p (default)
bash assets/render.sh title-card.html --duration=6 --fps=60
# Portrait 9:16
bash assets/render.sh title-card.html --duration=6 --fps=60 --width=1080 --height=1920
# 720p / 4K, same process
bash assets/render.sh title-card.html --duration=6 --fps=60 --width=1280 --height=720

Templates internally use a three-part scaling system to map the design to the actual canvas:

VariableMeaning1920×10801080×1920
SX = W/1920Horizontal scale, margins10.5625
SY = H/1080Vertical scale, line spacing11.7778
SF = √(SX·SY)Font size, line width, border radius, blur11

Font size uses area-proportional scaling (SF), not just width. When switching to portrait mode, the text doesn't get smaller; the layout just spreads out. At 1920×1080, all three are equal to 1, so the default output is pixel-identical to the old version (measured PSNR = inf).

Your custom animations should follow the same pattern, otherwise changing --width/--height will silently crop the bottom-right corner:

const cv = document.getElementById('stage');
cv.width  = window.innerWidth;      // ← Don't hardcode 1920
cv.height = window.innerHeight;     // ← Don't hardcode 1080

The renderer now actively measures the canvas size once and gives a clear warning if it's smaller than the viewport (no more silent half-images).

First: Install Dependencies (One-Time Setup)

The renderer requires a Node workspace (containing Playwright + full ffmpeg). One command handles it all:

bash scripts/install-deps.sh                       # Workspace defaults to ~/.workbuddy/binaries/node/workspace
bash scripts/install-deps.sh /path/to/workspace    # Or specify a custom path

This script automatically detects the local browser cache, selects a matching Playwright version, installs the full ffmpeg build, and writes the workspace path to ~/.workbuddy/.html2video-env. After that, render.sh / smoke.sh / qa.sh work out of the box.

Manual installation is also possible (the workspace needs playwright + @ffmpeg-installer/ffmpeg), then configure manually:

# ~/.workbuddy/.html2video-env
WB_NODE_WORKSPACE="<absolute path to your node workspace>"

You can also use an environment variable temporarily: export WB_NODE_WORKSPACE="<path>". Do not write the path inside ${VAR:-default} – if the username contains an apostrophe (e.g., O'Brien), bash will interpret the apostrophe as the start of a quote, leading to a bad substitution error.

Four Hard Constraints (Learned from Experience, Don't Repeat Them)

  1. The Playwright version must match the browser cache. playwright-core@X only recognizes the chromium build number declared in its own browsers.json. A mismatch will result in "Executable doesn't exist".

    → Don't guess the version; use scripts/install-deps.sh. It runs scripts/detect-env.js to probe the cache and automatically selects the right version. To probe manually: node scripts/detect-env.js (outputs RECOMMEND_PW=...). Known mappings: build 1148 → 1.49.1, 1155 → 1.50.1, 1169 → 1.52.0, 1187 → 1.55.0, 1208 → 1.58.0.

    Note: In some network environments, the chromium download may repeatedly fail (appearing as incomplete directories in the cache). In such cases, it's better to reuse the existing cache rather than upgrade – that's exactly why the detection script exists.

  2. The ffmpeg bundled with Playwright is useless. ms-playwright/ffmpeg-*/ffmpeg-win64.exe is compiled with --disable-everything. It only has VP8 encoding + webm container – no H.264, no GIF, not even PNG decoding. You must use the full version from @ffmpeg-installer/ffmpeg.

  3. ESM doesn't respect NODE_PATH. import 'playwright' in .mjs will inevitably fail with ERR_MODULE_NOT_FOUND. → All helper scripts must be written in CJS (.cjs / require).

  4. PATH is unreliable. Especially on Git Bash / Windows, Windows-style paths added to PATH can be mangled by path conversion. → Don't rely on PATH to find ffmpeg; use the FFMPEG environment variable (the renderer already supports this).

Rendering Workflow

Step 0 (Easiest Path): Pick a Template and Change the Text

cp assets/templates/bar-chart.html my-video.html
# Open my-video.html, only edit the title / data / note in the CFG block at the top
bash assets/render.sh my-video.html --duration=6 --fps=60

The templates are subtitle-safe: text won't be placed in the bottom 15% of the screen (which is often covered by burned-in subtitles). Follow this same practice when designing your own layouts.

Step 1: Write the Animation HTML (When Templates Aren't Enough)

Use assets/animation-skeleton.html as a skeleton. Core contract (what the renderer relies on to work):

// These two must be exposed
window.__seek = (t) => { /* Completely redraw the frame for time `t` */ };
window.__ready = true;                 // Ready signal

// The renderer will inject `window.__seekRender = true`.
// At that point, you must [FREEZE any self-driven clock] and rely entirely on external __seek calls (to ensure determinism).
if (window.__seekRender) { renderAt(0); window.__ready = true; }
else { /* Browser preview: requestAnimationFrame self-driven loop */ }

Golden rule: Each frame is a pure function of time t. Do not rely on "previous frame" state. Do not use Math.random() / Date.now() / performance.now() – they would cause different outputs for the same input, triggering the determinism check in QA, and multi-worker parallel rendering would produce inconsistent results.

The canvas is the sole output: <canvas width=1920 height=1080>, CSS positioned at left:0; top:0, body margin:0. The renderer uses clip:{x:0,y:0,width:W,height:H} to capture the top-left corner, so the canvas must exactly fill the viewport and not be centered.

(The renderer also supports DOM/CSS/SVG animations; they only need to expose __seek – but note that DOM animations are slower due to per-frame re-layout. Pure Canvas is generally more stable and faster.)

Forbidden class names (the renderer injects CSS that unconditionally hides them; elements will disappear): .no-record .progress .progress-bar .counter .tCur .phases .phase-label .phase .replay .masthead .kicker .title .footer Also, don't add <button> to elements with position:fixed/sticky, as heuristic rules will hide them as playback controls. (To ensure an element appears in the final video, add data-record-keep="true" to exempt it from heuristic hiding.)

Chinese fonts: '"Microsoft YaHei", "PingFang SC", system-ui, sans-serif'; monospace: 'ui-monospace, Consolas, monospace'.

Step 2: Smoke Test (Don't Skip)

First, capture just a few frames to confirm the contract is ready, there are no console errors, and the frame isn't blank, before running a full render.

bash assets/smoke.sh animation.html
# SMOKE_T="0,1,2" SMOKE_W=1280 SMOKE_H=720 bash assets/smoke.sh animation.html

A faster iteration approach: render only a short segment to see the dynamic effect in seconds.

bash assets/render.sh animation.html --from=3 --to=5 --fps=30   # Render only from 3s to 5s
bash assets/render.sh animation.html --stills=1,3,5            # Export only three still frames

Step 3: Full Render

bash assets/render.sh animation.html --duration=12 --fps=60 --width=1920 --height=1080 --concurrency=4

Common Parameters:

ParameterDescription
--duration / --from / --toDuration / Render only a segment (in seconds). When a range is given, the output filename becomes name.start-end.mp4, won't overwrite the finished product.
--stills=0.5,2.0Export only these timestamps as PNG, no encoding
--fps / --width / --heightFrame rate and resolution
--preset=<name>Predefined sizes by platform: douyin / tiktok / vertical (1080×1920), xiaohongshu (1080×1440), instagram (1080×1350), square (1080×1080), bilibili / youtube / landscape (1920×1080)
--presets=a,b,cOne command, multiple aspect ratios, each producing a separate file (filename auto-appended with -ratio-name). Use this for cross-platform publishing
--theme=<name>Switch theme (midnight / paper / neon), overriding CFG.theme in the template without editing the file. An invalid name will give a clear warning and fall back to midnight.
--out=<file>Specify output path (default: same directory and name as the HTML file). Use this when rendering multiple themes from one HTML file to avoid overwriting; parent directories are created automatically if they don't exist.
--jsonstdout outputs only a single JSON result line (with output / duration / frames / warnings etc.). Human-readable logs go to stderr. Useful for Agent parsing.
--concurrencyNumber of parallel workers (each worker is a separate Chromium page; be mindful of memory). Set to auto to let the system choose based on CPU and memory.
--audio=<file>Auto-mix audio after encoding (video stream is copied directly, no re-encoding). When --duration is not specified, the video duration is automatically determined by the audio length (rounded up to the nearest frame, ensuring the final sound isn't cut off).
--audio-fit=trim|padWhat to do if audio and video lengths don't match: trim ends with the audio track (default) / pad pads the audio with silence to preserve the video length.
--gop=<seconds>Keyframe interval, e.g., --gop=1 for a keyframe every second, useful for seeking by second during post-processing.
--keep-framesKeep per-frame PNGs even on success (useful for re-encoding with a different CRF without recapturing).
--tmpdir=<directory>Where to put the per-frame PNGs (default: next to the HTML file). For 1080p at one minute (~2.4 GB), point to a different drive if the system drive has insufficient space.
--resumeWas the previous render interrupted halfway? Resume from where it left off, reusing already rendered frames. It will refuse if parameters don't match the previous run (see below).
--alphaOutput with transparent background (ProRes 4444 .mov). Use to overlay on live-action / AI-generated footage (see below).
--srt=<file>External subtitle timeline (SRT / VTT / JSON), injected into window.__cues (see below). Equivalent parameter: --cues.
--cue-log=<file>Export a CSV with frame,t,cue_index,cue_start,cue_end to verify subtitle alignment.
--story=<file>Shot list JSON, injected into window.__story, replacing the entire script (see "Multi-shot and Transitions").
--scene-log=<file>Export a CSV of the shot timeline; warns if the storyboard duration doesn't match --duration.
--gif[=fps,width]Generate a GIF after rendering (default 15fps / width 960), with a two-stage palette optimization, no re-render.
--poster=<second>Extract a single poster frame .jpg after rendering, useful for websites / social media.
--batch=<file.json>Render a sequence of tasks from a JSON file, with a summary table at the end; a single task failure doesn't cancel already rendered tasks, but the overall exit code is non-zero.
--headedRender with the browser window open (debugging; concurrency is automatically set to 1 to avoid a cascade of windows).
--allow-errorsPage errors don't cause failure (default: any error → non-zero exit).
--keep-chromeDon't hide playback controls (debugging).
--readytimeout=8Seconds to wait for __seek to be ready (increase for large fonts / large data that start slowly).
--no-serveLoad the page using file:// (default uses a local http server, see below).

ESM / fetch projects can be rendered directly. By default, the renderer starts a local http server bound to 127.0.0.1 to load the page – under file://, <script type="module"> and fetch() would be blocked by CORS, so animations built with Vite/esbuild or that read local JSON files wouldn't work. Using http avoids these issues. The renderer prints this address at the start; for debugging, paste it directly into a browser to see the console. If the server fails to start, it falls back to file:// with a warning (just a warning, not a failure).

  • Output is an .mp4 with the same name as the HTML file.
  • Frame count = fps × duration. 12s@60fps = 720 frames, which takes ~6-7 minutes in practice (concurrency=4).
  • Encoding takes about 1 minute (crf 18 / preset medium).

If there are page errors at the end of rendering, the exit code will be non-zero (but the video file is still produced, allowing you to check it).

Interrupted Rendering: Don't Start Over

For a long video (e.g., 60fps × 30s = 1800 frames), crashing at 80% is frustrating if you have to start from scratch. Just add --resume to continue:

bash assets/render.sh animation.html --fps=60 --duration=30 --resume
# ▸ resume: Reused 1432 frames, need to render 368 more

Three rules ensure that "a resumed video is identical to a one-shot video":

  • Frames are written as .part first, then renamed. A frame killed during capture won't leave a half-PNG pretending to be "complete", so reused frames are genuinely finished.
  • If parameters have changed, it refuses outright. If the last run was 30fps and this is 60fps, it would interleave two different batches and produce a video with inconsistent pacing and no error – so it checks fps / dimensions / range / render mode, and if any differ, it exits and prints "previous vs this", leaving you to decide whether to delete the cache.
  • Frame numbers must be contiguous. A single missing frame would cause ffmpeg to silently finish early, making the video shorter. So before encoding, it checks every frame in the [start, end) range and reports any gaps, advising you to use --resume to fill them.

The temporary directory is fixed to .seek-tmp-resume/ next to the HTML file (when --resume is not used, it's still a temporary directory, so concurrent renders don't interfere). It's cleaned up after rendering; use --keep-frames if you want to keep it. This is intentional: page errors often mean "all black / half the screen missing". Previous versions would report success until you reviewed the frames, but now it prints the error before encoding and declares failure at the end.

Transparent Output: Overlaying Text on Live-Action Footage

The most cost-effective combination today is AI-generated backgrounds with code-generated text/textures (AI models always produce blurry text, while code renders text with pixel precision). --alpha is designed for this workflow:

bash assets/render.sh animation.html --duration=6 --fps=30 --alpha
# → animation.mov (ProRes 4444 / yuva444p10le, transparent background)

The template needs to cooperate on one thing: skip drawing the background layer when window.__transparent is true. Currently, title-card, compare, flow-steps, subtitle-card, vertical-subtitle, and explainer (6 out of 11) are already written this way; just add --alpha for a transparent effect. The remaining 5 are full-screen charts (bar/line/number/timeline/vertical card) that inherently need a background, so adding --alpha will only produce an opaque video. For your own animations, follow this pattern:

const TRANSPARENT = !!window.__transparent;
function drawFrame(t) {
  g.clearRect(0, 0, W, H);
  if (!TRANSPARENT) {
    // Background / full-screen gradient / light rays – all these must be skipped
    g.fillStyle = '#080A10';
    g.fillRect(0, 0, W, H);
  }
  // Draw text, shapes as usual
}

Full-screen translucent halos must also be skipped. Even at 5% opacity, when overlaid on live-action footage, it will make the entire area appear washed out, and an editor would immediately think "the footage is dirty". In tests: when only the background was skipped but the light rays were kept, the corners had alpha=12 (not 0); after skipping everything, the corners had alpha=0, and only 3.7% of pixels were non-transparent (all text).

Size trade-off (measured at 960×540 / 2s / 20 frames):

OutputSizeUse Case
H.264 .mp4118 KBDirect delivery
ProRes 4444 .mov760 KB (6.5×)Drag-and-drop into editing software for overlay

Only ProRes, no VP9. We tried --pix_fmt yuva420p + -auto-alt-ref 0. The file size increased from 7.8KB to 16.8KB (alpha data was written), but Chromium decoded it with all alpha values as 0 – even text was transparent. Switching to lossless, fixed bitrate, -lag-in-frames 0 – all produced the same result. Since we couldn't self-verify correctness, we don't offer it as an option. For small transparent assets, use ProRes → import into editing software → export as webm; that's the editor's job.

Audio Alignment: Audio First, Then Video Length

The real workflow is "write script → record audio → match visuals", not "set 6 seconds first, then figure out how to fit the audio in". So, when --audio is given without --duration, the video length is determined by the audio:

bash assets/render.sh title-card.html --audio=voice.m4a --story=shots.json
# ▸ audio: Audio is 12.48s, --duration not specified → Video length set by audio (12.5s, rounded up to the nearest frame)
  • Rounded up to the nearest frame, not rounded: a half-frame truncation would cut off the last syllable.
  • If --duration is explicitly given and the difference from the audio exceeds 0.35s, a clear warning is printed, explaining the magnitude and which side is affected. The old version would silently produce a short video – the entire second half of the video would disappear, with only a single ✓ Done in the log.
  • --audio-fit=pad: If the audio is shorter than the video, pad with silence to preserve the entire video length (default trim ends with the audio track).

Note: With trim, the final video won't be exactly equal to the audio duration in seconds. The video stream is copied with -c:v copy, and ffmpeg can only end at frame boundaries. In practice, an extra 0.3–0.5s remains. For millisecond precision, re-encoding is required, but that trades quality for precision – and it's usually not worth it; the extra frames at the end are rarely noticeable.

Multi-Platform Publishing: One Command, All Aspect Ratios

To publish the same video on Douyin, Xiaohongshu, and Bilibili, you'd need a different sequence and export for each ratio in editing software. Here, since the layout is parameterized, re-rendering is just another loop:

bash assets/render.sh bar-chart.html --duration=8 \
  --presets=douyin,xiaohongshu,square --out=dist/chart.mp4
# → dist/chart-douyin.mp4       1080×1920
#   dist/chart-xiaohongshu.mp4  1080×1440
#   dist/chart-square.mp4       1080×1080
PresetDimensionsFor
douyin / tiktok / vertical1080×1920Douyin, Kuaishou, WeChat Video Account, Reels, Shorts
xiaohongshu / xhs1080×1440Xiaohongshu 3:4
instagram / ig1080×1350IG Feed 4:5
square1080×1080Square 1:1
bilibili / youtube / landscape1920×1080Landscape 16:9
  • Use --preset=xiaohongshu for a single ratio; --presets=a,b for multiple (at least two; for just one, use --preset).
  • A misspelled preset name will cause immediate failure and list available options – it won't silently render a wrong-aspect-ratio video at 1920×1080.
  • Whether the layout holds up is another question: templates are viewport-adaptive, but 9:16 and 16:9 have different information densities. At 3:4, the font might be too large / content too crowded – you'll need to check visually. --presets only guarantees correct dimensions, not good composition.
  • The intentional blank space at the bottom of portrait layouts is not a layout bug: platform progress bars and buttons sit there, and templates uniformly leave a 15% safe zone for burned-in subtitles.
  • Mutually exclusive with --stills (one produces multiple videos, the other only exports stills); cannot be nested with --batch.

For Agent Use: --json

The primary caller of this tool is an AI Agent. ✓ Done: xxx.mp4 (1.2 MB · 30fps · 12s) is human-readable, but an Agent would have to guess with regex – paths with spaces, CJK characters, or parentheses would break. --json structures the result:

bash assets/render.sh title-card.html --duration=6 --json
{"ok":true,"exitCode":0,"input":"…/title-card.html","output":"…/title-card.mp4",
 "width":1920,"height":1080,"fps":60,"duration":6,"frames":360,"bytes":2411724,
 "gif":null,"poster":null,"warnings":[],"mode":"video","theme":null,"preset":null}
  • stdout contains only this one line, progress and human-readable logs go to stderr – directly JSON.parse(stdout).
  • warnings is an array: parameter conflicts, audio/video length mismatches, unknown theme names – all go here. Empty array = clean.
  • duration is the actual length of the final video (as read back by ffmpeg), not the planned value.
  • With --presets, output becomes an array, and an additional presets: [{name,width,height,output,ok,bytes}] is included.

External Subtitles: Audio Changes, Subtitles Follow

Writing subtitles directly in the template's CFG.lines is simplest, but once audio is involved, timestamps must follow the audio – you can't manually count seconds every time you change a line. That's why we support feeding an external subtitle file directly:

bash assets/render.sh subtitle-card.html --duration=8 --srt=voice.srt --cue-log=cues.csv
  • Supports SRT (HH:MM:SS,mmm), WebVTT (MM:SS.mmm also works), and JSON ([{start,end,text}]).
  • Parsed once, injected into window.__cues. The template then checks which cue to display based on the current frame's t. When --srt is used, the template reads __cues first; if it's empty, it falls back to CFG.lines, so the same template works with both methods.
  • --cue-log outputs a CSV with frame,t,cue_index,cue_start,cue_end. This turns "are the subtitles in sync?" into a calculable number: subtract the SRT start time from the first frame time of each cue for the timing error. Measured: 4 cues / 80 frames, each cue had an error of 0 ms, and the frame coverage matched the theoretical value exactly.

The template only needs two lines to support this:

const cues = (window.__cues && window.__cues.length) ? window.__cues : CFG.lines;
const cur = cues.find(c => t >= c.start && t < c.end) || cues[cues.length - 1];

Don't place subtitles in the bottom 15%. That's the area for platform progress bars / like buttons; writing there is pointless. qa.js will warn about subtitles entering that zone. All four new portrait templates have this safety zone built-in.

Changing Colors: One Field Change, No Canvas Code Touched

assets/templates/themes.js contains three palettes, and all 11 templates are connected. Two ways to switch:

# ① Command-line switch (recommended): No file changes, one theme per render
bash assets/render.sh bar-chart.html --duration=6 --theme=paper --out=bar-paper.mp4
# ② Edit the file: Hardcode it in the `CFG` at the top of the template
const CFG = {
  theme: 'paper',   // midnight (default, dark) | paper (light) | neon (black-purple neon)
  ...
};

Command-line takes precedence over CFG.theme (the renderer injects window.__h2vTheme, and the template reads h2vTheme(window.__h2vTheme || CFG.theme)). So the same HTML can be batch-rendered in three color schemes:

[{ "html": "my-video.html", "out": "dist/my-video-dark.mp4", "theme": "midnight" },
 { "html": "my-video.html", "out": "dist/my-video-light.mp4", "theme": "paper" },
 { "html": "my-video.html", "out": "dist/my-video-neon.mp4", "theme": "neon" }]
bash assets/render.sh my-video.html --duration=6 --batch=my-batch.json
# → dist/my-video-dark.mp4
#   dist/my-video-light.mp4
#   dist/my-video-neon.mp4

Multi-Shot and Transitions (Advanced)

A single animation (one timeline, no cuts) is fine for a 6-second title card, but a 2-minute explainer needs multiple shots, transitions, and camera cuts. The built-in explainer.html template already handles this, but for full control, write a custom multi-shot animation:

assets/lib/scenes.js provides a SceneManager class. Basic usage:

const SM = new SceneManager([
  { id: 'intro', duration: 4,  draw: (t, p) => { /* draw intro; p = 0..1 progress over this shot */ } },
  { id: 'body',  duration: 8,  draw: (t, p) => { /* draw body */ } },
  { id: 'outro', duration: 3,  draw: (t, p) => { /* draw outro */ } },
]);

// In __seek:
window.__seek = (t) => {
  g.clearRect(0, 0, W, H);
  const scene = SM.seek(t);
  scene.draw(t - scene.start, scene.progress);
};

The --story parameter allows externalizing the shot list as a JSON file. This file is injected into window.__story, replacing the hardcoded one in the HTML. The JSON structure:

[
  { "shot": "intro", "duration": 4,  "data": { "title": "My Video" } },
  { "shot": "body",  "duration": 8,  "data": { "points": ["A", "B", "C"] } },
  { "shot": "outro", "duration": 3,  "data": {} }
]

The template reads this and adjusts its behavior. Combined with --srt, you can build a complete asset pipeline: LLM writes JSON storyboard + audio + subtitles → agent renders the final video.

Batch Rendering: One Command, Many Videos

For rendering a series of videos from different HTML files or with different parameters, use --batch:

bash assets/render.sh --batch=tasks.json

tasks.json:

[
  { "html": "explainer.html", "duration": 10, "theme": "midnight" },
  { "html": "title-card.html", "duration": 5, "width": 1080, "height": 1920, "theme": "paper", "out": "title-portrait.mp4" },
  { "html": "bar-chart.html", "duration": 6, "story": "data/shot-list.json" }
]
  • Each task supports the same parameters as the command line.
  • Tasks are rendered sequentially. Progress is printed per task.
  • If a task fails (non-zero exit), the remaining tasks continue. The overall exit code is non-zero if any task failed.
  • A summary table is printed at the end.

Quality Assurance

Determinism Check

Since a core promise is "same input → same output", the qa.sh script runs a determinism test:

bash assets/qa.sh my-video.html --duration=6 --fps=60

It renders the video twice and compares them pixel-by-pixel using ffmpeg's PSNR comparison. If the two videos are not identical, it reports the difference. This catches issues like Math.random(), Date.now(), or uninitialized state.

Logging and Debugging

For debugging, the --headed flag shows the browser window. Combined with --keep-chrome, you can see which elements are being hidden by heuristic rules.

The --no-serve flag loads the page via file://, which --headed can use for faster iteration (though CORS restrictions apply for fetch/ESM).

Environment and Configuration

Environment Variables

VariableDescriptionDefault
WB_NODE_WORKSPACEPath to Node workspace with playwright + ffmpegRead from ~/.workbuddy/.html2video-env
FFMPEGPath to custom ffmpeg binary (if not using the one from the workspace)Auto-detected from workspace
SMOKE_TComma-separated list of timestamps (in seconds) for smoke test frames0
SMOKE_W / SMOKE_HWidth and height for smoke test frames1920 / 1080

File Structure

.
├── assets/
│   ├── render.sh           # Main rendering script
│   ├── smoke.sh            # Smoke test script
│   ├── qa.sh               # Quality assurance / determinism test
│   ├── templates/          # 11 ready-to-use templates
│   │   ├── explainer.html
│   │   ├── title-card.html
│   │   ├── bar-chart.html
│   │   ├── line-chart.html
│   │   ├── number-counter.html
│   │   ├── timeline.html
│   │   ├── vertical-card.html
│   │   ├── compare.html
│   │   ├── flow-steps.html
│   │   ├── subtitle-card.html
│   │   └── vertical-subtitle.html
│   │   └── themes.js       # Theme definitions (midnight, paper, neon)
│   └── lib/
│       └── scenes.js       # SceneManager for multi-shot videos
├── scripts/
│   ├── install-deps.sh     # Dependency installer
│   └── detect-env.js       # Environment detection (Playwright version, ffmpeg)
└── README.md               # This file

Troubleshooting

"Executable doesn't exist"

Playwright version mismatch. Run scripts/install-deps.sh or manually detect the correct version:

node scripts/detect-env.js

Canvas is not full-screen

Verify that:

  • The canvas element has width and height set to window.innerWidth and window.innerHeight.
  • There's no CSS centering or scaling.
  • Body has margin: 0.

The renderer now actively measures and warns.

Frames look wrong / half-screen

Use --headed --no-serve to see the page in a browser and check the console for errors.

Output video is too short

Check if --duration is too short, or if the audio with --audio-fit=trim is causing early truncation. Use --resume with the correct parameters to get all frames.

Subtitles are off

Use --cue-log to export a CSV and compare the cue start times with the frame timestamps. The CSV shows frame number, time in seconds, and cue index/timestamps.

"Bad substitution" error

If the username contains an apostrophe (e.g., O'Brien), don't use ${VAR:-default} syntax in shell scripts. Use a direct assignment or environment variable.

License

This tool is provided as-is, without warranty. Suitable for commercial production use, but test thoroughly for your specific use case.

相关技能