html-to-video · diff
git:20260420.6269f63 to git:20260922.ece18df
38 added, 197 removed. Audit A to A.
---
name: html-to-video
- description: Record any HTML page or URL as MP4 (H.264) or WebM (VP9) at a configurable resolution and frame rate via Playwright headless + ffmpeg. Optionally exports a first-frame JPG poster. Use for marketing trailers, tutorial screencasts, animated docs, or demo GIFs.
+ description: Record any HTML page or URL as MP4 (H.264) or WebM (VP9) at configurable resolution and frame rate via headless Playwright + ffmpeg, with optional first-frame JPG poster. Use for marketing trailers, tutorial screencasts (e.g. from a local dev server), video for docs/changelogs/social posts, archiving an animated page, or demo GIFs.
---
- ## Requirements
-
- - **Node.js** >= 20
- - **Playwright** with Chromium (installed via `npm install`)
- - **ffmpeg** in PATH — `brew install ffmpeg` (macOS), `apt install ffmpeg` (Linux), or [ffmpeg.org](https://ffmpeg.org/download.html) (Windows)
-
- ## When to use
-
- - Rendering marketing trailers from animated HTML pages
- - Capturing tutorial screencasts from a local dev server
- - Generating video assets for docs, changelogs, or social posts
- - Archiving an animated page as a portable video file
- - Pre-rendering GIF-style demos (see GIF recipe below)
-
- ## How to run
-
- ### API (in code)
-
- ```js
- import { recordHtml } from './plugins/atelier/skills/html-to-video/index.mjs';
-
- // Record 10 seconds of an animated page as 1080p MP4
- await recordHtml({
- url: 'http://localhost:5173/trailer.html',
- duration: 10,
- width: 1920,
- height: 1080,
- fps: 30,
- outPath: 'dist/trailer.mp4',
- });
-
- // WebM with poster image
- await recordHtml({
- url: 'https://example.com/demo',
- duration: 5,
- outPath: 'dist/demo.webm',
- format: 'webm',
- poster: true, // also writes dist/demo-poster.jpg
- });
- ```
+ Requires Node >= 20, Playwright Chromium (via `npm install`), ffmpeg on PATH (`brew install ffmpeg`, `apt install ffmpeg`, or ffmpeg.org on Windows).
- ### Local HTML file
+ ## Run
```js
- import { pathToFileURL } from 'url';
import { recordHtml } from './plugins/atelier/skills/html-to-video/index.mjs';
-
- const url = pathToFileURL('/path/to/page.html').href;
- await recordHtml({ url, outPath: 'output/preview.mp4', duration: 3 });
+ await recordHtml({ url: 'http://localhost:5173/trailer.html', duration: 10, width: 1920, height: 1080, fps: 30, outPath: 'dist/trailer.mp4' });
+ await recordHtml({ url, outPath: 'dist/demo.webm', format: 'webm', poster: true }); // also writes dist/demo-poster.jpg
```
- ### CLI
-
```bash
- # Record 5 seconds (default) as MP4
- node plugins/atelier/skills/html-to-video/index.mjs https://example.com output/video.mp4
-
- # Record 10 seconds
- node plugins/atelier/skills/html-to-video/index.mjs https://example.com output/video.mp4 10
-
- # WebM (inferred from extension)
- node plugins/atelier/skills/html-to-video/index.mjs https://example.com output/video.webm
+ node plugins/atelier/skills/html-to-video/index.mjs <url> <out.mp4|out.webm> [durationSec=5] # format from extension
```
- ## Options
-
- | Option | Type | Default | Description |
- |--------|------|---------|-------------|
- | `url` | string | — | Page URL or `file://` URL |
- | `duration` | number | `5` | Recording length in seconds |
- | `width` | number | `1280` | Viewport width in pixels |
- | `height` | number | `720` | Viewport height in pixels |
- | `fps` | number | `30` | Frames per second |
- | `outPath` | string | — | Output file path (`.mp4` or `.webm`) |
- | `format` | `'mp4'\|'webm'` | from ext | Override format detection |
- | `poster` | boolean | `false` | Export first frame as `<name>-poster.jpg` |
-
- ## Codec details
+ Options:
+ - `url`: page URL or `file://` URL (local file: `pathToFileURL(path).href`)
+ - `outPath`: `.mp4` or `.webm`
+ - `duration` 5 (s), `width` 1280, `height` 720, `fps` 30
+ - `format`: `'mp4'|'webm'`, default from extension
+ - `poster`: default `false`; exports first frame as `<name>-poster.jpg`
+ - `audioSource`: see below
- | Format | Codec | Args |
- |--------|-------|------|
- | MP4 | H.264 (libx264) | `-preset medium -crf 20 -pix_fmt yuv420p` |
- | WebM | VP9 (libvpx-vp9) | `-b:v 2M -pix_fmt yuv420p` |
+ Codecs: MP4 H.264 libx264 `-preset medium -crf 20 -pix_fmt yuv420p`; WebM VP9 libvpx-vp9 `-b:v 2M -pix_fmt yuv420p`.
## Audio capture
- By default, `recordHtml()` produces silent video. To include audio, supply
- an `audioSource` callback that writes a WAV file to the path you're handed;
- `recordHtml` will mux it against the silent video via ffmpeg before
- returning.
-
- ```js
- await recordHtml({
- url: 'file:///trailer.html',
- duration: 10,
- outPath: 'dist/trailer.mp4',
- audioSource: async (outWavPath) => {
- // Synthesize or copy audio into outWavPath.
- // 48kHz 16-bit PCM mono/stereo is the recommended format.
- const { renderAudio } = await import('./my-trailer-audio.mjs');
- renderAudio(outWavPath);
- },
- });
- ```
-
- Contract:
-
- - Omitting `audioSource` is a no-op — output is silent (identical to v0.1).
- - The callback receives an absolute path inside the skill's temp dir; write
- the file and resolve.
- - If the file is missing or ≤ 44 bytes (WAV header only), `recordHtml`
- throws `audioSource wrote an empty or missing file: <path>`.
- - Video is stream-copied during mux (`-c:v copy`), so it isn't re-encoded.
- Audio is re-encoded to `aac -b:a 192k` (MP4) or `libopus -b:a 160k`
- (WebM).
-
- ### Why not MediaRecorder / Web Audio capture in headless Chromium
-
- The obvious approach — inject `AudioContext → MediaStreamDestination →
- MediaRecorder` into the page, capture the destination stream, mux the
- result — was prototyped by the TideWane trailer pipeline and abandoned
- before shipping. Concrete failure modes:
+ Output is silent by default (identical to v0.1). Pass `audioSource: async (outWavPath) => {…}`; it receives an absolute path inside the skill's temp dir, must write a WAV there (48kHz 16-bit PCM mono/stereo recommended) and resolve. `recordHtml` muxes it before returning.
+ - Missing file or ≤ 44 bytes (WAV header only) throws `audioSource wrote an empty or missing file: <path>`.
+ - Video is stream-copied (`-c:v copy`), not re-encoded. Audio is re-encoded to `aac -b:a 192k` (MP4) or `libopus -b:a 160k` (WebM).
- - Audio drifts 10–50 ms per minute against video even with frame-locked
- screenshot capture. Any cut-to-SFX timing is visibly wrong.
- - `MediaRecorder` in headless silently emits zeros on some Chromium
- versions; the browser provides no failure signal.
- - Worker-thread scheduling jitter under CI load makes the drift
- non-deterministic.
+ Never capture Web Audio in headless Chromium (`AudioContext → MediaStreamDestination → MediaRecorder`). The TideWane trailer pipeline prototyped and abandoned it:
+ - Audio drifts 10-50 ms/min against video even with frame-locked screenshot capture; cut-to-SFX timing is visibly wrong.
+ - Headless `MediaRecorder` silently emits zeros on some Chromium versions, with no failure signal.
+ - CI worker-thread jitter makes the drift non-deterministic; output can also glitch.
- Pure-Node synthesis — which is what `audioSource` enables — is the
- replacement. Atelier doesn't ship a synth library; the caller owns that.
- Reference implementations live in `tidewane-build/scripts/trailers/render-*-audio.mjs`
- (hand-written percussive sequencing, writes 48 kHz PCM WAV). Full design
- rationale in `docs/superpowers/specs/2026-04-15-html-to-video-v0.2-audio.md`.
+ Synthesize in pure Node instead (OfflineAudioContext polyfill or hand-rolled synth); the browser pass produces video only. Atelier ships no synth library; the caller owns it. References: `tidewane-build/scripts/trailers/render-*-audio.mjs` (hand-written percussive sequencing, 48 kHz PCM WAV); rationale in `docs/superpowers/specs/2026-04-15-html-to-video-v0.2-audio.md`. Manual mux: `ffmpeg -i video.mp4 -i audio.wav -c:v copy -c:a aac -shortest out.mp4`.
## Production directives for marketing trailers
- The basic `recordHtml()` contract is a constant-fps Playwright capture. That's
- fine for tutorial screencasts and doc animations. For **marketing trailers
- published on a store page / Steam / YouTube**, the default loses fidelity in
- ways that only show up after upload. These directives come from the goneIdle /
- TideWane trailer pipeline — every rule here was learned from a specific
- regression.
-
- ### Capture at 60fps via virtual clock, not wall-clock ticks
-
- Playwright's screencast / screenshot cadence is not frame-locked; under CPU
- load it will drift to 15-30 effective fps while still producing a 60fps MP4
- with duplicated frames. Viewers read that as "stutter."
-
- Fix: drive animation from a virtual clock (`window.__vclock` or similar)
- that advances exactly `1 / fps` seconds per captured frame. Ignore wall
- time entirely — each capture tick calls a `tickFrame(t)` function the HTML
- exposes, then Playwright screenshots once, then the clock advances. The
- result is genuinely 60 per-frame-different screenshots per second of output,
- verified by hash-diffing consecutive frames.
-
- ### Synthesize audio offline, not in the browser
-
- Headless Chromium's Web Audio is unreliable — output can glitch, desync from
- video, or silently emit zeros. Pre-render the trailer audio track in pure
- Node (OfflineAudioContext polyfill or a hand-rolled synth) to a `.wav`, then
- `ffmpeg -i video.mp4 -i audio.wav -c:v copy -c:a aac -shortest out.mp4`.
- The browser pass produces video only.
-
- ### Validate output with ffprobe before ship
-
- Before publishing any trailer asset, probe it:
-
- ```bash
- ffprobe -v error -select_streams v:0 \
- -show_entries stream=width,height,r_frame_rate,nb_frames,duration \
- -of json trailer.mp4
- ```
-
- Gate the pipeline on: exact expected resolution, `r_frame_rate == fps/1`,
- `nb_frames == duration * fps ± 1`, audio track present (when expected),
- peak audio < −1 dBFS. A separate `trailer-tripwire` audit tool (see
- `github.com/EthanY33/trailer-tripwire`) also catches AI-default content
- tells: fade-heavy edits (>40% fades reads as slideshow), flat audio RMS
- (<3dB stdev reads as procedural drone), silent ratio, palette mono-mood.
-
- ### Content guidance
-
- Pattern-level rules learned from A/B'ing trailers:
-
- - **Real mockups over bullet lists.** Five seconds of an actual UI screenshot
- outperforms a 5-second bullet list every time. Treat the trailer as a
- demo, not a spec sheet.
- - **Concrete numbers over vibes.** "60fps · 3 MB · zero deps" reads as real;
- "fast, light, simple" reads as AI slop. Pull the numbers from your own
- telemetry before writing copy.
- - **No ambient pads, ever.** Flat synth drones are the single strongest
- "AI-generated" tell. Use percussive / rhythmic elements, even if
- sparse. The audio RMS curve should have visible stdev > 3dB.
- - **Short outros.** >1.5 s of black-frame / logo-hold at the end reads as
- unfinished. Cap outros at 0.8 s.
-
- ### Where the production pipeline actually lives
+ `recordHtml()` is a constant-fps capture: fine for screencasts and doc animations. For trailers published to a store page, Steam, or YouTube it loses fidelity that shows only after upload. Each rule below (from the goneIdle/TideWane pipeline) came from a specific regression.
- The skill's `recordHtml()` is intentionally minimal — a general-purpose
- HTML-to-video recorder. The full 60fps virtual-clock + offline-synth +
- ffprobe-gated pipeline is a separate implementation in the goneIdle repo
- (`scripts/record-trailers.mjs` + `docs/trailer-production-directive.md`).
- Atelier's v0.2 spec (`docs/superpowers/specs/2026-04-15-html-to-video-v0.2-audio.md`)
- tracks absorbing the audio-capture half of that pipeline into this skill.
+ - Capture 60fps via virtual clock, not wall-clock ticks. Playwright capture isn't frame-locked; under CPU load it drifts to 15-30 effective fps while still writing a 60fps MP4 of duplicated frames (reads as stutter). Drive animation from a virtual clock (`window.__vclock` or similar) advancing exactly `1 / fps` s per captured frame, ignoring wall time: each tick calls the page's exposed `tickFrame(t)`, Playwright screenshots once, then the clock advances. Verify by hash-diffing consecutive frames (every frame should differ).
+ - Synthesize audio offline (above).
+ - Validate with ffprobe before ship:
+ ```bash
+ ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate,nb_frames,duration -of json trailer.mp4
+ ```
+ Gate on: exact expected resolution, `r_frame_rate == fps/1`, `nb_frames == duration * fps ± 1`, audio track present (when expected), peak audio < -1 dBFS. `trailer-tripwire` (`github.com/EthanY33/trailer-tripwire`) also catches AI-default tells: >40% fades (reads as slideshow), audio RMS stdev <3dB (procedural drone), silent ratio, palette mono-mood.
+ - Content (from A/B tests):
+ - Real UI mockups over bullet lists: a demo, not a spec sheet.
+ - Concrete numbers ("60fps · 3 MB · zero deps") over vibes ("fast, light, simple" reads as AI slop). Pull numbers from your own telemetry.
+ - No ambient pads, ever (strongest "AI-generated" tell). Use percussive/rhythmic elements, even sparse; RMS stdev > 3dB.
+ - Cap outros at 0.8 s (>1.5 s of black/logo hold reads unfinished).
- ## GIF conversion recipe
+ The full 60fps virtual-clock + offline-synth + ffprobe-gated pipeline lives in the goneIdle repo (`scripts/record-trailers.mjs`, `docs/trailer-production-directive.md`); `recordHtml()` stays minimal. The v0.2 spec (`docs/superpowers/specs/2026-04-15-html-to-video-v0.2-audio.md`) tracks absorbing its audio half here.
- Convert the output MP4 to a high-quality GIF using ffmpeg's two-pass palette method:
+ ## GIF (two-pass palette)
```bash
- # Generate palette
ffmpeg -i output/video.mp4 -vf "fps=15,scale=640:-1:flags=lanczos,palettegen" palette.png
-
- # Render GIF using palette
- ffmpeg -i output/video.mp4 -i palette.png \
- -filter_complex "fps=15,scale=640:-1:flags=lanczos[x];[x][1:v]paletteuse" \
- output/demo.gif
+ ffmpeg -i output/video.mp4 -i palette.png -filter_complex "fps=15,scale=640:-1:flags=lanczos[x];[x][1:v]paletteuse" output/demo.gif
```