319 lines
15 KiB
Markdown
319 lines
15 KiB
Markdown
<div align="center">
|
||
|
||
# html-explainer
|
||
|
||
**An Agent Skill: give it a topic, get back a narrated explainer video with subtitles and covers.**
|
||
|
||
Write scenes in HTML → deterministic frame-by-frame rendering → a real MP4. Everything runs
|
||
locally; the core pipeline needs no API key and charges no per-render fee.
|
||
|
||
[](LICENSE)
|
||
[](CHANGELOG.md)
|
||
[](SKILL.md)
|
||
[](#install)
|
||
[](#install)
|
||
[](#install)
|
||
[](#requirements)
|
||
[](#requirements)
|
||
|
||
[简体中文](README.md) · [English](README.en.md)
|
||
|
||
</div>
|
||
|
||
---
|
||
|
||
## What it is
|
||
|
||
`html-explainer` turns a topic into a **narrated explainer video with burned-in subtitles and a
|
||
progress bar**, with the visuals written in HTML/CSS/GSAP.
|
||
|
||
**It is an Agent Skill** (`SKILL.md` + `scripts/` + `references/`), following the
|
||
[Agent Skills](https://code.claude.com/docs/en/skills) convention, so Claude Code, OpenAI Codex,
|
||
WorkBuddy, Cursor, Gemini CLI and others can load it directly. Underneath it is plain Python and
|
||
Node scripts, so you can also run it by hand.
|
||
|
||
You say one sentence:
|
||
|
||
> Make a 3-minute explainer video about why the sky is blue.
|
||
|
||
The skill then walks the agent through: research → script → voiceover → subtitles and beats →
|
||
scene authoring in one of 23 styles → render → QC → covers.
|
||
|
||
---
|
||
|
||
## Install
|
||
|
||
### One-line install (recommended)
|
||
|
||
Send this to your agent:
|
||
|
||
```text
|
||
Install this Skill into my local environment: https://github.com/OneMoh/html-explainer.git
|
||
Put it in your skills directory and check/install the required runtime
|
||
(Python 3.9+ / Node 18+ / Chrome or Edge / ffmpeg)
|
||
```
|
||
|
||
It clones into the right directory, runs the environment check and installs whatever is missing.
|
||
Then **start a new session** so the agent rescans its skills directory.
|
||
|
||
### Manual install
|
||
|
||
The repository root **is** the skill directory (`SKILL.md` sits at the root), so clone it straight
|
||
into your skills directory — there is no subdirectory to copy:
|
||
|
||
```bash
|
||
git clone https://github.com/OneMoh/html-explainer.git ~/.workbuddy/skills/html-explainer
|
||
bash ~/.workbuddy/skills/html-explainer/setup_env.sh --install
|
||
```
|
||
|
||
On Windows `~` is `C:\Users\<you>`.
|
||
|
||
### Skills directory by agent
|
||
|
||
| Agent | Personal (global) | Project (in-repo) |
|
||
|---|---|---|
|
||
| **WorkBuddy** | `~/.workbuddy/skills/` | `<workspace>/.workbuddy/skills/` |
|
||
| **Claude Code** | `~/.claude/skills/` | `.claude/skills/` |
|
||
| **OpenAI Codex** | `~/.codex/skills/` | `.codex/skills/` or `.agents/skills/` |
|
||
| **Gemini CLI** | `~/.gemini/skills/` | `.gemini/skills/` or `.agents/skills/` |
|
||
| **Cursor** | `~/.cursor/skills/` | `.cursor/skills/` |
|
||
| **GitHub Copilot / VS Code** | `~/.copilot/skills/` | `.github/skills/` |
|
||
| **OpenCode** | `~/.config/opencode/skills/` | `.opencode/skills/` |
|
||
| **Windsurf** | `~/.windsurf/skills/` | `.windsurf/skills/` |
|
||
| Others (common convention) | `~/.agents/skills/` | `.agents/skills/` |
|
||
|
||
If unsure where to put it, prefer `.agents/skills/` — most tools read it. (Claude Code is the
|
||
exception and only reads `.claude/skills/`.)
|
||
|
||
### Requirements
|
||
|
||
| Item | Minimum | Notes |
|
||
|---|---|---|
|
||
| Python | 3.9+ | `edge-tts==7.2.8` (pinned deliberately — v7 changed the boundary API), `numpy`, `pillow`, `imageio-ffmpeg` |
|
||
| Node.js | 18+ | Used by the renderer and the cover builder |
|
||
| Browser | Chrome or Edge | Auto-detected; playwright chromium (~115MB, one time) only if neither exists |
|
||
| ffmpeg | any version | Looked up on `PATH`; falls back to the static binary bundled with `imageio-ffmpeg` |
|
||
| Disk | ~2GB per video | Frame PNGs are large; safe to delete after muxing |
|
||
|
||
`bash setup_env.sh` only reports what is missing; `--install` installs it. No admin rights needed.
|
||
|
||
---
|
||
|
||
## Video project layout
|
||
|
||
Say one sentence and the agent walks the whole pipeline inside a **video project directory**;
|
||
output lands in `out/`: `slug.mp4`, `cover_169.png`, `cover_34.png`, `slug.srt`, `slug.vtt`, plus
|
||
`qc_report.md` and `qc_sheet.jpg`.
|
||
|
||
A **video project** looks like this:
|
||
|
||
```
|
||
my-video/
|
||
├── project.json # slug, fps, size, voice, rate, scene order, chapters
|
||
├── narration.json # [{ id, text }] — pipes "|" split subtitle blocks
|
||
├── theme.css # every colour, as CSS variables
|
||
├── frames/ # one HTML per scene; <id>.beats.js is generated, never hand-edited
|
||
├── audio/ render/ research/ script/
|
||
└── out/ # MP4, covers, SRT/VTT, QC report
|
||
```
|
||
|
||
---
|
||
|
||
## Why not screen recording
|
||
|
||
Most "HTML to video" tools launch a browser, play the animation and record the screen. That breeds
|
||
a whole class of **intermittent** bugs — the animation played before recording started, a web font
|
||
loaded late so half the video used the wrong typeface, the first frames captured the pre-animation
|
||
state.
|
||
|
||
`html-explainer` does not record. It **seeks**: position the GSAP timeline at that instant
|
||
(`tl.pause(t, false)`), sync every CSS animation (`document.getAnimations().currentTime`), wait
|
||
two animation frames, screenshot. Frame 1204 of one render is pixel-identical to frame 1204 of the
|
||
next — so QC can point-sample, a single scene can be re-rendered alone, and a whole class of timing
|
||
bugs becomes impossible.
|
||
|
||
The trade-off: **every animation must be seekable.** Wall-clock animation (`setInterval`,
|
||
`requestAnimationFrame` counters, CSS `transition` entrances) cannot work and is rejected by
|
||
`lint_frames.py`.
|
||
|
||
---
|
||
|
||
## Features
|
||
|
||
| | |
|
||
|---|---|
|
||
| **Beats anchored to narration** | Scenes position animation by **matching subtitle text** (`B('block text')`), never by hardcoded frame numbers. Edit the script and the whole video re-times with zero scene-code changes. |
|
||
| **Word-boundary subtitles** | Timing comes from edge-tts `WordBoundary` events, never interpolated by character count — in Chinese two phrases of equal length can differ 3× in duration. |
|
||
| **Two-level clock** | Frame length uses MP3 **container duration** (keeps A/V in sync); the final subtitle block ends on **actual speech end**. |
|
||
| **23 visual styles** | 8 categories, each with its canvas, type scale, timeline and colour discipline recorded. No designing from a blank page. |
|
||
| **Dual covers** | Every video ships a 16:9 cover plus an **independently re-laid-out** 3:4 cover — not a crop. Cropping loses 57.8% of the width. |
|
||
| **Pre-render audit + QC** | `lint_frames.py` reports contract violations before you spend render time; `qc_check.py` checks loudness, drift and samples frames. |
|
||
| **Structurally offline** | GSAP vendored; browser auto-detected; ffmpeg falls back to the `imageio-ffmpeg` static binary; web fonts are banned by contract. |
|
||
|
||
---
|
||
|
||
## Pipeline
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A["Topic"] --> B["Research<br/><i>every figure sourced</i>"]
|
||
B --> C["narration.json<br/><i>pipes split subtitle blocks</i>"]
|
||
C --> D["tts_build.py<br/><i>edge-tts to MP3 + word boundaries</i>"]
|
||
D --> E["timeline_build.py<br/><i>global axis + stitched track</i>"]
|
||
E --> F["subs.py<br/><i>subs.json, srt/vtt, beats.js</i>"]
|
||
F --> G["frames/*.html<br/><i>one scene per line, styled from the library</i>"]
|
||
G --> H{"lint_frames.py<br/>eight contract rules"}
|
||
H -->|fail| G
|
||
H -->|pass| I["render_video.mjs<br/><i>seek per frame to PNG</i>"]
|
||
I --> J["ffmpeg<br/><i>H.264 + AAC mux</i>"]
|
||
J --> K[("out/slug.mp4")]
|
||
I --> L["qc_check.py<br/><i>loudness, drift, contact sheet</i>"]
|
||
K --> M["cover_build.mjs<br/><i>cover_169.png, cover_34.png</i>"]
|
||
|
||
style K fill:#1f6feb,color:#fff
|
||
style H fill:#8957e5,color:#fff
|
||
style M fill:#238636,color:#fff
|
||
```
|
||
|
||
---
|
||
|
||
## The `B()` beat system
|
||
|
||
`subs.py` emits one `beats.js` per scene, exposing each subtitle block by its **source text**, so
|
||
scene animation can be anchored directly to the moment words are spoken:
|
||
|
||
```js
|
||
tl.fromTo('.card', { y: 60, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.7 }, B('right-hand card'));
|
||
tl.fromTo('.verdict', { scale: 0.8 }, { scale: 1, duration: 0.6 }, B('the verdict') + 0.2);
|
||
```
|
||
|
||
`B('block text')` is the moment those words start being spoken; `Be('block text')` is the moment
|
||
they finish.
|
||
|
||
This is this project's own design, and it is the part that changes how the work feels: edit the
|
||
narration once, re-run three commands, and every scene re-times itself. Compare with hardcoded
|
||
frame numbers — one edited sentence means re-aligning the entire video by hand.
|
||
|
||
**Fallbacks are forbidden.** `B('x') || 3.2` hides "beat not found" behind a stale number. A miss
|
||
should fail the build — a mis-timed video is far worse than a failed build.
|
||
|
||
---
|
||
|
||
## Visual style library
|
||
|
||
**Do not design scenes from a blank page.** 23 styles across 8 categories, each recording its
|
||
canvas, type scale, timeline structure and colour discipline — full catalogue in
|
||
[`references/style-catalog.md`](references/style-catalog.md) (Chinese).
|
||
|
||
It covers bold signal cards, luxurious minimal whitespace, NYT-grade data charts, Swiss grid,
|
||
glitch art, film light leaks, fluid hero backgrounds, brand logo outros, VFX text cursors, 9:16
|
||
social formats, product promos and more.
|
||
|
||
Two classes, with very different adaptation costs:
|
||
|
||
- **`rich` (12)** — single file, pure CSS `@keyframes`. The seek renderer drives them **with no
|
||
changes**; adaptation is three steps: swap web fonts for a system stack, lift bottom elements out
|
||
of the subtitle band, drop in real content.
|
||
- **`gsap` (11)** — multiple compositions loaded from a CDN. **Do not port the code.** Take only
|
||
the visual DNA and re-express it in CSS keyframes.
|
||
|
||
Rotate 2–4 styles per project. Eight scenes sharing one look reads as monotonous.
|
||
|
||
---
|
||
|
||
## Dual covers
|
||
|
||
Every video ships **two covers that are siblings, not crops**. Cropping 16:9 to 3:4 leaves 810px of
|
||
1920px — **57.8% of the width is gone**, and any full-width headline gets sliced in half. So both
|
||
share one visual DNA, but each is **laid out again from scratch**:
|
||
|
||
| Element | 16:9 (`1920×1080`) | 3:4 (`1440×1080`) |
|
||
|---|---|---|
|
||
| Paradox visual | Right side, side by side | Top, stacked vertically |
|
||
| Hook | Bottom-left, two lines, ≥96px | Bottom, three lines, ≥120px |
|
||
| Max characters per line | 7 | 5 |
|
||
|
||
`new_project.py` generates the templates for both sizes directly; output is 2× by default. Full
|
||
rules and the pre-upload checklist: [`references/cover-guide.md`](references/cover-guide.md)
|
||
(Chinese).
|
||
|
||
---
|
||
|
||
## Troubleshooting
|
||
|
||
| Symptom | Cause | Fix |
|
||
|---|---|---|
|
||
| Every frame is a frozen first frame, no error | Timeline not registered on `window.__tl`; or `page.evaluate` received a **string** instead of a function literal (Playwright evaluates it once, so the body never runs) | Pass a real function literal |
|
||
| Video comes out 1280×720 | `viewport` was passed to `browser.launch()` — it is a *context*-level option | Pass it to `newPage()` |
|
||
| Cover renders at 1× | Same trap, `deviceScaleFactor` twin | Pass to `newPage()` + `screenshot({ scale: 'device' })` |
|
||
| Numbers render but never move | The seek suppressed `onUpdate` callbacks | Renderer fixed (`pause(t, false)`); use a transform-based number reel in scenes |
|
||
| `No such file or directory` on Windows | Non-ASCII path — Windows ffmpeg reads UTF-8 as ANSI | Keep paths ASCII |
|
||
|
||
**`references/lessons.md` is the most valuable file in this repository.** 34 numbered entries, each
|
||
one a bug where "the video looked fine but was wrong" — including how it was misdiagnosed at first.
|
||
Read it before debugging from scratch.
|
||
|
||
---
|
||
|
||
## Who this is for
|
||
|
||
**Good fit:** turning articles, docs or a topic into narrated video at scale; already using an AI
|
||
coding agent and preferring to describe visuals in HTML rather than learn motion software; needing
|
||
reproducible output; needing to run offline or refusing per-render fees.
|
||
|
||
**Poor fit:** live-action editing, talking-head footage, re-creating an existing video; wanting a
|
||
drag-and-drop editor (scenes are code, deliberately); wanting React component animation as the
|
||
authoring model — that is the domain of the sibling project
|
||
[`anything2explainer`](https://github.com/Vincentwei1021/anything2explainer).
|
||
|
||
---
|
||
|
||
## Reference project ideas
|
||
|
||
`html-explainer` did not invent its methodology. It has two clear sources of ideas. Both are
|
||
**independent projects by different authors — not the same author as this one**, and this
|
||
repository neither bundles nor depends on any of their code at runtime. What was carried over is
|
||
design specification and engineering experience.
|
||
|
||
- [**Vincentwei1021/anything2explainer**](https://github.com/Vincentwei1021/anything2explainer)
|
||
(TypeScript / Remotion) — topic in, narrated explainer video out. What this project inherited
|
||
from that line is the **narration and A/V-sync methodology**: word-boundary subtitles, the
|
||
two-level clock, speech-rate calibration, and the QC criteria.
|
||
*Difference:* it is Remotion plus React component animation; this project is HTML/CSS/GSAP plus
|
||
deterministic seeking.
|
||
|
||
- [**nexu-io/html-video**](https://github.com/nexu-io/html-video) (Apache-2.0, by the Open Design
|
||
team) — HTML to real MP4 on your own machine. What this project inherited from that line is the
|
||
**23-style visual catalogue**, transcribed from its template design specifications. Seven of
|
||
those styles trace further back to MIT-licensed design work.
|
||
*Difference:* it relies on real-time recording; this project seeks frame by frame, so output is
|
||
reproducible.
|
||
|
||
**This project's own parts:** the deterministic seek renderer, `B()` beat anchoring, the dual-cover
|
||
system, and the criteria in `lint_frames.py` / `qc_check.py`. Full attribution and the per-style
|
||
mapping are in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) (Chinese).
|
||
|
||
If you want React component animation or Studio-style visual collaboration, go straight to the two
|
||
projects above. If you want to edit one sentence without re-aligning the whole video by hand, and
|
||
you want frame-level reproducibility, use this one.
|
||
|
||
---
|
||
|
||
## Contributing
|
||
|
||
Issues and PRs are welcome. The most valuable contribution is a **diagnosed silent failure** added
|
||
to `references/lessons.md` — write down how it first misled you, the evidence that proved the cause,
|
||
the fix, and the general rule that follows. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) first
|
||
(Chinese).
|
||
|
||
---
|
||
|
||
## Licence and acknowledgements
|
||
|
||
[MIT](LICENSE) © 2026 Moh
|
||
|
||
MIT covers the original code and documentation. Bundled third-party components and the derived
|
||
style specifications remain under their own terms; GSAP is bundled under GreenSock's
|
||
[standard "no charge" licence](https://gsap.com/standard-license). Full notices are in
|
||
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) (Chinese).
|