docs: 重构 README 并统一文档语言为中文

README 精简(中文 528 → 364 行,-31%;英文 552 → 390 行,-29%):
- 删除使用者不需要的深层细节,改为指向 references/ 与 SKILL.md:
  横向对比全表、八条契约表、project.json 逐字段说明、仓库目录树、B() 代码详解
- 第一定位改为「Agent Skill」,而不是「一条渲染流水线」
- 新增「安装」章:一句话安装 prompt + 九个智能体的技能目录对照表 + 环境要求
- 新增「参考思路与姊妹项目」:anything2explainer 与 html-video,并明确标注
  两者为不同作者的独立项目,本项目运行时不依赖其代码

文档语言统一为中文(README.en.md 除外):
CHANGELOG.md、CONTRIBUTING.md、THIRD_PARTY_NOTICES.md、PR/issue 模板、CI 注释。
许可证原文(MIT / Apache-2.0)与 GSAP 自带 README 属第三方逐字文本,未翻译。

跨平台可移植性修复(原先隐含 WorkBuddy 专属路径):
- peek_frame.mjs 的技能根兜底改为遍历各智能体技能目录
  (.workbuddy / .claude / .codex / .gemini / .cursor / .agents),已用隔离夹具正反验证
- setup_env.sh 标注哪几个候选是 WorkBuddy 托管路径
- SKILL.md 新增「跨 Agent 安装」章,安装路径不再写死单一平台

修正一处事实错误:THIRD_PARTY_NOTICES 原称两个方法来源项目「均为作者本人作品」,
与事实不符,已改为「他人独立项目」并补充来源链接。
This commit is contained in:
MohZhuang committed 2026-09-21 13:11:22 +08:00
1 parent 6474719119
commit 2581e2c685
13 files changed
+874 -1198

No files matched your search

+35 -36
View File
@@ -1,75 +1,74 @@
name: Bug report
description: Something rendered wrong, failed silently, or crashed.
name: Bug 报告
description: 画面渲错了、静默失败,或者直接崩了。
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for the report. This pipeline fails **silently** by design — a broken
frame usually still produces a plausible-looking video. The more of the detail
below you can fill in, the faster this gets diagnosed.
感谢反馈。这条流水线是**静默**失败的 —— 一个坏掉的帧通常还是能产出一条看着挺像样的
视频。下面这些信息你填得越全,定位得越快。
- type: dropdown
id: stage
attributes:
label: Which stage failed?
label: 哪个阶段出错了?
options:
- "0 · new_project (scaffold)"
- "1 · tts_build (voiceover)"
- "2 · timeline_build (global timeline)"
- "3 · subs (subtitles / beats)"
- "4 · lint_frames (static audit)"
- "5 · render_video (rendering)"
- "6 · qc_check (audit)"
- "7 · cover_build (covers)"
- "setup_env / environment"
- "Not sure"
- "0 · new_project(建项目)"
- "1 · tts_build(配音)"
- "2 · timeline_build(全局时间轴)"
- "3 · subs(字幕 / 节拍)"
- "4 · lint_frames(静态体检)"
- "5 · render_video(渲染)"
- "6 · qc_check(体检)"
- "7 · cover_build(封面)"
- "setup_env / 环境问题"
- "不确定"
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Include the exact command you ran and the full output.
label: 发生了什么?
description: 请附上你执行的完整命令与完整输出。
placeholder: |
Command: node scripts/render_video.mjs . --preview 30
Expected: 30s preview with animated cards
Actual: preview renders, but the number counter never moves
命令:node scripts/render_video.mjs . --preview 30
期望:30 秒预览,卡片有动画
实际:预览渲出来了,但数字计数器一直不动
validations:
required: true
- type: textarea
id: symptom-class
attributes:
label: Did it fail loudly or silently?
label: 它是报错了,还是静默出错?
description: >
Silent failures are the interesting ones here. If playback works but the output
is wrong (frozen element, desynced subtitle, missing text), say so explicitly.
静默失败才是这里最有价值的一类。如果视频能播但输出是错的
(元素卡住、字幕不同步、文字缺失),请明确写出来。
validations:
required: false
- type: textarea
id: environment
attributes:
label: Environment
description: "Run these and paste the result."
label: 环境
description: "跑一下这些,把结果贴上来。"
value: |
- OS:
- `node -v`:
- `python -V`:
- Shell (Git Bash / PowerShell / zsh):
- Browser used for rendering (Chrome / Edge / playwright chromium):
- ffmpeg source (PATH / imageio-ffmpeg):
- 操作系统:
- `node -v`:
- `python -V`:
- Shell(Git Bash / PowerShell / zsh):
- 渲染用浏览器(Chrome / Edge / playwright chromium):
- ffmpeg 来源(PATH / imageio-ffmpeg):
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: The `version:` field in SKILL.md, or the release tag.
label: 版本
description: SKILL.md 里的 `version:` 字段,或发布 tag。
placeholder: "1.2.2"
validations:
required: true
@@ -77,7 +76,7 @@ body:
- type: textarea
id: qc-output
attributes:
label: QC output (if you got as far as rendering)
description: Paste `out/qc_report.md`, or attach the contact sheet.
label: QC 输出(如果你已经跑到渲染那一步)
description: 贴 `out/qc_report.md`,或附上抽帧速览图。
validations:
required: false
+7 -7
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: Read the frame contract first
- name: 先读画面契约
url: https://github.com/OneMoh/html-explainer/blob/main/references/frame-contract.md
about: Most "my frame renders but looks wrong" reports are explained by the eight contract rules.
- name: Check the lessons ledger
about: 绝大多数「我的画面能渲但看着不对」都能被八条契约规则解释。
- name: 先查踩坑台账
url: https://github.com/OneMoh/html-explainer/blob/main/references/lessons.md
about: Known silent-failure modes and their diagnoses. Worth a search before filing.
- name: Setup and environment issues
url: https://github.com/OneMoh/html-explainer/blob/main/README.md#疑难排查
about: Run `bash setup_env.sh` and compare against the troubleshooting table (English version: README.en.md#troubleshooting).
about: 已知的静默失败模式及其定位方法。提 issue 之前值得先搜一遍。
- name: 安装与环境问题
url: https://github.com/OneMoh/html-explainer/blob/main/README.md
about: 先跑 `bash setup_env.sh`,再对照 README 的「疑难排查」一节。
+18 -20
View File
@@ -1,51 +1,49 @@
name: Feature request
description: Propose a new capability, style, or workflow improvement.
name: 功能建议
description: 提议一个新能力、新风格,或流程改进。
title: "[Feature] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
This project deliberately keeps a small surface: HTML frames in, MP4 out, with
audio-video sync that actually holds. Proposals that add a build system, a plugin
API, or a cloud dependency are usually out of scope — but say so if you think
there's an important exception.
本项目刻意保持很小的接口面:HTML 帧进,MP4 出,音画同步稳得住。会增加构建系统、
插件 API 或云依赖的提议,一般都在范围之外 —— 但如果你认为有重要的例外,请说明。
- type: textarea
id: problem
attributes:
label: What problem does this solve?
description: Describe the situation you're in, not the API you want.
label: 它解决什么问题?
description: 描述你处的场景,而不是你想要的 API。
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed approach
label: 你建议的做法
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
label: 涉及范围
options:
- Visual styles / templates
- Narration / TTS
- Subtitles / beat sync
- Rendering
- QC / auditing
- Covers
- Docs
- Other
- 画面风格 / 模板
- 解说 / TTS
- 字幕 / 节拍同步
- 渲染
- QC / 体检
- 封面
- 文档
- 其它
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Including "just do it manually" — it's a valid answer.
label: 考虑过的替代方案
description: 包括「干脆手工做」—— 这也是个有效答案。
validations:
required: false
+18 -18
View File
@@ -1,32 +1,32 @@
## What this changes
## 这次改了什么
<!-- One or two sentences. What was wrong, and what does this do about it? -->
<!-- 一两句话。原来错在哪,这个改动做了什么? -->
## Why
## 为什么要改
<!--
If this fixes a silent failure, explain what was misbehaving and how you proved the
cause. "I tried X, it still broke; I changed Y, it worked" is genuinely useful here —
this pipeline produces plausible-looking output while being wrong.
如果这修的是一个静默失败,请说明它原来表现成什么样、你又是怎么证明病因的。
「我试了 X,还是坏的;改了 Y,就好了」在这里是真正有用的信息 ——
这条流水线的输出经常看着挺像样,其实是错的。
-->
## How it was verified
## 怎么验证的
<!--
Not "it should work". What did you actually run?
e.g. `node scripts/peek_frame.mjs . hook --at 25,50,85` and inspected the 50% frame
不要写「应该能跑」。你实际跑了什么?
例如 `node scripts/peek_frame.mjs . hook --at 25,50,85`,并检查了 50% 那一帧
-->
## Checklist
## 检查清单
- [ ] `python scripts/lint_frames.py --project <project>` reports no FAILs for frames I touched
- [ ] I actually rendered the change and looked at the output
- [ ] No external fonts, CDN links, or hardcoded colour literals introduced
- [ ] New lesson (if any) appended to `references/lessons.md` with the next number
- [ ] `python scripts/check_integrity.py` passes
- [ ] `README.md` and `README.en.md` updated together if user-facing behaviour changed
- [ ] `THIRD_PARTY_NOTICES.md` updated if third-party content was added or re-derived
- [ ] `python scripts/lint_frames.py --project <project>` 对我动过的帧没有 FAIL
- [ ] 我真的渲染过这次改动,并看过输出
- [ ] 没有引入外部字体、CDN 链接或硬编码颜色字面量
- [ ] 新增的踩坑记录(如果有)已按下一个编号追加到 `references/lessons.md`
- [ ] `python scripts/check_integrity.py` 通过
- [ ] 涉及使用者可见行为的改动,`README.md` 与 `README.en.md` 已同步更新
- [ ] 如果新增或再衍生了第三方内容,`THIRD_PARTY_NOTICES.md` 已更新
## Related issues
## 相关 issue
<!-- Closes #... -->
+11 -13
View File
@@ -7,7 +7,7 @@ on:
branches: [main]
workflow_dispatch:
# Only the latest run per branch matters; cancel superseded ones.
# 每个分支只关心最新一次运行;被取代的自动取消。
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -19,8 +19,8 @@ jobs:
strategy:
fail-fast: false
matrix:
# Windows + Git Bash is the primary development platform, so it is
# tested alongside Linux rather than treated as an afterthought.
# Windows + Git Bash 是主要开发平台,所以和 Linux 一起测,
# 而不是被当成事后补的。
os: [ubuntu-latest, windows-latest]
python: ["3.9", "3.13"]
@@ -35,27 +35,27 @@ jobs:
with:
node-version: "22"
# --- Repo self-consistency: version drift, catalog drift, stray CDN links ---
# --- 仓库自洽性:版本号漂移、风格目录漂移、残留外链 ---
- name: Repository integrity check
run: python scripts/check_integrity.py
# --- Python scripts must at least compile on the oldest supported version ---
# --- Python 脚本至少在支持的最低版本上要能编译 ---
- name: Compile Python scripts
run: python -m compileall -q scripts
# --- Frame audit tool must be importable and its CLI must respond ---
# --- 画面体检工具要能被导入,且命令行要能响应 ---
- name: Lint tool smoke test
run: python scripts/lint_frames.py --help
- name: Integrity checker smoke test
run: python scripts/check_integrity.py --root .
# --- Shell entrypoint syntax ---
# --- Shell 入口语法 ---
- name: Check setup_env.sh syntax
shell: bash
run: bash -n setup_env.sh
# --- Renderer / cover builder / peek tool syntax ---
# --- 渲染器 / 封面器 / 单帧速览工具的语法 ---
- name: Check Node module syntax
shell: bash
run: |
@@ -69,11 +69,9 @@ jobs:
shell: bash
run: |
set -e
# Frames must never reach out to a CDN or web font: offline rendering
# degrades silently rather than failing.
# Scope to HTML only — markdown docs legitimately *mention* these URLs
# (e.g. the guide explaining that you must delete them), and GSAP's own
# bundled README documents its CDN option.
# 画面帧绝不允许引 CDN 或网络字体:离线渲染不会报错,只会静默劣化。
# 只扫 HTML —— markdown 文档里*提到*这些 URL 是合理的
# (比如正是指南在教你要把它们删掉),而 GSAP 自带的 README 也会介绍它的 CDN 用法。
hits=$(grep -rInE --include='*.html' \
'https?://[^"'"'"' ]*(fonts\.googleapis|fonts\.gstatic|cdn\.)' . \
|| true)
+81 -105
View File
@@ -1,142 +1,118 @@
# Changelog
# 变更日志
All notable changes to `html-explainer` are documented here.
`html-explainer` 的所有重要变更都记录在这里。
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
Versions before `1.2.2` were private, internal releases. `1.2.2` is the first public
release on GitHub.
`1.2.2` 之前的版本都是私有内部发布。**`1.2.2` 是 GitHub 上的首个公开版本。**
---
## [1.2.2] — 2026-09-21 — first public release
## [1.2.2] — 2026-09-21 — 首个公开版本
### Added
### 新增
- **`scripts/lint_frames.py`** — static pre-render audit of every scene frame. Checks the
eight frame-contract rules plus known pitfalls before spending time on a full render:
external fonts / CDN links, hardcoded colour literals, missing `window.__tl`
registration, CDN GSAP, wall-clock logic (`setInterval`, `requestAnimationFrame`
counters), CSS `transition` entrances, `B('…') || number` fallbacks, content intruding
into the subtitle safe zone, and missing CJK font families.
- **`scripts/peek_frame.mjs`** — single-frame viewer. Seeks one scene to a few time points
and screenshots them in seconds, so frame design can be judged without rendering the
whole video. `--at` takes percentages, not frame numbers.
- **Bilingual project README** (`README.md` in Chinese, `README.en.md` in English).
- **Open-source governance files**: `LICENSE` (MIT), `THIRD_PARTY_NOTICES.md`,
`CHANGELOG.md`, `CONTRIBUTING.md`, `licenses/Apache-2.0.txt`, `.gitignore`,
`.gitattributes`, GitHub issue/PR templates, and a CI workflow.
- **`scripts/lint_frames.py`** —— 渲染前的画面静态体检。在花时间渲染整片之前,先检查八条
画面契约与已知坑:外部字体 / CDN 引用、硬编码颜色字面量、缺失 `window.__tl` 注册、
CDN 版 GSAP、墙钟逻辑(`setInterval`、`requestAnimationFrame` 计数)、CSS `transition`
入场、`B('…') || 数字` 兜底写法、侵入字幕禁区的内容、缺失中文字体族。
- **`scripts/peek_frame.mjs`** —— 单帧速览。把一个场景 seek 到若干时点截图,耗时是**秒级**,
于是不用渲全片就能判断画面设计。`--at` 收的是百分比,不是帧号。
- **双语项目 README**(`README.md` 中文为默认,`README.en.md` 英文)。
- **开源治理文件**:`LICENSE`(MIT)、`THIRD_PARTY_NOTICES.md`、`CHANGELOG.md`、
`CONTRIBUTING.md`、`licenses/Apache-2.0.txt`、`.gitignore`、`.gitattributes`、
GitHub issue / PR 模板,以及 CI 工作流。
### Fixed
### 修复
- **`speech_end_sec` disagreed between a cold run and a cached re-run** (lessons #31).
`_result()` measured the already-trimmed output file, so the head-trim amount was
subtracted twice: a cold run reported `7.541s`, a cached re-run reported `7.696s` —
a 0.155s discrepancy that broke the "two runs are frame-identical" guarantee and made
the final subtitle block disappear early. Now stored in post-trim coordinates.
- **`speech_end_sec` 在冷跑与缓存重跑之间不一致**(lessons #31)。`_result()` 量的是已经裁过
静音的输出文件,于是头部裁剪量被减了两次:冷跑报 `7.541s`,缓存重跑报 `7.696s` ——
0.155s 的差异破坏了「两次渲染逐帧一致」的保证,并让末块字幕提前消失。现在统一存**裁后
坐标系**下的值。
### Changed
### 变更
- Documentation: `scripts/lint_frames.py` and `scripts/peek_frame.mjs` added to the
command sequence and the file reference table (both previously undocumented).
- Documentation: the lint stage is now an explicit step in the workflow, between subtitle
generation and rendering.
- Documentation: removed references to private client projects so the skill can ship
publicly.
- Documented licensing: the style catalog is derived from Apache-2.0 content and now
carries full upstream attribution.
- 文档:`scripts/lint_frames.py` 与 `scripts/peek_frame.mjs` 补进命令序列与文件表
(两者此前都没有被文档记录)。
- 文档:lint 现在是工作流里的显式阶段,位置在生成字幕之后、渲染之前。
- 文档:删除对私有客户项目的引用,使技能可以公开发布。
- 许可:风格目录衍生自 Apache-2.0 内容,现已带上完整的来源署名。
### Notes
### 备注
- Backfilled from field work: preview-render frame deletion (`--keep-frames`), the
temporary-project technique for re-rendering a single scene, and the cold-open rule
that a video's first 0.5s must already show its subject (lessons #32–#34).
- 从实战回填:预览渲染会清空帧目录(`--keep-frames`)、重渲单个场景的临时项目法、
以及「开场 0.5 秒内就必须出现主角」的冷开场规则(lessons #32–#34)。
---
## [1.2.1] — 2026-09-20
Four rendering / QC correctness fixes, all found by instrumenting the renderer rather
than by inspection. See `references/lessons.md` #27–#30.
四个渲染 / QC 正确性修复,全部是靠给渲染器加探针找出来的,而不是靠肉眼看。
详见 `references/lessons.md` #27–#30。
### Fixed
### 修复
- **`tl.pause(t)` silently suppressed seek callbacks.** GSAP's signature is
`pause(atTime, suppressEvents)` and the second parameter defaults to `true`, so any
`onUpdate` / `onStart` / `onComplete` fired by a seek was dropped. A counter animated
via `onUpdate` therefore sat frozen at its initial value in the output — no error, no
warning, correct thumbnail size, and undetectable by static checks. The renderer and
cover builder now call `tl.pause(t, false)`. The docs additionally steer number
animations toward a pure-transform "digit strip" that depends on no callback at all.
- **`deviceScaleFactor` passed to `chromium.launch()` was silently ignored.** Both
`viewport` and `deviceScaleFactor` are context-level options and only take effect on
`newPage()`. Covers were being written at 1× while the log claimed 2×. The build now
verifies output dimensions with PIL instead of trusting the log.
- **QC sampling only covered the first quarter of the video.** `qc_check.py` sampled
subtitle-block start times but sliced `pts[:samples]`, taking the first N blocks rather
than a spread. A 39-block video sampled 12 blocks covering only 0.25–23.86s, leaving six
late scenes completely unchecked while reporting "all passed". Sampling now steps evenly
across the full range.
- **Cover layout was being measured mid-animation.** Verification ran straight after page
load, so it read `fromTo` start values instead of the settled layout and reported
phantom offsets. Measurement now sets `__MG_RENDER__` and seeks to `tl.duration()`
first, matching the cover builder exactly.
- **`tl.pause(t)` 会静默吞掉本次 seek 的回调。** GSAP 的签名是
`pause(atTime, suppressEvents)`,第二参默认为 `true`,于是 seek 触发的任何
`onUpdate` / `onStart` / `onComplete` 都被丢弃。用 `onUpdate` 写的计数器因此在成片里恒为
初值 —— 不报错、不告警、缩略图尺寸正确,静态检查也查不出来。渲染器与封面器现在调用
`tl.pause(t, false)`。文档同时把数字滚动引导向**纯 transform 的「数字卷轴」**,
完全不依赖任何回调。
- **传给 `chromium.launch()` 的 `deviceScaleFactor` 被静默忽略。** `viewport` 与
`deviceScaleFactor` 都是 context 级选项,只在 `newPage()` 上生效。封面实际写成了 1 倍图,
而日志声称 2 倍。现在构建用 PIL 核验输出尺寸,不再信日志。
- **QC 抽样只覆盖了视频前四分之一。** `qc_check.py` 取字幕块起始时间后写成 `pts[:samples]`,
拿的是**前 N 块**而不是均匀铺开。一条 39 块的视频抽了 12 块,只覆盖 0.25–23.86s,后 6 个
场景一帧没抽,却报告「全部通过」。现在按步长在全片范围均匀抽样。
- **封面排版是在动画中途量的。** 校验紧接页面加载后就跑,读的是 `fromTo` 起始值而不是稳定
终态,于是报出幽灵偏移。现在测量前先置 `__MG_RENDER__` 并 seek 到 `tl.duration()`,
与封面渲染器完全一致。
---
## [1.2.0] — 2026-09-20
### Added
### 新增
- **Dual-cover system.** Every finished video now produces two independently laid-out
covers instead of one: `cover_169.png` (1920×1080, 3840×2160 output) for the feed and
player, and `cover_34.png` (1440×1080, 2880×2160 output) for profile grids.
- `scripts/cover_build.mjs` — cover renderer reusing the scene renderer's deterministic
seek core, with three deliberate differences: no subtitle layer, a single seek to one
"cover moment" (`--at last` by default) rather than per-frame stepping, and 2× output
by default.
- `assets/cover-template.html` — cover template with the three required elements
documented in its header.
- `references/cover-guide.md` — re-layout comparison table, sizing rules, upload
strategy, and a self-check list.
- `scripts/new_project.py` now scaffolds both cover HTML files into a new project.
- **封面双方案。** 每条成片现在产出两张独立排版的封面而不是一张:`cover_169.png`
(1920×1080,输出 3840×2160)用于信息流与播放器,`cover_34.png`(1440×1080,
输出 2880×2160)用于主页栅格。
- `scripts/cover_build.mjs` —— 封面渲染器,复用场景渲染器的确定性 seek 内核,但有三处
刻意不同:不带字幕层、只 seek 到单个「封面时刻」(默认 `--at last`)而不是逐帧步进、
以及默认 2 倍输出。
- `assets/cover-template.html` —— 封面模板,三个必需元素写在文件头注释里。
- `references/cover-guide.md` —— 重排对照表、尺寸规则、上传策略与自检清单。
- `scripts/new_project.py` 现在会把两份封面 HTML 一起生成到新项目里。
### Notes
### 备注
- The two covers are **sibling layouts sharing one visual DNA, never a crop pair**.
Cropping 3:4 out of 16:9 discards 57.8% of the frame width (1920 → 810px), which
guarantees a headline hook gets cut. The re-layout changes the composition itself:
the paradox visual moves from side-by-side on the right to a vertical stack on top,
and the hook goes from two lines to three.
- 两张封面是**共享同一套视觉基因的姊妹版式,绝不是一对裁切**。从 16:9 裁 3:4 会丢掉
57.8% 的画面宽度(1920 → 810px),必然把大字钩子切掉。重排改的是构型本身:悖论视觉
从「右侧左右并置」变成「上方竖排堆叠」,钩子从两行变成三行。
---
## [1.1.0] — 2026-09-20 — first packaged release
## [1.1.0] — 2026-09-20 — 首个打包发布
### Added
### 新增
- Core pipeline: `new_project.py` → `tts_build.py` → `timeline_build.py` → `subs.py` →
`render_video.mjs` → `qc_check.py`.
- **Deterministic seek renderer** (`render_video.mjs`). Instead of screen-recording a
live page, every frame is produced by seeking the GSAP timeline (`tl.pause(t, false)`)
and synchronising CSS animations (`document.getAnimations().currentTime = t*1000`)
before screenshotting. This eliminates the entire class of live-recording failures:
no playback-start race, no font-load timeout, no unstable lead-in.
- **`B('block text')` beat sync.** Frame animations are positioned by matching subtitle
block text rather than hardcoded frame numbers, so editing narration re-times the whole
video without touching a single frame file.
- `scripts/subs.py` — three subtitle outputs (runtime JSON, SRT/VTT sidecars, and the
in-frame layer injected by the renderer) plus generated `beats.js` per scene.
- `scripts/make_theme.py` — four presets plus topic-word colour derivation, writing a
single `theme.css` source of truth for all colours.
- `scripts/qc_check.py` — stream, duration, loudness, and sampled-frame auditing with a
contact sheet.
- `scripts/import_styles.py` — one-time porting tool that transcribes template design
specifications into `references/style-catalog.md` / `.json`.
- 23-style visual catalog across 8 categories, classified by adaptation cost.
- `setup_env.sh` for first-time environment checks and `--install` for fetching missing
dependencies; `package_skill.py` for portable zips.
- 核心流水线:`new_project.py` → `tts_build.py` → `timeline_build.py` → `subs.py` →
`render_video.mjs` → `qc_check.py`。
- **确定性 seek 渲染器**(`render_video.mjs`)。不录活页面的屏,而是每一帧都靠 seek
GSAP 时间轴(`tl.pause(t, false)`)并同步 CSS 动画
(`document.getAnimations().currentTime = t*1000`)后再截图。这消掉了实时录制的一整类
失败:没有起播竞态、没有字体加载超时、没有不稳定的引导期。
- **`B('块文本')` 节拍同步。** 画面动画按**匹配字幕块文本**定位,而不是写死帧号,于是改解说词
会重排全片时序,而一个画面文件都不用动。
- `scripts/subs.py` —— 字幕三出口(运行时 JSON、SRT/VTT 外挂、渲染器注入的画面内层),
外加每场景生成一份 `beats.js`。
- `scripts/make_theme.py` —— 四个预设 + 按主题词推色,写出唯一颜色来源 `theme.css`。
- `scripts/qc_check.py` —— 流 / 时长 / 响度 / 抽帧体检,附抽帧速览图。
- `scripts/import_styles.py` —— 移植期一次性工具,把模板设计规范转写成
`references/style-catalog.md` / `.json`。
- 23 种画面风格目录,8 个类别,按改编成本分类。
- `setup_env.sh` 做首次环境自检,`--install` 装缺失依赖;`package_skill.py` 打可移植 zip。
[1.2.2]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.2
[1.2.1]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.1
+75 -93
View File
@@ -1,44 +1,42 @@
# Contributing to html-explainer
# 参与贡献
Thanks for taking the time to contribute. This document covers the practical things:
how to get a working environment, what the project's hard rules are, and where different
kinds of contributions actually belong.
感谢你愿意花时间。本文只说实际的东西:怎么把环境跑起来、项目的硬性规则有哪些、
以及不同类型的贡献该往哪儿放。
## Ways to contribute
## 可以贡献什么
| Kind of contribution | Where it goes |
| 贡献类型 | 该往哪儿放 |
|---|---|
| Bug report | [Open an issue](https://github.com/OneMoh/html-explainer/issues/new/choose) using the bug template |
| Rendering / sync bug you diagnosed | Add a numbered entry to `references/lessons.md` **and** fix the code |
| New visual style or a better adaptation recipe | `references/style-catalog.md` notes + a documented example |
| Documentation fix | Direct PR — typo fixes need no prior discussion |
| New feature | Open an issue first so the approach can be agreed before you write it |
| Platform-specific fix (macOS / Linux paths, shell variants) | Very welcome — the project is developed on Windows + Git Bash |
| 报告 Bug | 用 bug 模板[开一个 issue](https://github.com/OneMoh/html-explainer/issues/new/choose) |
| 你已定位的渲染 / 同步问题 | 在 `references/lessons.md` 追加一条编号记录,**并且**修代码 |
| 新的画面风格,或更好的改编配方 | 更新 `references/style-catalog.md`,并附一个可运行的示例 |
| 文档修正 | 直接提 PR —— 改错别字不需要先讨论 |
| 新功能 | 先开 issue 把方案谈拢,再动手写 |
| 平台相关修复(macOS / Linux 路径、其它 shell) | 非常欢迎 —— 本项目在 Windows + Git Bash 上开发 |
## Development setup
## 开发环境
```bash
git clone https://github.com/OneMoh/html-explainer.git
cd html-explainer
# Check the environment, then install anything missing
bash setup_env.sh # report only
bash setup_env.sh --install # fetch missing dependencies
# 先自检,再装缺的东西
bash setup_env.sh # 只报告
bash setup_env.sh --install # 安装缺失依赖
# Confirm the toolchain is healthy
# 确认工具链正常
node -v && python -V
```
Requirements: Python ≥ 3.9, Node ≥ 18, and either Chrome or Edge (both are almost always
already present). `ffmpeg` is used from `PATH` or falls back to the static binary bundled
with `imageio-ffmpeg`.
要求:Python ≥ 3.9、Node ≥ 18,以及 Chrome 或 Edge(两者几乎总是已经装好)。
`ffmpeg` 优先从 `PATH` 取,取不到则回退到 `imageio-ffmpeg` 自带的静态二进制。
### Running an end-to-end check
### 跑一次端到端自检
```bash
python scripts/new_project.py demo --topic "人工智能"
cd demo
# fill narration.json, write frames/*.html, set project.json order
# 填 narration.json、写 frames/*.html、设置 project.json 的 order
python ../scripts/tts_build.py --project .
python ../scripts/timeline_build.py --project .
python ../scripts/subs.py --project .
@@ -46,95 +44,79 @@ python ../scripts/lint_frames.py --project .
node ../scripts/render_video.mjs . --preview 30
```
If all of that completes and `out/preview.mp4` plays with synchronised audio, your
environment is good.
如果这些全部跑完、`out/preview.mp4` 播放时音画同步,你的环境就没问题。
## The project's hard rules
## 项目的硬性规则
These are not style preferences. Breaking them produces silent failures — a video that
renders but is wrong. They are enforced by `scripts/lint_frames.py` where possible.
这些不是风格偏好。违反它们会产生**静默失败** —— 视频能渲出来,但是错的。凡是能自动检查的,
都由 `scripts/lint_frames.py` 强制。
1. **No external fonts or CDN resources in any frame.** Web fonts are unreachable from
many networks and time out silently. Use system font stacks only, and reference GSAP
from the bundled `../assets/gsap.min.js`.
2. **Colours come from `theme.css` CSS variables only.** No hex literals in frame code.
This is what makes a theme change a one-file operation.
3. **No wall-clock logic.** `setInterval` and `requestAnimationFrame` counters cannot
work under deterministic seeking. Animate with GSAP or CSS `@keyframes`.
4. **Animate with GSAP timelines, registered on `window.__tl`.** The renderer seeks this
timeline once per frame; without it, nothing animates.
5. **Position animations with `B('block text')`, never hardcoded times.** The fallback
form `B('x') || 3.2` is forbidden — a missing block should fail loudly at build time,
not silently reuse a stale number.
6. **Keep the subtitle safe zone clear.** Nothing may render in the bottom 80–170px, and
entrance paths must not cross it.
7. **Use ASCII paths for all project directories and output files.** Non-ASCII paths break
ffmpeg on Windows. Project and output names are ASCII; rename for presentation later.
1. **任何画面帧里都不许引外部字体或 CDN 资源。** 网络字体在很多网络下不可达,而且超时是
静默的。只用系统字体栈,GSAP 一律从随包的 `../assets/gsap.min.js` 引用。
2. **颜色只能取 `theme.css` 的 CSS 变量。** 画面代码里不许出现十六进制字面量 —— 这正是
「换主题只改一个文件」得以成立的原因。
3. **不许有墙钟逻辑。** `setInterval` 与 `requestAnimationFrame` 计数在确定性 seek 下不可能
工作。动画用 GSAP 或 CSS `@keyframes`。
4. **动画用 GSAP 时间轴,并注册到 `window.__tl`。** 渲染器每帧 seek 这个时间轴;没有它,
什么都不动。
5. **动画定位用 `B('块文本')`,绝不用写死的时间。** 兜底写法 `B('x') || 3.2` 是被禁止的 ——
块查不到就应该在构建时大声失败,而不是静默复用一具过期的数字。
6. **保持字幕禁区干净。** 底部 80–170px 不许有任何内容渲染,入场路径也不许穿过它。
7. **项目目录与输出文件一律用 ASCII 路径。** 非 ASCII 路径会让 Windows 上的 ffmpeg 出错。
项目名与输出名保持 ASCII,要展示用的名字以后再改。
The full contract, with the reasoning behind each rule and the counter-examples that get
a frame rejected, is in [`references/frame-contract.md`](references/frame-contract.md).
完整契约(每条规则背后的理由,以及会被打回的反例)见
[`references/frame-contract.md`](references/frame-contract.md)。
## Adding a new lesson
## 追加一条踩坑记录
`references/lessons.md` is the most valuable file in this repository. It exists because
this kind of pipeline fails *silently* — a bug produces a plausible-looking video rather
than an error.
`references/lessons.md` 是这个仓库里最值钱的文件。它之所以存在,是因为这条流水线的失败方式
是**静默**的 —— 出问题不会报错,只会产出一条看着挺像样的视频。
A good entry contains, in this order:
一条好的记录按这个顺序写:
1. **The symptom**, as you first observed it — including how it misled you.
2. **The diagnosis**, and the evidence that proved it (a probe, a diff, a measurement).
3. **The fix**, and what to check afterwards to confirm it.
4. **The general rule** this implies, if it implies one.
1. **症状**,就按你最初观察到的样子 —— 包括它是怎么把你带偏的。
2. **定位过程**,以及证明病因的证据(探针、diff、测量值)。
3. **修法**,以及修完该检查什么来确认。
4. **由此得出的通用规则**(如果确实能推出的话)。
Number entries sequentially and append. Do not renumber existing entries — they are
referenced from code comments.
编号顺次追加。**不要给已有条目重新编号** —— 代码注释里在引用这些编号。
## Adding a new visual style
## 追加一种画面风格
1. Record the specification in `references/style-catalog.md` (canvas, type scale, timeline
structure, colour discipline) and `references/style-catalog.json`.
2. Classify it as `rich` (single file, CSS `@keyframes` timeline — drivable by the seek
renderer with no changes) or `gsap` (multi-composition — must be re-expressed, not
copied).
3. Add an adaptation recipe to `references/template-guide.md` if the style needs anything
beyond the standard three steps (swap the font stack, lift bottom elements out of the
subtitle zone, fill in real content).
4. Verify it renders: `node scripts/peek_frame.mjs <project> <id> --at 25,50,85`.
1. 在 `references/style-catalog.md` 与 `references/style-catalog.json` 里记录规范
(画布、字阶、时间轴结构、配色纪律)。
2. 分类为 `rich`(单文件,CSS `@keyframes` 时间轴 —— seek 渲染器零改动可驱动)或 `gsap`
(多 composition —— 必须重写表达,不能照搬)。
3. 如果这种风格需要超出标准三步(换字体栈、把底部元素抬出字幕带、填真实内容)之外的
处理,在 `references/template-guide.md` 里补一条改编配方。
4. 验证它能渲出来:`node scripts/peek_frame.mjs <project> <id> --at 25,50,85`。
Style identifiers are `kebab-case` and match the pattern already in use (`frame-*`,
`vfx-*`).
风格标识符用 `kebab-case`,并沿用已有的命名模式(`frame-*`、`vfx-*`)。
## Pull request checklist
## PR 检查清单
Before opening a PR, please confirm:
提 PR 之前请确认:
- [ ] `python scripts/lint_frames.py --project <project>` reports no FAILs for any frame
you touched
- [ ] You have actually rendered your change. Frame code that "looks right" frequently
is not — seek-based rendering has its own failure modes
- [ ] Any new lesson is added to `references/lessons.md` with a number
- [ ] No external fonts, CDN references, or hardcoded colours were introduced
- [ ] If you redistributed or re-derived third-party content, `THIRD_PARTY_NOTICES.md`
is updated
- [ ] `README.md` and `README.en.md` are updated together if user-facing behaviour
changed
- [ ] `python scripts/lint_frames.py --project <project>` 对你动过的每一帧都没有 FAIL
- [ ] 你真的渲染过你的改动。画面代码「看着对」经常是不对的 —— seek 渲染有自己的失败模式
- [ ] 新增的踩坑记录已带编号写进 `references/lessons.md`
- [ ] 没有引入外部字体、CDN 引用或硬编码颜色
- [ ] 如果有再分发或再衍生的第三方内容,`THIRD_PARTY_NOTICES.md` 已更新
- [ ] 涉及使用者可见行为的改动,`README.md` 与 `README.en.md` 已同步更新
## Commit messages
## 提交信息
Conventional Commits are preferred (`fix:`, `feat:`, `docs:`, `chore:`). A body is
encouraged when the change fixes a silent failure — explain what was misbehaving and how
you verified the fix.
建议使用 Conventional Commits(`fix:`、`feat:`、`docs:`、`chore:`)。如果这次改动修的是一个
静默失败,请写正文说明它原来错在哪、你是怎么验证修好了的。
## Scope note
## 范围说明
This project intentionally does **not** aim to be a framework. It is a pipeline with a
small, boring surface: HTML frames in, MP4 out, with audio-video sync that holds. Features
that add a build system, a plugin API, or a runtime dependency on a cloud service are
generally out of scope — say hello in an issue if you think there's an important exception.
本项目刻意**不**打算成为一个框架。它是一条流水线,接口很小、很无聊:HTML 帧进,MP4 出,
音画同步稳得住。会增加构建系统、插件 API,或对云服务产生运行时依赖的功能,一般都在范围之外
—— 如果你认为有重要的例外,开个 issue 聊聊。
## License
## 许可
By contributing, you agree that your contributions are licensed under the MIT License
(see [LICENSE](LICENSE)), and you confirm you have the right to submit them. If you are
porting content from another project, add it to `THIRD_PARTY_NOTICES.md` in the same PR.
提交贡献即表示你同意你的贡献以 MIT 许可授权(见 [LICENSE](LICENSE)),并确认你有权提交它们。
如果你从别的项目移植了内容,请在同一个 PR 里把它写进 `THIRD_PARTY_NOTICES.md`。
+261 -423
View File
@@ -2,17 +2,19 @@
# html-explainer
**Turn a topic into a narrated, subtitled, fully-covered explainer video — locally, from HTML, with audio-video sync that actually holds.**
**An Agent Skill: give it a topic, get back a narrated explainer video with subtitles and covers.**
A deterministic HTML → MP4 pipeline for AI coding agents.
No Remotion. No cloud render queue. No per-render fees. No API keys for the core path.
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: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.2.2-blue.svg)](CHANGELOG.md)
[![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB.svg?logo=python&logoColor=white)](https://python.org)
[![Node](https://img.shields.io/badge/Node-18%2B-339933.svg?logo=node.js&logoColor=white)](https://nodejs.org)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-6e6e6e.svg)](#requirements)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
[![Agent Skill](https://img.shields.io/badge/Agent%20Skill-SKILL.md-8A2BE2.svg)](SKILL.md)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-%E2%9C%93-D97757.svg)](#install)
[![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#install)
[![WorkBuddy](https://img.shields.io/badge/WorkBuddy-%E2%9C%93-1f6feb.svg)](#install)
[![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB.svg?logo=python&logoColor=white)](#requirements)
[![Node](https://img.shields.io/badge/Node-18%2B-339933.svg?logo=node.js&logoColor=white)](#requirements)
[简体中文](README.md) · [English](README.en.md)
@@ -22,47 +24,143 @@ No Remotion. No cloud render queue. No per-render fees. No API keys for the core
## What it is
`html-explainer` produces explainer and knowledge videos — the kind with a voiceover, hard
subtitles, a progress bar, and motion-graphics scenes built in HTML/CSS/GSAP.
`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.
You write a narration script. An agent (or you) picks a visual style from a library of 23,
writes one self-contained HTML file per beat, and this pipeline renders it to a real MP4
with frame-accurate subtitle timing and two platform-ready covers.
**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.
Everything runs on your machine. The only network calls are the TTS request and, optionally,
web research for the script.
You say one sentence:
It is packaged as an **Agent Skill** (`SKILL.md` + `scripts/` + `references/`), so it drops
into Claude Code, WorkBuddy, Cursor, or any agent that can read a skill file — but the
scripts themselves are plain Python and Node, and run perfectly well by hand.
> 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.
---
## The core idea: deterministic seeking instead of screen recording
## Install
Most "HTML to video" tools launch a browser, play the animation, and screen-record the
result. That approach is the source of an entire family of bugs that are maddening because
they are *intermittent*: the animation starts before recording does, a web font resolves late
so half the video uses the wrong typeface, the first few frames capture a pre-animation
### 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.
---
## Quick start
A video takes **seven commands**, all run inside the **video project directory**:
```bash
PY=<path to your venv python> # expand to an absolute path when briefing a sub-agent
SKILL=<skill install directory>
# 0 · scaffold a project (tree + config + theme + both cover templates)
"$PY" "$SKILL/scripts/new_project.py" ~/videos/sky-blue sky-blue --topic "why the sky is blue"
cd ~/videos/sky-blue
# …fill narration.json, fill project.json's order, author one frames/<id>.html per scene…
"$PY" "$SKILL/scripts/tts_build.py" --project . # 1 · voiceover
"$PY" "$SKILL/scripts/timeline_build.py" --project . # 2 · global timeline
"$PY" "$SKILL/scripts/subs.py" --project . # 3 · subtitles + beats
"$PY" "$SKILL/scripts/lint_frames.py" --project . # 4 · audit frames before rendering
node "$SKILL/scripts/render_video.mjs" . --preview 30 # 5 · 30-second draft first
"$PY" "$SKILL/scripts/qc_check.py" --project . # 6 · QC
node "$SKILL/scripts/render_video.mjs" . # 7 · full render
node "$SKILL/scripts/cover_build.mjs" . # + both covers
```
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
```
The order matters: `beats.js` is derived from the TTS output. That is why, after you edit the
narration, everything downstream re-times automatically while no scene code changes.
> **If you plan to reuse the rendered PNGs, always pass `--keep-frames`.** Preview mode clears the
> frame directory after muxing by design.
---
## 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**.
`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.
For every single frame it:
1. sets `window.__MG_RENDER__` so the frame knows not to autoplay,
2. seeks the GSAP timeline to that exact timestamp with `tl.pause(t, false)`,
3. synchronises every CSS animation with `document.getAnimations().currentTime = t * 1000`,
4. waits two animation frames, then screenshots.
Frame 1,204 of a render is bit-identical to frame 1,204 of the next render. That property is
what makes the rest of the system possible — QC can audit specific frames, a single scene can
be re-rendered in isolation, and a whole class of timing bugs simply cannot occur.
> The second parameter is not optional. `pause(atTime, suppressEvents)` defaults
> `suppressEvents` to `true`, which silently drops every callback fired by the seek — a
> counter animated via `onUpdate` will sit frozen at its initial value with no error. This
> cost real debugging time; see [`references/lessons.md`](references/lessons.md) #27.
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`.
---
@@ -70,36 +168,13 @@ be re-rendered in isolation, and a whole class of timing bugs simply cannot occu
| | |
|---|---|
| **Narration-locked beats** | Scenes position their animation by *matching subtitle text* (`B('block text')`), never by hardcoded frame number. Rewrite the script and the whole video re-times itself — **not one line of frame code changes.** |
| **Word-boundary subtitles** | Timing comes from edge-tts `WordBoundary` events, not character-count interpolation. In Chinese, two same-length phrases can differ 3× in duration, so interpolation drifts badly. |
| **Two-level clock** | Frame length uses the MP3 *container* duration (keeps audio and video aligned); the final subtitle block retires on the true *speech* end. Mixing the two produces subtitles that linger ~0.8s per scene. |
| **23 visual styles** | Eight categories — bold signal cards, luxury minimal, NYT-grade data charts, Swiss grid, glitch art, film light-leak, fluid hero, brand outro, and more. Each with canvas, type scale, timeline, and colour discipline documented. |
| **Dual covers** | Every finished video also ships a 16:9 feed cover and an independently laid-out 3:4 grid cover. Not a crop — a crop loses 57.8% of the frame width. |
| **Pre-render linting** | `lint_frames.py` audits all eight frame-contract rules before you spend 15 minutes rendering. |
| **Adaptive QC** | Loudness, duration drift, stream integrity, and a sampled contact sheet — with frame-size thresholds derived from the video's own median, so dark themes don't produce false positives. |
| **Offline by construction** | GSAP is vendored. Chrome or Edge is auto-detected. ffmpeg falls back to the static binary that ships with `imageio-ffmpeg`. Web fonts are banned by contract. |
| **Single source of truth for colour** | Every colour lives in `theme.css` as a CSS variable. Change the theme and the entire video re-skins with zero frame edits. |
---
## How it compares
| | `html-explainer` | Screen-recording HTML tools | Remotion | Cloud video APIs |
|---|---|---|---|---|
| **Render method** | Deterministic seek | Live capture | React → frames | Server-side render |
| **Reproducible frames** | Yes | No | Yes | N/A |
| **Per-render cost** | Zero | Zero | Zero | Metered |
| **Runtime dependency** | Node + Python + a browser | Same | Node + React toolchain | Network + account |
| **Authoring model** | HTML/CSS/GSAP per scene | HTML per scene | React components | JSON / template |
| **Re-timing after a script edit** | Automatic (`B()` beats) | Manual | Manual | Manual |
| **Works offline end-to-end** | Yes | Yes | Yes | No |
| **Built-in style library** | 23 documented styles | Varies | Community templates | Vendor templates |
| **Cover/thumbnail output** | Two verified layouts | No | No | Sometimes |
The honest trade-off: because rendering is seek-based, **every animation must be seekable**.
Wall-clock animation (`setInterval`, `requestAnimationFrame` counters, CSS `transition`
entrances) cannot work and is rejected by the linter. That constraint is what buys
determinism, and it is enforced rather than merely advised.
| **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. |
---
@@ -107,339 +182,110 @@ determinism, and it is enforced rather than merely advised.
```mermaid
flowchart LR
A["Topic"] --> B["Research<br/><i>facts with sources</i>"]
B --> C["narration.json<br/><i>text, | splits subtitle blocks</i>"]
C --> D["tts_build.py<br/><i>edge-tts → MP3 + word boundaries</i>"]
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 beat, style from library</i>"]
G --> H{"lint_frames.py<br/>8 contract rules"}
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 → PNG per frame</i>"]
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>"]
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 order matters. `beats.js` is generated from the TTS output, which is why changing the
script re-times everything downstream without touching scene code.
---
## Requirements
| Requirement | Minimum | Notes |
|---|---|---|
| Python | 3.9+ | `edge-tts==7.2.8` (pinned deliberately — v7 changed the boundary API), `numpy`, `pillow`, `imageio-ffmpeg` |
| Node.js | 18+ | Runs the renderer and cover builder |
| Browser | Chrome or Edge | Auto-detected. Neither present → playwright chromium (~115MB, downloaded once) |
| ffmpeg | any | Found on `PATH`, otherwise the static binary bundled with `imageio-ffmpeg` is used |
| Disk | ~2 GB per finished video | Frame PNGs are large; they can be discarded after muxing |
No administrator rights are needed anywhere. Everything installs to user scope.
---
## Quick start
```bash
git clone https://github.com/OneMoh/html-explainer.git
cd html-explainer
# 1 · Check the environment (reports what's missing)
bash setup_env.sh
# 2 · Install anything missing
bash setup_env.sh --install
```
Then run the pipeline inside a project directory:
```bash
PY=<path to your venv python> # expand to an absolute path if you hand work to an agent
SKILL=$(pwd) # this repo
# Stage 0 — scaffold a project
"$PY" "$SKILL/scripts/new_project.py" ~/videos/ai-intro ai-intro --topic "人工智能"
cd ~/videos/ai-intro
# …write narration.json, fill project.json's order,
# and author one frames/<id>.html per scene…
# Stages 1–3 — voiceover, timeline, subtitles
"$PY" "$SKILL/scripts/tts_build.py" --project .
"$PY" "$SKILL/scripts/timeline_build.py" --project .
"$PY" "$SKILL/scripts/subs.py" --project .
# Stage 4 — audit frames before spending render time
"$PY" "$SKILL/scripts/lint_frames.py" --project .
# Stage 5 — render (--preview N gives a fast draft of the first N seconds)
node "$SKILL/scripts/render_video.mjs" . --preview 30
# Stage 6 — QC
"$PY" "$SKILL/scripts/qc_check.py" --project .
# Stage 7 — full render + covers
node "$SKILL/scripts/render_video.mjs" .
node "$SKILL/scripts/cover_build.mjs" .
```
Output lands in `out/`: `slug.mp4`, `cover_169.png`, `cover_34.png`, `slug.srt`, `slug.vtt`,
plus `qc_report.md` and `qc_sheet.jpg`.
> **Always pass `--keep-frames` if you intend to reuse the rendered PNGs.** Preview mode
> deletes the frame directory after muxing, by design. See
> [`references/lessons.md`](references/lessons.md) #32.
---
## Command reference
### Pipeline
| Command | Does |
| Command | Purpose |
|---|---|
| `python scripts/new_project.py <dir> <slug> [--topic T] [--preset P] [--fps 30] [--width 1920] [--height 1080] [--lang zh]` | Scaffolds a project: config, theme, templates, and both cover HTML files |
| `python scripts/tts_build.py --project . [--voice V] [--rate +8%] [--no-trim] [--no-cache]` | Synthesises narration; caches, timeouts, retries, trims silence; writes both duration clocks |
| `python scripts/timeline_build.py --project . [--gap SEC]` | Builds the global timeline and stitches `narration-full.mp3` |
| `python scripts/subs.py --project . [--fps N]` | Emits `subs.json`, `srt`/`vtt` sidecars, and one `beats.js` per scene |
| `python scripts/lint_frames.py --project . [--only id,id] [--include-covers]` | Static audit of every frame against the eight contract rules |
| `node scripts/render_video.mjs <dir> [--out PATH] [--preview SEC] [--fps N] [--concurrency N] [--jpeg] [--scale N] [--keep-frames] [--browser PATH]` | Renders and muxes to MP4 |
| `python scripts/qc_check.py --project . [--samples N]` | Loudness / duration / stream audit plus a contact sheet |
| `node scripts/cover_build.mjs <dir> [--at last\|SEC] [--only 169\|34] [--scale N] [--jpg] [--keep-frames]` | Renders both covers at 2× by default |
| `new_project.py <dir> <slug> --topic "..."` | Scaffold a project |
| `tts_build.py --project .` | Synthesise voiceover (caching, timeouts, backoff retries, silence trim) |
| `timeline_build.py --project .` | Global timeline + stitched `narration-full.mp3` |
| `subs.py --project .` | Three subtitle outputs + one `beats.js` per scene |
| `lint_frames.py --project .` | Audit frames against the eight contract rules |
| `render_video.mjs <dir>` | Render and mux the MP4 |
| `qc_check.py --project .` | Loudness / duration / stream checks + contact sheet |
| `cover_build.mjs <dir>` | Render both covers, 2× by default |
### Utilities
Common optional flags: `--preview N` renders only the first N seconds, `--keep-frames` keeps the
frame PNGs, `--only id,id` audits just the named scenes, `--scale N` sets the output multiplier.
| Command | Does |
|---|---|
| `node scripts/peek_frame.mjs <dir> <id> [--at 40,80]` | Screenshots one scene at percentage points — seconds, not minutes. `--all` for every scene |
| `python scripts/make_theme.py --topic "医疗" --use` | Derives a palette from a topic word, or `--preset violet\|cyan\|amber\|mono` |
| `python scripts/check_integrity.py` | Version / catalog / count consistency and stray-CDN scan (also runs in CI) |
| `python setup_env.sh --install` → `bash setup_env.sh --install` | Environment check and dependency install |
| `python scripts/package_skill.py [--with-deps]` | Builds a portable zip (omit `--with-deps` for ~110KB) |
A few handy extras: `peek_frame.mjs <dir> <id>` (screenshot one scene in seconds — the one you
reach for while designing), `make_theme.py --topic "medical" --use` (derive a palette from a topic
word), `package_skill.py` (build a portable zip).
**Full flag reference is in [`SKILL.md`](SKILL.md), or pass `--help` to any script.**
---
## The `B()` beat system
This is the part that changes how the work feels.
A generated `beats.js` per scene exposes every subtitle block by its text:
`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
B('右边卡片') // the second the phrase "右边卡片" starts being spoken
Be('证据') // the second that phrase finishes
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);
```
So scene animation reads like this:
`B('block text')` is the moment those words start being spoken; `Be('block text')` is the moment
they finish.
```js
var tl = gsap.timeline({ paused: true });
window.__tl = tl;
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.
tl.fromTo('.card', { y: 60, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.7 }, B('右边卡片'));
tl.fromTo('.verdict',{ scale: 0.8 }, { scale: 1, duration: 0.6, ease: 'back.out(2)' }, B('结论句') + 0.2);
```
Reword the narration and you re-run three commands. The beats shift, every scene re-times
itself, and no frame file is edited. Compare that with frame-number hardcoding, where one
changed sentence means re-aligning the entire video by hand.
**The fallback form is banned.** `B('x') || 3.2` hides a broken beat lookup behind a stale
number. If a block can't be found the build should fail loudly — a mis-timed video is worse
than a failed build.
**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 documented
with its canvas, type scale, timeline structure, and colour discipline
([`references/style-catalog.md`](references/style-catalog.md)).
**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).
| Category | Styles |
|---|---|
| Presentation / title cards | Bold Poster, Bold Signal, Build Minimal, Creative Voltage, Electric Studio, Glitch Title, Kinetic Type, Swiss Grid, Warm Grain |
| Data visualisation | NYT Data Chart, Data Rollup, NYT Graph, Swiss Grid Data |
| Diagrams / process | Takram Organic, Decision Tree |
| Cinematic atmosphere | Film Light-Leak |
| Marketing / hero | Fluid Background Hero |
| Bumpers | Brand Logo Outro |
| Social portrait (9:16) | Play Mode, Vignelli |
| Product demo | Product Promo, Product Promo · 30s |
| Effects | VFX Text Cursor |
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, two very different adaptation costs:
Two classes, with very different adaptation costs:
- **`rich` (12)** — a single file driven by pure CSS `@keyframes`. The seek renderer drives
these **with zero code changes**; adapting one is three steps: swap the web font for a
system stack, lift bottom elements out of the subtitle zone, fill in real content.
- **`gsap` (11)** — multi-composition, CDN-loaded. **Do not port the code.** A multi-scene
composition has no playback hook inside a single seekable frame, so importing it yields a
static first frame and no error whatsoever. Take the visual DNA and re-express it as CSS
keyframes.
- **`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 two to four styles across a video. Eight scenes sharing one template looks stale;
mixing an aggressive opener, a spare mid-section, and a data-driven conclusion does not.
Rotate 2–4 styles per project. Eight scenes sharing one look reads as monotonous.
---
## Dual-cover system
## Dual covers
A finished video produces **two covers that are siblings, not a crop pair**.
Cropping 3:4 out of 16:9 yields 810px from 1920px — discarding **57.8% of the frame width**.
Any full-width headline gets sliced. So both covers share one visual DNA (palette,
backdrop, key visual, hook copy) but are each laid out from scratch:
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, vertically stacked |
| Hook | Bottom-left, 2 lines, ≥96px | Bottom, 3 lines, ≥120px |
| Corner label | Top-left | Top-centre or dropped |
| 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 |
Three elements are mandatory, and a missing one means rework: a large hook with genuine
tension, a central visual that *argues a contradiction* (two figures moving opposite ways,
a split line, light against dark — not a decorative shape), and margin to spare
(≥96px / ≥110px on all sides).
Output is 2× by default: `3840×2160` and `2880×2160`, sized for platform compression.
Full rules and the pre-upload checklist: [`references/cover-guide.md`](references/cover-guide.md).
---
## The frame contract
One scene is one self-contained HTML file. Eight rules — enforced by `lint_frames.py`, and
each one exists because breaking it fails *quietly*:
| # | Rule | Breaks like this |
|---|---|---|
| 1 | Canvas `1920×1080` (or the size declared in `project.json`), system fonts only | Render viewport is the canvas |
| 2 | **No external fonts or CDNs** | Font fetch times out; type weight shifts mid-video |
| 3 | Colours only from `../theme.css` variables | A theme change becomes a partial re-skin |
| 4 | GSAP timeline registered on `window.__tl` | Renderer finds no timeline and errors |
| 5 | Position animation with `B('block text')` | Frame and voiceover drift apart |
| 6 | Keep the bottom 80–170px clear (subtitle zone) | Subtitles overprint content; both become unreadable |
| 7 | One focal point per frame; text ≥22px | QC rejection |
| 8 | Load GSAP from `../assets/gsap.min.js` | CDN unreachable → offline render fails |
Numbers animated in a frame should use a **pure-transform digit strip** rather than
`onUpdate`. The renderer passes `suppressEvents = false` so callbacks do work, but a
transform-based animation depends on no callback at all and therefore survives every seek
strategy.
Details, layout vocabulary, and the rejection examples: [`references/frame-contract.md`](references/frame-contract.md).
---
## Project layout
```
html-explainer/
├── SKILL.md # Agent-facing entry point: workflow, commands, contracts
├── README.md # Project documentation (Chinese, default)
├── README.en.md # This document
├── LICENSE · THIRD_PARTY_NOTICES.md · licenses/
├── CHANGELOG.md · CONTRIBUTING.md
│
├── scripts/
│ ├── new_project.py # Scaffold
│ ├── tts_build.py # edge-tts → MP3 + word boundaries
│ ├── timeline_build.py # Global axis + stitched audio
│ ├── subs.py # Subtitles ×3 outputs + beats.js
│ ├── lint_frames.py # Pre-render contract audit
│ ├── render_video.mjs # Deterministic seek renderer
│ ├── qc_check.py # Post-render audit + contact sheet
│ ├── cover_build.mjs # Dual-cover renderer
│ ├── peek_frame.mjs # Single-scene screenshot preview
│ ├── make_theme.py # Palette derivation → theme.css
│ ├── check_integrity.py # Repo self-consistency (CI)
│ ├── import_styles.py # One-time style-catalog import tool
│ └── package_skill.py # Portable zip builder
│
├── references/
│ ├── style-catalog.md / .json # 23 styles, 8 categories
│ ├── template-guide.md # Adaptation recipes
│ ├── frame-contract.md # The eight rules, with reasoning
│ ├── cover-guide.md # Dual-cover specification
│ ├── workflow-guide.md # Stage-by-stage detail + agent prompt templates
│ └── lessons.md # 34 numbered silent-failure post-mortems
│
├── assets/
│ ├── frame-template.html # Scene template
│ ├── cover-template.html # Cover template
│ └── gsap.min.js # GSAP 3.13, bundled for offline rendering
│
├── node/ # Vendored playwright-core
├── setup_env.sh # Environment check / install
└── .github/workflows/ci.yml # CI: integrity, syntax, CDN scan
```
A **video project** looks like this:
```
my-video/
├── project.json # slug, fps, size, voice, rate, scene order, chapters
├── narration.json # [{ id, text }] — "|" splits subtitle blocks
├── theme.css # Every colour, as CSS variables
├── frames/
│ ├── _template.html
│ ├── <id>.html # One scene per file
│ ├── <id>.beats.js # Generated — never edit by hand
│ ├── cover_169.html
│ └── cover_34.html
├── audio/ render/ research/ script/
└── out/ # MP4, covers, SRT/VTT, QC report
```
---
## Configuration
`project.json`:
| Key | Meaning |
|---|---|
| `slug` | Output filename stem |
| `lang` | `zh` or `en` — selects the default voice |
| `fps` | Frame rate (default 30) |
| `width` / `height` | Canvas size (default `1920×1080`) |
| `gap` | Silence inserted between scenes, in seconds |
| `order` | Scene IDs in playback order — **fill this in after writing `narration.json`** |
| `chapters` | Optional `[{ title, startSegment }]` for progress-bar ticks |
| `theme` | Theme preset name |
| `voice` | edge-tts voice, e.g. `zh-CN-YunxiNeural` |
| `rate` | Speech rate, e.g. `+8%` |
| `progress` | Whether to render the global progress bar |
Writing narration:
```json
[
{ "id": "hook", "text": "开场钩子,一到两句。|用竖线切字幕块,每块不超过16个字。" },
{ "id": "point1", "text": "一个场景一句解说。|字幕块之间用竖线分隔。" }
]
```
- `id` becomes the scene name and must be ASCII (`hook`, `point1`, `ending`).
- `|` splits subtitle blocks, **not** scenes. One narration entry = one scene.
- Keep Chinese subtitle blocks ≤16 characters, and split at semantic boundaries.
- Never add punctuation back to control line breaks — the renderer treats subtitles as
on-screen text and strips punctuation.
- Two adjacent numbers need a Chinese comma between them, or edge-tts reads them as one
fused number (`沪深300` + `4479.55` → "three million four hundred seventy-nine…").
Aim for roughly 5.4–6 Chinese characters per second. If the total lands more than 15% off
your target duration, rewrite sentences — do not distort the speech rate.
`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).
---
@@ -447,99 +293,90 @@ your target duration, rewrite sentences — do not distort the speech rate.
| Symptom | Cause | Fix |
|---|---|---|
| Every frame is the static first frame; nothing errors | A timeline was never registered on `window.__tl`, or `page.evaluate` was handed a *string* instead of a function literal (Playwright evaluates a string once and returns the function object — the body never runs) | Register the timeline; pass a real function literal. Lessons #17 |
| Numbers render but never move | `onUpdate` callbacks suppressed by a seek | Fixed in the renderer (`pause(t, false)`). Animate with a transform-based digit strip instead. Lessons #27 |
| Video is 1280×720 instead of 1920×1080 | `viewport` passed to `browser.launch()` — it is a *context* option | Pass it to `newPage()`. Lessons #18 |
| Covers come out at 1× though the log says 2× | Same trap with `deviceScaleFactor` | Pass to `newPage()` + `screenshot({ scale: 'device' })`; verify with PIL. Lessons #28 |
| `No such file or directory` on Windows | Non-ASCII path — ffmpeg on Windows reads UTF-8 as ANSI | Keep project, output, and frame paths ASCII. Lessons #11 |
| Node can't find `/tmp/...` | Git Bash's `/tmp` ≠ Node's `/tmp` on Windows | Pass `C:/...` forward-slash absolute paths. Lessons #12 |
| QC reports every frame as suspicious | Fixed frame-size threshold, invalid on dark themes | Now adaptive (35% of the video's own median). Lessons #19 |
| QC says everything passed but late scenes were never checked | Sampling that slices the first N blocks instead of stepping evenly | Fixed. Lessons #29 |
| Subtitles vanish slightly early on the last block | `speech_end_sec` computed in pre-trim coordinates | Fixed in 1.2.2. Lessons #31 |
| First 0.5s of the video is nearly black | Entrances fading in from `opacity: 0` on the first narration beat | Start earlier, use `power4.out`, and let a mask wipe provide the reveal instead of opacity. Lessons #34 |
| Rendered PNGs disappeared after a preview | Preview mode deletes the frame directory by design | Pass `--keep-frames`. Lessons #32 |
| Re-rendered the whole video to fix one scene | No per-scene switch exists | Use the temporary-project technique. Lessons #33 |
| 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 |
| Preview finished, frame PNGs are gone | Preview mode clears the frame directory by design | Pass `--keep-frames` |
**`references/lessons.md` is the most valuable file here.** 34 numbered entries, each
recording a bug that produced a plausible-looking video while being wrong — including how
it was misdiagnosed first. Read it before debugging from scratch.
**`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**
**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.
- You want to turn articles, documentation, or a topic into narrated videos at some volume.
- You already work with an AI coding agent and would rather express scenes as HTML than
learn a motion-graphics suite.
- You need reproducible output — re-render the same project and get the same frames.
- You need it to run offline, or you object to per-render pricing.
- You want the infrastructure work done: subtitles, audio sync, covers, QC.
**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).
**Poor fit**
---
- Real-footage editing, talking-head video, or recreating an existing video.
- React component animation as the authoring model (that is the job of the Remotion-based
`anything2explainer`, this project's sibling).
- A graphical drag-and-drop editor. Scenes are code, deliberately.
## Prior work and sibling projects
`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.
---
## Roadmap
- [ ] Per-scene render switch (`--only <id>`) — today this needs the temporary-project workaround
- [ ] Cover layout measurement tool (line count / four-side margins / element overlap / hook size in one pass) — today you write a dozen lines yourself
- [ ] Additional cover aspect ratios via the spec table in `cover_build.mjs`
- [ ] English-first style notes in the catalog (currently Chinese descriptions throughout)
- [ ] Optional background-music bed with automatic ducking under narration
- [ ] Frame-contract lint rules for focal-point and glow discipline (currently eyeball-only)
- [ ] Pre-built project templates for common video shapes (60s short, 3-min explainer)
- [ ] Cover layout measurement tool (line count / margins / overlap / hook font size in one pass)
- [ ] More cover aspect ratios
- [ ] English descriptions in the style catalogue
- [ ] Optional music bed with automatic ducking under narration
---
## Contributing
Issues and PRs are welcome. The most valuable contribution is a **diagnosed silent
failure** added to `references/lessons.md` — the symptom as it first misled you, the
evidence that proved the cause, the fix, and the resulting rule.
Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) first. It covers the project's hard rules,
how to run an end-to-end check, and the PR checklist.
CI checks the repository for version drift, style-catalog drift, and stray CDN references
on Linux and Windows across Python 3.9 and 3.13.
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).
---
## Acknowledgements
The narration and audio-sync methodology in this project — word-boundary subtitles, the
two-level clock, speech-rate calibration, and the QC criteria — grew out of two earlier
projects by the same author, `anything2explainer` and `html-video-workbuddy-driver`.
The 23-style visual catalog transcribes the template design specifications of
[nexu-io/html-video](https://github.com/nexu-io/html-video) (Apache-2.0). Seven of those
styles in turn trace back to MIT-licensed design work by
[Zara Zhang](https://github.com/zarazhangrui/frontend-slides),
[alchaincyf](https://github.com/alchaincyf/huashu-design), and Nate Herk.
No upstream source code is bundled or required at runtime — the catalog is a written
specification, comparable to copying a recipe out of a restaurant. Full attribution, licence
texts, and the exact style-level mapping are in
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
GSAP is bundled under GreenSock's [standard "no charge" licence](https://gsap.com/standard-license).
---
## Licence
## Licence and acknowledgements
[MIT](LICENSE) © 2026 Moh
The MIT licence covers the original code and documentation. Third-party components and the
derived style specification remain under their own terms — see
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
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).
---
@@ -547,6 +384,7 @@ derived style specification remain under their own terms — see
**Author: Moh**
Built because re-aligning an entire video by hand after one changed sentence is a bad way to spend an afternoon.
Built because "edit one sentence, then re-align the entire video by hand" is not a good way to
spend an afternoon.
</div>
+212 -376
View File
@@ -2,17 +2,18 @@
# html-explainer
**把一个主题做成有配音、有硬字幕、有封面的讲解视频 —— 全本地、用 HTML 写画面,音画同步稳得住。**
**一个 Agent Skill:给任意主题,产出一条带配音、硬字幕、封面的讲解视频。**
面向 AI 编程智能体的确定性 HTML → MP4 渲染流水线。
不需要 Remotion,不需要云端渲染队列,没有按次计费,核心链路不需要任何 API key。
HTML 写画面 → 确定性逐帧渲染 → 真 MP4。全本地跑,核心链路零 API key、零按次计费。
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.2.2-blue.svg)](CHANGELOG.md)
[![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB.svg?logo=python&logoColor=white)](https://python.org)
[![Node](https://img.shields.io/badge/Node-18%2B-339933.svg?logo=node.js&logoColor=white)](https://nodejs.org)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-6e6e6e.svg)](#环境要求)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md)
[![Agent Skill](https://img.shields.io/badge/Agent%20Skill-SKILL.md-8A2BE2.svg)](SKILL.md)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-%E2%9C%93-D97757.svg)](#安装)
[![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#安装)
[![WorkBuddy](https://img.shields.io/badge/WorkBuddy-%E2%9C%93-1f6feb.svg)](#安装)
[![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB.svg?logo=python&logoColor=white)](#环境要求)
[![Node](https://img.shields.io/badge/Node-18%2B-339933.svg?logo=node.js&logoColor=white)](#环境要求)
[简体中文](README.md) · [English](README.en.md)
@@ -20,43 +21,138 @@
---
## 这是什么
## 它是什么
`html-explainer` 用来生产科普 / 讲解 / 知识类视频 —— 就是那种带配音、带硬字幕、带进度条,
画面用 HTML/CSS/GSAP 做的动效视频。
`html-explainer` 把一个主题做成**带配音、带硬字幕、带进度条**的讲解视频,画面用
HTML/CSS/GSAP 写。
你写一份解说词。智能体(或者你自己)从 23 种画面风格里挑一个,为每个节拍写一个自包含的
HTML 文件,然后这条流水线把它渲染成真正的 MP4:字幕逐字对齐,外加两张可直接上传的封面。
它**是一个 Agent Skill**(`SKILL.md` + `scripts/` + `references/`),遵循
[Agent Skills](https://code.claude.com/docs/en/skills) 约定,Claude Code / OpenAI Codex /
WorkBuddy / Cursor / Gemini CLI 等都能直接加载。内部是普通的 Python 与 Node 脚本,
手工跑也完全没问题。
所有计算都在你自己的机器上。唯一的联网请求是 TTS 合成,以及(可选的)为写稿做的资料调研。
你只需要说一句:
它以 **Agent Skill** 的形式封装(`SKILL.md` + `scripts/` + `references/`),可以直接放进
Claude Code、WorkBuddy、Cursor,或任何能读技能文件的智能体 —— 但脚本本身只是普通的
Python 和 Node,手工跑完全没问题。
> 把「为什么天空是蓝色的」做成一条 3 分钟的讲解视频。
技能会带着智能体走完:调研 → 解说词 → 配音 → 字幕与节拍 → 挑风格写画面 → 渲染 → 体检 → 封面。
---
## 核心思路:确定性 seek,而不是屏幕录制
## 安装
多数「HTML 转视频」工具的做法是:启动浏览器、播放动画、录屏。这条路线会生出一整类让人
抓狂的 bug,因为它们都是**间歇性**的:动画在录制开始前就播了、网络字体加载晚了导致半段
视频用了错误的字体、前几帧拍到的是动画前的状态。
### 一句话安装(推荐)
`html-explainer` 不录制,它 **seek**。
把下面这句发给你的智能体:
每一帧都执行这四步:
```text
给当前本地环境安装该 Skill:https://github.com/OneMoh/html-explainer.git
安装到你的技能目录,并检测安装必要的运行环境(Python 3.9+ / Node 18+ / Chrome 或 Edge / ffmpeg)
```
1. 置位 `window.__MG_RENDER__`,让画面知道自己不该自动起播;
2. 用 `tl.pause(t, false)` 把 GSAP 时间轴定位到那一瞬间;
3. 用 `document.getAnimations().currentTime = t * 1000` 同步所有 CSS 动画;
4. 等两个动画帧后截图。
它会自己 clone 到对应目录、跑环境自检、把缺的东西装上。装完**新开一个会话**,让智能体重新
扫描技能目录。
渲染出来的第 1204 帧,和下一次渲染的第 1204 帧,逐像素一致。正是这个性质让后续的一切成为
可能 —— QC 可以定点抽查某一帧、单个场景可以独立重渲,而一整类时序 bug 从根本上无法发生。
### 手动安装
> 第二个参数不是可选项。`pause(atTime, suppressEvents)` 的 `suppressEvents` 默认为 `true`,
> 它会**静默丢弃**这次 seek 触发的所有回调 —— 用 `onUpdate` 写的数字滚动会永远停在初值,
> 且零报错。这个坑花掉了真实的调试时间,见 [`references/lessons.md`](references/lessons.md) #27。
仓库根目录**就是**技能目录(`SKILL.md` 在根),直接 clone 进技能目录即可,不用再拷子目录:
```bash
git clone https://github.com/OneMoh/html-explainer.git ~/.workbuddy/skills/html-explainer
bash ~/.workbuddy/skills/html-explainer/setup_env.sh --install
```
Windows 上 `~` 就是 `C:\Users\<你的用户名>`。
### 各智能体的技能目录
| 智能体 | 个人级(全局) | 项目级(仓库内) |
|---|---|---|
| **WorkBuddy** | `~/.workbuddy/skills/` | `<工作区>/.workbuddy/skills/` |
| **Claude Code** | `~/.claude/skills/` | `.claude/skills/` |
| **OpenAI Codex** | `~/.codex/skills/` | `.codex/skills/` 或 `.agents/skills/` |
| **Gemini CLI** | `~/.gemini/skills/` | `.gemini/skills/` 或 `.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/` |
| 其他(通用约定) | `~/.agents/skills/` | `.agents/skills/` |
记不住放哪:优先 `.agents/skills/`,多数工具都认它(Claude Code 是例外,只认
`.claude/skills/`)。
### 环境要求
| 项 | 最低 | 说明 |
|---|---|---|
| Python | 3.9+ | `edge-tts==7.2.8`(刻意钉死 —— v7 改过边界 API)、`numpy`、`pillow`、`imageio-ffmpeg` |
| Node.js | 18+ | 渲染器与封面器用 |
| 浏览器 | Chrome 或 Edge | 自动探测;都没有才下载 playwright chromium(约 115MB,只需一次) |
| ffmpeg | 任意版本 | 先在 `PATH` 找;没有则用 `imageio-ffmpeg` 自带的静态二进制 |
| 磁盘 | 每条成片约 2GB | 帧 PNG 体积大,合成后可删 |
`bash setup_env.sh` 只检查并报告缺什么,`--install` 才会装。全程不需要管理员权限。
---
## 快速开始
一条视频由**七条命令**跑完,都在**视频项目目录**里执行:
```bash
PY=<你的 venv python 路径> # 派给子智能体时要展开成绝对路径
SKILL=<技能安装目录>
# 0 · 建项目脚手架(目录树 + 配置 + 主题 + 两份封面模板)
"$PY" "$SKILL/scripts/new_project.py" ~/videos/sky-blue sky-blue --topic "为什么天空是蓝色的"
cd ~/videos/sky-blue
# …填 narration.json、填 project.json 的 order、为每个场景写一个 frames/<id>.html…
"$PY" "$SKILL/scripts/tts_build.py" --project . # 1 · 配音
"$PY" "$SKILL/scripts/timeline_build.py" --project . # 2 · 全局时间轴
"$PY" "$SKILL/scripts/subs.py" --project . # 3 · 字幕 + 节拍
"$PY" "$SKILL/scripts/lint_frames.py" --project . # 4 · 渲染前体检画面
node "$SKILL/scripts/render_video.mjs" . --preview 30 # 5 · 先出 30 秒样片
"$PY" "$SKILL/scripts/qc_check.py" --project . # 6 · QC
node "$SKILL/scripts/render_video.mjs" . # 7 · 全片渲染
node "$SKILL/scripts/cover_build.mjs" . # + 封面双方案
```
产物都在 `out/`:`slug.mp4`、`cover_169.png`、`cover_34.png`、`slug.srt`、`slug.vtt`,
外加 `qc_report.md` 与 `qc_sheet.jpg`。
**视频项目**长这样:
```
my-video/
├── project.json # slug、fps、尺寸、音色、语速、场景顺序、章节
├── narration.json # [{ id, text }] —— 用 "|" 切字幕块
├── theme.css # 所有颜色,以 CSS 变量形式
├── frames/ # 一个场景一个 HTML;<id>.beats.js 自动生成,绝不手改
├── audio/ render/ research/ script/
└── out/ # MP4、封面、SRT/VTT、QC 报告
```
顺序不能乱:`beats.js` 由 TTS 产出推导而来。这就是为什么改了解说词之后,下游全部自动重排
时序,而画面代码一行都不用动。
> **只要打算复用渲出来的 PNG,就一定要加 `--keep-frames`。** 预览模式在合成后会按设计
> 清空帧目录。
---
## 为什么不用录屏
多数「HTML 转视频」工具是启动浏览器、播放动画、录屏。这条路会生出一整类**间歇性** bug ——
动画在录制开始前就播了、网络字体加载晚了导致半段视频用了错误字体、前几帧拍到的是动画前的状态。
`html-explainer` 不录制,它 **seek**:把 GSAP 时间轴定位到那一瞬间
(`tl.pause(t, false)`),同步所有 CSS 动画(`document.getAnimations().currentTime`),
等两个动画帧后截图。于是第 1204 帧与下一次渲染的第 1204 帧逐像素一致 —— QC 可以定点抽查、
单个场景可以独立重渲,一整类时序 bug 从根本上无法发生。
代价是**每个动画都必须可 seek**:墙钟动画(`setInterval`、`requestAnimationFrame` 计数、
CSS `transition` 入场)不可能工作,会被 `lint_frames.py` 直接拒绝。
---
@@ -64,35 +160,13 @@ Python 和 Node,手工跑完全没问题。
| | |
|---|---|
| **节拍锚定解说词** | 画面按**匹配字幕文本**定位动画(`B('块文本')`),绝不硬编码帧号。改解说词,全片自动重排时间 —— **画面代码一行都不用改。** |
| **词边界字幕** | 时序来自 edge-tts 的 `WordBoundary` 事件,不按字数插值。中文里两个同字数的短语时长可以差 3 倍,插值会明显错位。 |
| **两级时钟** | 帧长用 MP3 **容器时长**(保证音画同步);末块字幕按**语音真实结束**收尾。两者混用会让每场景字幕多停约 0.8 秒。 |
| **23 种画面风格** | 8 个类别 —— 大胆信号卡、奢华极简、NYT 编辑级数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、品牌收尾等,每种都记录了画布、字阶、时间轴与配色纪律。 |
| **封面双方案** | 每条成片都附带 16:9 信息流封面与**独立排版**的 3:4 主页封面 —— 不是裁切,裁切会丢掉 57.8% 的画面宽度。 |
| **渲染前静态体检** | `lint_frames.py` 在你花 15 分钟渲染之前,先把八条画面契约的违规逐条报出来。 |
| **自适应 QC** | 响度、时长漂移、流完整性、抽帧速览图 —— 抽帧体积阈值取本片自身中位数推导,暗色主题不会产生假阳性。 |
| **结构性离线** | GSAP 内置;Chrome / Edge 自动探测;ffmpeg 缺失时回退到 `imageio-ffmpeg` 自带的静态二进制;网络字体被契约禁止。 |
| **配色单一来源** | 所有颜色都是 `theme.css` 里的 CSS 变量。换主题即整片换肤,画面代码零改动。 |
---
## 横向对比
| | `html-explainer` | 录屏式 HTML 工具 | Remotion | 云端视频 API |
|---|---|---|---|---|
| **渲染方式** | 确定性 seek | 实时录制 | React → 逐帧 | 服务端渲染 |
| **帧可复现** | 是 | 否 | 是 | 不适用 |
| **单次成本** | 零 | 零 | 零 | 按量计费 |
| **运行时依赖** | Node + Python + 浏览器 | 同上 | Node + React 工具链 | 网络 + 账号 |
| **创作模型** | 每场景一个 HTML | 每场景一个 HTML | React 组件 | JSON / 模板 |
| **改稿后重新对时** | 自动(`B()` 节拍) | 手工 | 手工 | 手工 |
| **全程离线可跑** | 是 | 是 | 是 | 否 |
| **内置风格库** | 23 种(含规范文档) | 视情况 | 社区模板 | 厂商模板 |
| **封面 / 缩略图产出** | 两套已验证版式 | 无 | 无 | 有时有 |
诚实的取舍:因为渲染是 seek 式的,**每一个动画都必须可 seek**。墙钟动画(`setInterval`、
`requestAnimationFrame` 计数、CSS `transition` 入场)不可能工作,会被 linter 直接拒绝。
这个约束换来了确定性 —— 而且它是被强制执行的,不只是写在文档里的建议。
| **节拍锚定解说词** | 画面按**匹配字幕文本**定位动画(`B('块文本')`),绝不硬编码帧号。改解说词,全片自动重排时间,画面代码零改动。 |
| **词边界字幕** | 时序来自 edge-tts 的 `WordBoundary` 事件,不按字数插值 —— 中文里两个同字数的短语时长可以差 3 倍。 |
| **两级时钟** | 帧长用 MP3 **容器时长**(保音画同步);末块字幕按**语音真实结束**收尾。 |
| **23 种画面风格** | 8 个类别,每种都记录了画布、字阶、时间轴与配色纪律。不用对着空白页从零设计。 |
| **封面双方案** | 每条成片附带 16:9 与**独立重排**的 3:4 封面 —— 不是裁切,裁切会丢掉 57.8% 的画面宽度。 |
| **渲染前体检 + QC** | `lint_frames.py` 在渲染前报出契约违规;`qc_check.py` 查响度、时长漂移、抽帧速览。 |
| **结构性离线** | GSAP 内置;浏览器自动探测;ffmpeg 缺失时回退到 `imageio-ffmpeg` 静态二进制;网络字体被契约禁止。 |
---
@@ -119,309 +193,84 @@ flowchart LR
style M fill:#238636,color:#fff
```
顺序不能乱。`beats.js` 由 TTS 产出推导而来 —— 这就是为什么改了解说词之后,下游全部自动
重排时序,而场景代码完全不用动。
---
## 环境要求
| 项 | 最低 | 说明 |
|---|---|---|
| Python | 3.9+ | `edge-tts==7.2.8`(刻意钉死 —— v7 改过边界 API)、`numpy`、`pillow`、`imageio-ffmpeg` |
| Node.js | 18+ | 渲染器与封面器用 |
| 浏览器 | Chrome 或 Edge | 自动探测。都没有才下载 playwright chromium(约 115MB,只需一次) |
| ffmpeg | 任意版本 | 先在 `PATH` 找;没有则用 `imageio-ffmpeg` 自带的静态二进制 |
| 磁盘 | 每条成片约 2GB | 帧 PNG 体积大,合成后可删 |
全程不需要管理员权限,依赖一律装用户级。
---
## 快速开始
```bash
git clone https://github.com/OneMoh/html-explainer.git
cd html-explainer
# 1 · 环境自检(缺什么报什么)
bash setup_env.sh
# 2 · 安装缺失依赖
bash setup_env.sh --install
```
然后在项目目录里跑流水线:
```bash
PY=<你的 venv python 路径> # 派给子智能体时要展开成绝对路径
SKILL=$(pwd) # 本仓库
# 阶段 0 —— 建项目脚手架
"$PY" "$SKILL/scripts/new_project.py" ~/videos/ai-intro ai-intro --topic "人工智能"
cd ~/videos/ai-intro
# …写 narration.json、填 project.json 的 order、
# 并为每个场景写一个 frames/<id>.html…
# 阶段 1–3 —— 配音、全局轴、字幕
"$PY" "$SKILL/scripts/tts_build.py" --project .
"$PY" "$SKILL/scripts/timeline_build.py" --project .
"$PY" "$SKILL/scripts/subs.py" --project .
# 阶段 4 —— 渲染前先体检画面(省下整片渲染的时间)
"$PY" "$SKILL/scripts/lint_frames.py" --project .
# 阶段 5 —— 渲染(--preview 30 先出前 30 秒的快速样片)
node "$SKILL/scripts/render_video.mjs" . --preview 30
# 阶段 6 —— QC
"$PY" "$SKILL/scripts/qc_check.py" --project .
# 阶段 7 —— 全片渲染 + 封面
node "$SKILL/scripts/render_video.mjs" .
node "$SKILL/scripts/cover_build.mjs" .
```
产物在 `out/`:`slug.mp4`、`cover_169.png`、`cover_34.png`、`slug.srt`、`slug.vtt`,
外加 `qc_report.md` 与 `qc_sheet.jpg`。
> **只要打算复用渲出来的 PNG,就一定要加 `--keep-frames`。** 预览模式在合成后会按设计
> 清空帧目录。见 [`references/lessons.md`](references/lessons.md) #32。
---
## 命令参考
### 主流水线
| 命令 | 作用 |
|---|---|
| `python scripts/new_project.py <dir> <slug> [--topic T] [--preset P] [--fps 30] [--width 1920] [--height 1080] [--lang zh]` | 建项目脚手架:配置、主题、模板,以及两份封面 HTML |
| `python scripts/tts_build.py --project . [--voice V] [--rate +8%] [--no-trim] [--no-cache]` | 合成配音;带缓存、超时、退避重试、裁静音;写两种时长口径 |
| `python scripts/timeline_build.py --project . [--gap SEC]` | 生成全局时间轴并拼接 `narration-full.mp3` |
| `python scripts/subs.py --project . [--fps N]` | 产出 `subs.json`、`srt`/`vtt` 外挂字幕,以及每场景一份 `beats.js` |
| `python scripts/lint_frames.py --project . [--only id,id] [--include-covers]` | 按八条契约静态体检所有画面帧 |
| `node scripts/render_video.mjs <dir> [--out PATH] [--preview SEC] [--fps N] [--concurrency N] [--jpeg] [--scale N] [--keep-frames] [--browser PATH]` | 渲染并合成 MP4 |
| `python scripts/qc_check.py --project . [--samples N]` | 响度 / 时长 / 流体检 + 抽帧速览图 |
| `node scripts/cover_build.mjs <dir> [--at last\|SEC] [--only 169\|34] [--scale N] [--jpg] [--keep-frames]` | 渲两张封面,默认 2 倍图 |
| `new_project.py <dir> <slug> --topic "主题"` | 建项目脚手架 |
| `tts_build.py --project .` | 合成配音(带缓存、超时、退避重试、裁静音) |
| `timeline_build.py --project .` | 全局时间轴 + 拼接 `narration-full.mp3` |
| `subs.py --project .` | 字幕三出口 + 每场景 `beats.js` |
| `lint_frames.py --project .` | 按八条契约静态体检画面帧 |
| `render_video.mjs <dir>` | 渲染并合成 MP4 |
| `qc_check.py --project .` | 响度 / 时长 / 流体检 + 抽帧速览图 |
| `cover_build.mjs <dir>` | 渲染封面双方案,默认 2 倍图 |
### 辅助工具
常用可选参数:`--preview N` 只渲前 N 秒样片、`--keep-frames` 保留帧 PNG、
`--only id,id` 只体检指定场景、`--scale N` 输出倍率。
| 命令 | 作用 |
|---|---|
| `node scripts/peek_frame.mjs <dir> <id> [--at 40,80]` | 给单个场景在若干百分比时点截图 —— 是「秒级」而不是「分钟级」。`--all` 可覆盖全部场景 |
| `python scripts/make_theme.py --topic "医疗" --use` | 按主题词推导配色,或用 `--preset violet\|cyan\|amber\|mono` |
| `python scripts/check_integrity.py` | 版本号 / 风格目录 / 计数一致性 + 外链扫描(CI 也会跑) |
| `bash setup_env.sh --install` | 环境自检与依赖安装 |
| `python scripts/package_skill.py [--with-deps]` | 打成可移植 zip(不加 `--with-deps` 约 110KB) |
另有几个顺手的小工具:`peek_frame.mjs <dir> <id>`(单场景秒级截图,改画面时最常用)、
`make_theme.py --topic "医疗" --use`(按主题词推配色)、`package_skill.py`(打成可移植 zip)。
**完整参数表见 [`SKILL.md`](SKILL.md),或对任一脚本加 `--help`。**
---
## `B()` 节拍系统
这是让整个工作方式变掉的那一块。
`subs.py` 为每个场景生成一份 `beats.js`,把每块字幕按**原文**暴露出来:
`subs.py` 为每个场景生成一份 `beats.js`,把每块字幕按**原文**暴露出来,于是画面动画可以直接
锚在吐字时刻上:
```js
B('右边卡片') // 「右边卡片」这几个字开始被念出的那一秒
Be('证据') // 这句话念完的那一秒
```
于是画面动画可以这样写:
```js
var tl = gsap.timeline({ paused: true });
window.__tl = tl;
tl.fromTo('.card', { y: 60, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.7 }, B('右边卡片'));
tl.fromTo('.verdict',{ scale: 0.8 }, { scale: 1, duration: 0.6, ease: 'back.out(2)' }, B('结论句') + 0.2);
tl.fromTo('.verdict', { scale: 0.8 }, { scale: 1, duration: 0.6 }, B('结论句') + 0.2);
```
改一次解说词,重跑三条命令。节拍随之平移,所有场景自行重排对时,没有一个画面文件被改动。
对比帧号硬编码的写法:改一句话意味着整片手工重新对位。
`B('块文本')` 是「这几个字开始被念出」的时刻,`Be('块文本')` 是念完的时刻。
**兜底写法是被禁止的。** `B('x') || 3.2` 会把「节拍查不到」这件事藏在一个过期的数字后面。
查不到就应该让构建直接失败 —— 一条对错了时间的视频,比一次构建失败糟糕得多。
这是本项目自己的设计,也是让工作方式变掉的那一块:改一次解说词,重跑三条命令,所有场景自行
重排对时。对比帧号硬编码 —— 改一句话就是整片手工重新对位。
**兜底写法是被禁止的。** `B('x') || 3.2` 会把「节拍查不到」藏在一个过期的数字后面。查不到
就应该让构建直接失败 —— 一条对错了时间的视频,比一次构建失败糟糕得多。
---
## 画面风格库
**不要对着空白页从零设计画面。** 23 种风格、8 个类别,每种都记录了画布、字阶、时间轴结构与
配色纪律([`references/style-catalog.md`](references/style-catalog.md))。
配色纪律 —— 完整目录见 [`references/style-catalog.md`](references/style-catalog.md)。
| 类别 | 风格 |
|---|---|
| 演示 / 标题卡 | 大胆海报帧、大胆信号卡帧、奢华极简留白帧、创意电压分屏帧、电光工作室分屏帧、故障艺术标题帧、Kinetic Type、Swiss Grid、Warm Grain |
| 数据可视化 | NYT 风数据图表帧、数据滚动帧、NYT Graph、瑞士网格数据帧 |
| 图解 / 流程 | 东方柔和有机帧、Decision Tree |
| 氛围 / 空镜 | 胶片漏光电影帧 |
| 营销 / Hero | 流体背景 Hero 帧 |
| 片头片尾 | 品牌 Logo 收尾帧 |
| 社媒竖版(9:16) | Play Mode、Vignelli |
| 产品演示 | Product Promo、Product Promo · 30s |
| 特效 | VFX 文字光标 |
涵盖:大胆信号卡 / 奢华极简留白 / NYT 编辑级数据图表 / 瑞士网格 / 故障艺术 / 胶片漏光 /
流体 Hero / 品牌 Logo 收尾 / VFX 文字光标 / 社媒竖版(9:16)/ 产品演示等。
分成两类,改编成本差别很大:
- **`rich`(12 个)** —— 单文件 + 纯 CSS `@keyframes` 驱动。seek 渲染器**零改动即可驱动**;
改编只要三步:把网络字体换成系统字体栈、把底部元素抬出字幕带、填进真实内容。
- **`gsap`(11 个)** —— 多 composition + CDN 加载。**不要搬代码。** 多场景组合在单个可
seek 的帧里没有起播钩子,整段搬进来只会得到静止首帧,而且零报错。只取它的视觉 DNA,
用 CSS keyframes 重新表达。
- **`rich`(12 个)** —— 单文件 + 纯 CSS `@keyframes`。seek 渲染器**零改动即可驱动**;改编
只要三步:换系统字体栈、把底部元素抬出字幕带、填进真实内容。
- **`gsap`(11 个)** —— 多 composition + CDN 加载。**不要搬代码。** 只取它的视觉 DNA,用
CSS keyframes 重新表达。
一个项目建议轮换 2–4 种风格。8 个场景共用同一张脸会显得单调;用一个强冲击开场、一段留白
论述、一个理性数据收尾,观感完全不同。
一个项目建议轮换 2–4 种风格。8 个场景共用同一张脸会显得单调。
---
## 封面双方案
每条成片产出**两张互为姊妹、而非裁切关系**的封面。
从 16:9 居中裁 3:4,1920px 只剩 810px —— 丢掉 **57.8% 的画面宽度**。任何横跨全宽的标题都会
被切掉一半。所以两张封面共享同一套视觉基因(配色、幕底、主视觉、钩子文案),但各自**重新
排一次版**:
每条成片产出**两张互为姊妹、而非裁切关系**的封面。从 16:9 居中裁 3:4,1920px 只剩 810px ——
丢掉 57.8% 的画面宽度,任何横跨全宽的标题都会被切掉一半。所以两张共享同一套视觉基因,
但各自**重新排一次版**:
| 元素 | 16:9(`1920×1080`) | 3:4(`1440×1080`) |
|---|---|---|
| 悖论视觉 | 右侧,左右并置 | 上方,竖排堆叠 |
| 钩子 | 左下,两行,≥96px | 下方,三行,≥120px |
| 角标 | 左上 | 顶部居中或省略 |
| 每行字数上限 | 7 字 | 5 字 |
三个要素缺一即返工:一句**真正有张力**的大字钩子、一个**在讲矛盾**的主视觉(两个数字反向
运动、一道分裂线、一明一暗 —— 不是装饰图形)、以及四边都留有余量(≥96px / ≥110px)。
默认输出 2 倍图:`3840×2160` 与 `2880×2160`,用来应付平台压缩。
完整规则与上传前自检清单:[`references/cover-guide.md`](references/cover-guide.md)。
---
## 画面契约
一个场景 = 一个自包含 HTML 文件。八条规则 —— 由 `lint_frames.py` 强制执行,每一条的存在
都是因为违反它会**静默失败**:
| # | 规则 | 违反了会怎样 |
|---|---|---|
| 1 | 画布 `1920×1080`(或 `project.json` 声明的尺寸),只用系统字体 | 渲染视口即画布 |
| 2 | **绝不引外部字体或 CDN** | 字体请求超时,片中字重跳变 |
| 3 | 颜色只取 `../theme.css` 的变量 | 换主题变成半换皮 |
| 4 | GSAP 时间轴注册到 `window.__tl` | 渲染器找不到时间轴,直接报错 |
| 5 | 用 `B('块文本')` 定位动画 | 画面与配音变成两张皮 |
| 6 | 底部 80–170px 保持干净(字幕带) | 字幕压字,两边都读不清 |
| 7 | 每帧一个焦点,文字 ≥22px | QC 退回 |
| 8 | GSAP 从 `../assets/gsap.min.js` 加载 | CDN 不可达 → 离线渲染失败 |
帧里的数字滚动应该用**纯 transform 的「数字卷轴」**,而不是 `onUpdate`。渲染器已经传
`suppressEvents = false` 让回调能跑,但基于 transform 的动画不依赖任何回调,因此在任何
seek 策略下都成立。
细则、版式基因与反例:[`references/frame-contract.md`](references/frame-contract.md)。
---
## 目录结构
```
html-explainer/
├── SKILL.md # 面向智能体的入口:流程、命令、契约
├── README.md # 本文档(中文,默认)
├── README.en.md # 英文版
├── LICENSE · THIRD_PARTY_NOTICES.md · licenses/
├── CHANGELOG.md · CONTRIBUTING.md
│
├── scripts/
│ ├── new_project.py # 建项目
│ ├── tts_build.py # edge-tts → MP3 + 词边界
│ ├── timeline_build.py # 全局轴 + 拼接音轨
│ ├── subs.py # 字幕三出口 + beats.js
│ ├── lint_frames.py # 渲染前契约体检
│ ├── render_video.mjs # 确定性 seek 渲染器
│ ├── qc_check.py # 成片体检 + 抽帧速览图
│ ├── cover_build.mjs # 封面双方案渲染器
│ ├── peek_frame.mjs # 单场景速览截图
│ ├── make_theme.py # 推色 → theme.css
│ ├── check_integrity.py # 仓库自洽性(CI)
│ ├── import_styles.py # 一次性风格目录导入工具
│ └── package_skill.py # 可移植 zip 打包
│
├── references/
│ ├── style-catalog.md / .json # 23 种风格 / 8 个类别
│ ├── template-guide.md # 改编三步法 / 重写法
│ ├── frame-contract.md # 八条契约与理由
│ ├── cover-guide.md # 封面双方案详规
│ ├── workflow-guide.md # 阶段详解 + agent prompt 模板
│ └── lessons.md # 34 条静默失败复盘
│
├── assets/
│ ├── frame-template.html # 场景模板
│ ├── cover-template.html # 封面模板
│ └── gsap.min.js # GSAP 3.13,内置供离线渲染
│
├── node/ # 随包 playwright-core
├── setup_env.sh # 环境自检 / 安装
└── .github/workflows/ci.yml # CI:自洽性、语法、外链扫描
```
一个 **视频项目** 长这样:
```
my-video/
├── project.json # slug、fps、尺寸、音色、语速、场景顺序、章节
├── narration.json # [{ id, text }] —— 用 "|" 切字幕块
├── theme.css # 所有颜色,以 CSS 变量形式
├── frames/
│ ├── _template.html
│ ├── <id>.html # 一个场景一个文件
│ ├── <id>.beats.js # 自动生成 —— 绝不手改
│ ├── cover_169.html
│ └── cover_34.html
├── audio/ render/ research/ script/
└── out/ # MP4、封面、SRT/VTT、QC 报告
```
---
## 配置说明
`project.json`:
| 字段 | 含义 |
|---|---|
| `slug` | 输出文件名主干 |
| `lang` | `zh` 或 `en` —— 决定默认音色 |
| `fps` | 帧率(默认 30) |
| `width` / `height` | 画布尺寸(默认 `1920×1080`) |
| `gap` | 场景之间插入的静音秒数 |
| `order` | 场景 ID 的播放顺序 —— **写完 `narration.json` 后必须填** |
| `chapters` | 可选 `[{ title, startSegment }]`,给进度条加章节刻度 |
| `theme` | 主题预设名 |
| `voice` | edge-tts 音色,如 `zh-CN-YunxiNeural` |
| `rate` | 语速,如 `+8%` |
| `progress` | 是否渲染全局进度条 |
写解说词:
```json
[
{ "id": "hook", "text": "开场钩子,一到两句。|用竖线切字幕块,每块不超过16个字。" },
{ "id": "point1", "text": "一个场景一句解说。|字幕块之间用竖线分隔。" }
]
```
- `id` 会成为场景名,必须是 ASCII(`hook`、`point1`、`ending`)。
- `|` 切的是**字幕块**,不是场景。一条解说词 = 一个场景。
- 中文字幕每块 ≤16 字,切点落在语义边界上。
- 不要靠加回标点来控制断行 —— 渲染器把字幕当作屏幕文本,会去掉标点。
- **相邻的两个数字之间要插中文逗号**,否则 edge-tts 会把它们熔成一个数
(`沪深300` + `4479.55` → 「三百万四千四百七十九点五五点」)。
中文语速约 5.4–6 字/秒。如果总时长偏离目标超过 15%,改句子 —— 不要扭曲语速去硬凑。
两个尺寸的输出都由 `new_project.py` 直接生成模板,默认出 2 倍图。完整规则与上传前自检清单:
[`references/cover-guide.md`](references/cover-guide.md)。
---
@@ -429,18 +278,12 @@ my-video/
| 症状 | 原因 | 处理 |
|---|---|---|
| 每一帧都是静止首帧,且不报错 | 时间轴没注册到 `window.__tl`;或 `page.evaluate` 传了**字符串**而不是函数字面量(Playwright 只把字符串求值一次,返回的是函数对象,函数体根本不执行) | 注册时间轴;传真正的函数字面量。lessons #17 |
| 数字能渲染,但永远不动 | seek 抑制了 `onUpdate` 回调 | 渲染器已修(`pause(t, false)`)。帧里改用 transform 数字卷轴。lessons #27 |
| 视频变成 1280×720 而不是 1920×1080 | `viewport` 传给了 `browser.launch()` —— 它是 *context* 级选项 | 传给 `newPage()`。lessons #18 |
| 封面出成 1 倍图,但日志写着 2 倍 | 同上,`deviceScaleFactor` 的孪生坑 | 传给 `newPage()` + `screenshot({ scale: 'device' })`;用 PIL 核尺寸。lessons #28 |
| Windows 上 `No such file or directory` | 非 ASCII 路径 —— Windows 的 ffmpeg 把 UTF-8 当 ANSI 读 | 项目、输出、抽帧路径全部保持 ASCII。lessons #11 |
| Node 找不到 `/tmp/...` | Git Bash 的 `/tmp` ≠ Node 的 `/tmp` | 传 `C:/...` 正斜杠绝对路径。lessons #12 |
| QC 把所有帧都报成可疑 | 抽帧体积阈值写死了,在暗色主题上失效 | 已改为自适应(本片中位数的 35%)。lessons #19 |
| QC 说全过,但后半段其实一帧没抽 | 抽样逻辑取了前 N 块,而不是按步长均匀挑 | 已修。lessons #29 |
| 末块字幕提前消失 | `speech_end_sec` 用了裁前坐标 | 1.2.2 已修。lessons #31 |
| 视频前 0.5 秒几乎全黑 | 第一句解说配了从 `opacity: 0` 开始的淡入 | 提前入场起点、换 `power4.out`,并让遮罩上推本身去提供遮挡而不是叠加透明度。lessons #34 |
| 预览渲完,帧 PNG 不见了 | 预览模式按设计会清空帧目录 | 加 `--keep-frames`。lessons #32 |
| 为了修一个场景重渲了整片 | 渲染器没有单场景开关 | 用临时项目法。lessons #33 |
| 每一帧都是静止首帧,且不报错 | 时间轴没注册到 `window.__tl`;或 `page.evaluate` 传了**字符串**而非函数字面量(Playwright 只求值一次,函数体根本不执行) | 传真正的函数字面量 |
| 视频变成 1280×720 | `viewport` 传给了 `browser.launch()` —— 它是 *context* 级选项 | 传给 `newPage()` |
| 封面出成 1 倍图 | 同上,`deviceScaleFactor` 的孪生坑 | 传 `newPage()` + `screenshot({ scale: 'device' })` |
| 数字能渲染但永远不动 | seek 抑制了 `onUpdate` 回调 | 渲染器已修(`pause(t, false)`);帧里改用 transform 数字卷轴 |
| Windows 上 `No such file or directory` | 非 ASCII 路径 —— Windows 的 ffmpeg 把 UTF-8 当 ANSI 读 | 路径保持 ASCII |
| 预览渲完,帧 PNG 不见了 | 预览模式按设计会清空帧目录 | 加 `--keep-frames` |
**`references/lessons.md` 是这个仓库里最值钱的文件。** 34 条编号记录,每一条都是一个
「成片看着挺正常、其实是错的」的 bug —— 包括它一开始是怎么被误判的。从零开始 debug 之前,
@@ -450,71 +293,64 @@ my-video/
## 适合谁
**适合**
**适合**:想成规模地把文章 / 文档 / 某个主题做成带解说的视频;已经在用 AI 编程智能体,
且宁愿用 HTML 描述画面也不想学动效软件;需要可复现的输出;需要离线跑或拒绝按次计费。
- 想成规模地把文章、文档或某个主题做成带解说的视频。
- 已经在用 AI 编程智能体,并且宁愿用 HTML 描述画面,也不想学一套动效软件。
- 需要可复现的输出 —— 同一个项目重渲一次,帧应当一致。
- 需要离线跑,或者不接受按次计费。
- 想把字幕、音画同步、封面、QC 这些基础工程直接拿走用。
**不适合**:实拍剪辑、真人口播、复刻已有视频;想要图形化拖拽编辑器(画面是代码,这是刻意
选择);想把 React 组件动画作为创作模型 —— 那是姊妹项目
[`anything2explainer`](https://github.com/Vincentwei1021/anything2explainer) 的领域。
**不适合**
---
- 实拍剪辑、真人口播,或者复刻一条已有的视频。
- 想把「React 组件动画」作为创作模型(那是本项目的姊妹项目、基于 Remotion 的
`anything2explainer` 的领域)。
- 想要图形化拖拽编辑器。画面是代码,这是刻意选择。
## 参考思路与姊妹项目
`html-explainer` 的方法论不是凭空来的。它有两处明确的思想来源,两个项目都**由不同作者独立
开发,与本项目不是同一作者**;本仓库不打包、运行时不依赖它们的任何代码 —— 吸收的是设计规范
与工程经验。
- [**Vincentwei1021/anything2explainer**](https://github.com/Vincentwei1021/anything2explainer)
(TypeScript / Remotion)—— 「主题进 → 带解说的讲解视频出」。本项目从这条线继承的是
**解说与音画同步方法论**:词边界字幕、两级时钟、语速标定、QC 判据。
*差异*:它是 Remotion + React 组件动画;本项目用 HTML/CSS/GSAP + 确定性 seek。
- [**nexu-io/html-video**](https://github.com/nexu-io/html-video)(Apache-2.0,Open Design
团队)—— 「在本机把 HTML 变成真 MP4」。本项目从这条线继承的是 **23 种画面风格目录**,
转写自它的模板设计规范。其中 7 种又可追溯到 MIT 许可的设计作品。
*差异*:它靠实时录制;本项目靠 seek 逐帧渲染,帧可复现。
**本项目自己的部分**:确定性 seek 渲染器、`B()` 节拍锚定、封面双方案,以及
`lint_frames.py` / `qc_check.py` 的判据。完整的署名与逐风格对应关系见
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。
如果你要的是 React 组件动画、或 Studio 可视化协作,直接去用上面两个项目;如果你想改一句
解说词而不用手工重新对位、并且要帧级可复现,用本项目。
---
## 路线图
- [ ] 单场景渲染开关(`--only <id>`)—— 目前需要临时项目法绕过
- [ ] 封面排版终态测量器(行数 / 四边边距 / 元素重叠 / 钩子字号一次判完)—— 目前需自行写十几行
- [ ] 通过 `cover_build.mjs` 里的规格表支持更多封面比例
- [ ] 风格目录补英文说明(目前描述以中文为主)
- [ ] 封面排版终态测量器(行数 / 四边边距 / 元素重叠 / 钩子字号一次判完)
- [ ] 更多封面比例
- [ ] 风格目录补英文说明
- [ ] 可选背景音乐轨,并对解说自动做闪避(ducking)
- [ ] 「焦点 / 发光纪律」的 lint 规则(目前只能靠肉眼)
- [ ] 常见视频形态的项目模板(60 秒短片、3 分钟讲解)
---
## 参与贡献
欢迎提 issue 和 PR。最有价值的贡献,是一条**已被定位的静默失败**,加进
`references/lessons.md` —— 写下它一开始是怎么误导你的、证明病因的证据、修法,以及由此
得出的通用规则。
请先读 [`CONTRIBUTING.md`](CONTRIBUTING.md),里面有本项目的硬性规则、端到端自检方法,
以及 PR 检查清单。
CI 会在 Linux 与 Windows 上、Python 3.9 与 3.13 上检查版本号漂移、风格目录漂移和外链引用。
`references/lessons.md` —— 写下它一开始是怎么误导你的、证明病因的证据、修法,以及由此得出的
通用规则。请先读 [`CONTRIBUTING.md`](CONTRIBUTING.md)。
---
## 致谢
本项目的解说与音画同步方法论 —— 词边界字幕、两级时钟、语速标定、QC 判据 —— 源自作者
更早的两个项目 `anything2explainer` 与 `html-video-workbuddy-driver`。
23 种画面风格目录转写自 [nexu-io/html-video](https://github.com/nexu-io/html-video)
(Apache-2.0)的模板设计规范。其中 7 种风格又可追溯到 MIT 许可的设计作品,作者分别是
[Zara Zhang](https://github.com/zarazhangrui/frontend-slides)、
[alchaincyf](https://github.com/alchaincyf/huashu-design) 与 Nate Herk。
本仓库不打包、运行时不依赖任何上游源码 —— 风格目录是一份文字规范,类比把菜谱抄回家。
完整的署名、许可证原文与逐风格对应关系见
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。
GSAP 按 GreenSock 的 [standard "no charge" 许可](https://gsap.com/standard-license) 内置。
---
## 许可
## 许可与致谢
[MIT](LICENSE) © 2026 Moh
MIT 许可覆盖原创代码与文档。第三方组件与衍生出的风格规范仍适用其各自的条款 —— 见
MIT 覆盖原创代码与文档。第三方组件与衍生出的风格规范仍适用其各自的条款;GSAP 按 GreenSock
的 [standard "no charge" 许可](https://gsap.com/standard-license) 内置。完整声明见
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。
---
+43 -4
View File
@@ -218,13 +218,49 @@ GSAP 用 `../assets/gsap.min.js`(本地内置)→ 主体动画压在 speech_
| `assets/frame-template.html` | 场景模板(契约注释在文件头,B() 用法示例) |
| `assets/cover-template.html` | **封面模板**(封面三要素注释在文件头,可改尺寸复用为两份) |
| `assets/gsap.min.js` | GSAP 3.13 本地内置(离线渲染;License 见同目录 `gsap-README.md`) |
| `README.md` / `README.en.md` | 对外项目说明(中文为主,英文副) |
| `README.md` / `README.en.md` | 对外项目说明(**README.md 中文为默认**,`README.en.md` 英文;含跨 Agent 安装指引) |
| `CONTRIBUTING.md` | 贡献指南:硬性规则、端到端自检、PR 清单 |
| `CHANGELOG.md` | 版本变更史 |
| `THIRD_PARTY_NOTICES.md` | 第三方组件与衍生内容的授权声明(**发布前必读**) |
## 跨 Agent 安装
本技能遵循 [Agent Skills](https://code.claude.com/docs/en/skills) 约定(`SKILL.md` +
`scripts/` + `references/` + `assets/`),**不绑定任何单一智能体平台**。仓库根目录**就是**
技能目录,所以 clone 到下表任一路径即可直接生效,不需要再拷子目录。
| 智能体 | 个人级(全局) | 项目级(仓库内) |
|---|---|---|
| WorkBuddy | `~/.workbuddy/skills/` | `<工作区>/.workbuddy/skills/` |
| Claude Code | `~/.claude/skills/` | `.claude/skills/` |
| OpenAI Codex | `~/.codex/skills/` | `.codex/skills/` 或 `.agents/skills/` |
| Gemini CLI | `~/.gemini/skills/` | `.gemini/skills/` 或 `.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/` |
| 通用约定 | `~/.agents/skills/` | `.agents/skills/` |
手动安装(把 `~/.claude` 换成你所用智能体的目录):
```bash
git clone https://github.com/OneMoh/html-explainer.git ~/.claude/skills/html-explainer
bash ~/.claude/skills/html-explainer/setup_env.sh --install
```
也可以直接把这句话交给智能体,让它自己装:
> 给当前本地环境安装该 Skill:https://github.com/OneMoh/html-explainer.git
> 安装到你的技能目录,并检测安装必要的运行环境(Python 3.9+ / Node 18+ / Chrome 或 Edge / ffmpeg)
装完**新开一个会话**,让智能体重新扫描技能目录。同理,调用本技能时应把
`<SKILL_ROOT>` 替换成实际安装路径 —— 派子 agent 时要展开成**绝对路径**写进 prompt。
## 打包移植
技能目录自包含,不引用本机任何绝对路径(脚本按自身位置定位 `SKILL_ROOT`)。
仅有两处**兜底探测**会去探 WorkBuddy 托管的运行时目录(`setup_env.sh` 的 Python/Node
候选、`peek_frame.mjs` 的技能根候选)—— 探不到就自动跳过,不影响其它平台。
**零依赖声明(重要)**:本技能**不需要** html-video 或 anything2explainer 存在。
两个来源项目的名字只出现在:① 代码注释的出处署名 ② `scripts/import_styles.py`
@@ -237,7 +273,7 @@ qc → cover)**完全不碰这两个项目**。风格库是抄成纯文本的
python package_skill.py # → dist/html-explainer-v<版本>.zip
python package_skill.py --with-deps # 含 playwright-core,~14MB(目标机全程离线)
# ② 新机器:解压到任意目录(推荐 ~/.workbuddy/skills/)
# ② 新机器:解压到任意目录(放进上表任一「个人级」技能目录即可被该智能体识别)
bash setup_env.sh --install # 自检 + 装缺项(先装后判定,装成功即算就绪)
# ③ 跑一遍端到端(可选,验证链路)
@@ -249,8 +285,8 @@ python scripts/qc_check.py --project . && node scripts/cover_build.mjs .
```
**依赖探测顺序**(都尽量用系统已有的,避免下载):
- Python:先找托管 venv,再 `python3` / `python`
- Node:`node -v` ≥18;没有则扫 `~/.workbuddy/binaries/node/versions/*`
- Python:先找 WorkBuddy 托管 venv,再 `python3` / `python`
- Node:`node -v` ≥18;没有则扫 `~/.workbuddy/binaries/node/versions/*`(WorkBuddy 托管路径)
- 浏览器:Chrome → Edge → playwright chromium → `BROWSER_PATH` 环境变量
- ffmpeg:PATH → `imageio_ffmpeg.get_ffmpeg_exe()`(pip 装依赖时自带静态二进制)
- GSAP:包内 `assets/gsap.min.js`(离线,不外链)
@@ -263,6 +299,9 @@ python scripts/qc_check.py --project . && node scripts/cover_build.mjs .
- 本技能**原创代码与文档**:MIT © 2026 **Moh**(见 `LICENSE`)。
- **画面风格目录**(`references/style-catalog.md` / `.json`)由 [nexu-io/html-video](https://github.com/nexu-io/html-video)
(Apache-2.0)的模板设计规范转写而来,已保留署名;转写文本按 Apache-2.0 分发。
- **方法论思路来源**:[Vincentwei1021/anything2explainer](https://github.com/Vincentwei1021/anything2explainer)
(词边界字幕 / 两级时钟 / 语速标定 / QC 判据)。两者均为**他人独立项目,与本技能不是同一作者**。
确定性 seek 渲染器、`B()` 节拍锚定、封面双方案为本项目原创。
- **打包内置**:GSAP 3.13(GreenSock 标准「no charge」许可,见 `assets/gsap-README.md`)、
playwright-core(Apache-2.0)。
- 完整清单与逐条授权见 **`THIRD_PARTY_NOTICES.md`**。发布/再分发前请连同该文件一起带上。
+94 -100
View File
@@ -1,123 +1,110 @@
# Third-Party Notices
# 第三方声明
`html-explainer` is MIT-licensed (see [LICENSE](LICENSE)). That license covers the
**original code and documentation** authored for this project.
`html-explainer` 以 MIT 许可发布(见 [LICENSE](LICENSE))。该许可覆盖本项目原创的
**代码与文档**。
It does **not** cover the third-party components and derived content listed below.
Those remain under their own terms, reproduced here as required.
它**不**覆盖下面列出的第三方组件与衍生内容 —— 那些仍适用其各自的条款,按要求在此复现。
> **If you redistribute this project (fork, zip, package, mirror, or ship it inside a
> product), you must carry this file along with it.**
> **如果你要再分发本项目(fork、打包 zip、发布为包、镜像,或放进某个产品里),必须把本文件
> 一起带上。**
---
## 1. Bundled in this repository
## 1. 随仓库打包的组件
### GSAP 3.13.0 — `assets/gsap.min.js`
- **Copyright**: © 2025 GreenSock. All rights reserved. Author: Jack Doyle.
- **License**: GreenSock standard "no charge" license — <https://gsap.com/standard-license>
- **Status**: Bundled verbatim for offline rendering. Not modified.
- **Note**: This is **not** an OSI license. GSAP is free to use, including commercially,
under its standard license terms. The copyright header inside `assets/gsap.min.js`
must not be removed. Upstream README is kept alongside it at
`assets/gsap-README.md`.
- **版权**:© 2025 GreenSock. All rights reserved. 作者 Jack Doyle。
- **许可**:GreenSock standard "no charge" 许可 —— <https://gsap.com/standard-license>
- **状态**:为离线渲染逐字内置,未做修改。
- **说明**:这**不是**一个 OSI 许可。GSAP 在其标准许可条款下可免费使用(含商业用途)。
`assets/gsap.min.js` 里的版权头不得移除。上游 README 一并保留在 `assets/gsap-README.md`。
---
## 2. Runtime dependencies (installed, not vendored)
## 2. 运行时依赖(安装,而非随包内置)
These are declared as dependencies and fetched by `bash setup_env.sh --install`.
They are not committed to this repository.
这些在依赖清单中声明,由 `bash setup_env.sh --install` 拉取,**不**提交进本仓库。
| Component | Version (tested) | License | Upstream |
| 组件 | 版本(已测) | 许可 | 上游 |
|---|---|---|---|
| `edge-tts` | 7.2.8 (pinned) | LGPL-3.0 | <https://github.com/rany2/edge-tts> |
| `edge-tts` | 7.2.8(钉死) | LGPL-3.0 | <https://github.com/rany2/edge-tts> |
| `playwright-core` | 1.63.0 | Apache-2.0 | <https://github.com/microsoft/playwright> |
| `numpy` | 2.5.3 | BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0 | <https://numpy.org> |
| `pillow` | 12.3.0 | MIT-CMU | <https://python-pillow.org> |
| `imageio-ffmpeg` | 0.6.0 | BSD-2-Clause | <https://github.com/imageio/imageio-ffmpeg> |
| `ffmpeg` (binary) | via `imageio-ffmpeg` | LGPL-2.1+ / GPL-2.0+ (varies by build) | <https://ffmpeg.org/legal.html> |
| `ffmpeg`(二进制) | 来自 `imageio-ffmpeg` | LGPL-2.1+ / GPL-2.0+(随构建而异) | <https://ffmpeg.org/legal.html> |
**A note on `edge-tts` (LGPL-3.0) and `ffmpeg`:** these are invoked as **separate
processes / imported as unmodified libraries**; this project does not statically link
them or ship modified copies. If you plan to redistribute a *bundled* binary build of
ffmpeg, review FFmpeg's own licensing page — build flags determine whether a given
binary is LGPL or GPL.
**关于 `edge-tts`(LGPL-3.0)与 `ffmpeg`**:本项目以**独立进程调用 / 作为未修改的库导入**
的方式使用它们,不做静态链接,也不分发修改过的副本。如果你打算再分发一个**打包的** ffmpeg
二进制构建,请先看 FFmpeg 自己的许可页面 —— 编译选项决定该二进制属于 LGPL 还是 GPL。
`edge-tts` is pinned to `7.2.8` intentionally: the 7.x series has had breaking changes
in its speech-boundary API, and this project depends on `WordBoundary` events.
`edge-tts` 是**刻意**钉在 `7.2.8` 的:7.x 系列在其语音边界 API 上有过破坏性变更,而本项目
依赖 `WordBoundary` 事件。
---
## 3. Derived content — the style catalog
## 3. 衍生内容 —— 风格目录
`references/style-catalog.md` and `references/style-catalog.json` are a **transcription
of third-party template design specifications** — canvas size, typography, timeline
structure, colour discipline — for 23 named styles. No template source code, markup, or
asset from any upstream is included. Only factual design parameters and style names are
recorded, so that a compatible frame can be authored from scratch.
`references/style-catalog.md` 与 `references/style-catalog.json` 是**对第三方模板设计规范的
转写** —— 画布尺寸、字体排印、时间轴结构、配色纪律 —— 覆盖 23 种具名风格。其中**不包含**任何
上游的模板源码、标记或素材。记录的只有事实性的设计参数与风格名称,目的是让人能从零写出一个
兼容的画面帧。
This catalog was produced by `scripts/import_styles.py`, which reads an installed copy of
html-video and writes the specification out as plain text. **It is a one-time porting
tool.** Running an html-explainer video never reads html-video, and no html-video code is
executed or linked at any point.
该目录由 `scripts/import_styles.py` 产出:它读取一份**已安装的** html-video,把规范转写成纯
文本。**它是一个移植期的一次性工具。** 跑一条 html-explainer 视频从不读取 html-video,
任何环节都不会执行或链接 html-video 的代码。
### 3.1 `nexu-io/html-video` — 23 templates
### 3.1 `nexu-io/html-video` —— 23 个模板
- **Copyright**: the html-video authors (Open Design / nexu-io)
- **License**: Apache-2.0
- **Source**: <https://github.com/nexu-io/html-video>
- **What was derived**: design specifications and style identifiers for all 23 templates
(`frame-bold-poster` … `vfx-text-cursor`), transcribed into `references/style-catalog.*`.
- **Changes made**: specifications reformatted into a flat Markdown/JSON catalog; each
template classified as `rich` (single-file CSS `@keyframes`) or `gsap` (multi-composition);
annotated with adaptation cost and subtitle-safe-zone constraints. Original template
code, compositions, and assets were **not** copied.
- Apache-2.0 requires that recipients of a derivative work receive a copy of the license.
A verbatim copy is included at [`licenses/Apache-2.0.txt`](licenses/Apache-2.0.txt).
The upstream repository ships no `NOTICE` file, so no `NOTICE` propagation applies
(Apache-2.0 §4(d)).
- **版权**:html-video 的作者(Open Design / nexu-io)
- **许可**:Apache-2.0
- **来源**:<https://github.com/nexu-io/html-video>
- **衍生了什么**:全部 23 个模板的设计规范与风格标识符(`frame-bold-poster` …
`vfx-text-cursor`),转写进 `references/style-catalog.*`。
- **做了哪些改动**:规范重排为扁平的 Markdown / JSON 目录;每个模板分类为 `rich`
(单文件 CSS `@keyframes`)或 `gsap`(多 composition);补充了改编成本与字幕禁区约束的
标注。原始模板代码、composition 与素材**没有**被复制。
- Apache-2.0 要求衍生作品的接收者获得一份许可副本。逐字副本见
[`licenses/Apache-2.0.txt`](licenses/Apache-2.0.txt)。上游仓库不含 `NOTICE` 文件,
因此不涉及 `NOTICE` 传递(Apache-2.0 §4(d))。
### 3.2 Upstreams of the html-video templates
### 3.2 html-video 模板的上游
`references/style-catalog.json` records a `via` field per style, preserving the
provenance that html-video attaches to each of its templates. Seven of the 23 styles
trace back to an earlier MIT-licensed project. Those notices are reproduced here because
this project's catalog describes the same styles.
`references/style-catalog.json` 为每种风格记录了 `via` 字段,保留了 html-video 为其模板附带的
来源信息。23 种风格中有 7 种可追溯到更早的 MIT 许可项目。由于本项目的目录描述了同样的风格,
这些声明在此一并复现。
Exact mapping, as recorded in the catalog:
按目录中记录的精确对应关系:
#### `frontend-slides` — 4 styles
#### `frontend-slides` —— 4 种风格
- **Copyright**: Zara Zhang © 2025
- **License**: MIT
- **Source**: <https://github.com/zarazhangrui/frontend-slides>
- **Styles**: `frame-bold-poster`, `frame-bold-signal`, `frame-creative-voltage`,
- **版权**:Zara Zhang © 2025
- **许可**:MIT
- **来源**:<https://github.com/zarazhangrui/frontend-slides>
- **风格**:`frame-bold-poster`、`frame-bold-signal`、`frame-creative-voltage`、
`frame-electric-studio`
#### `huashu-design` — 3 styles
#### `huashu-design` —— 3 种风格
- **Copyright**: alchaincyf (花叔 · 花生) © 2026
- **License**: MIT
- **Source**: <https://github.com/alchaincyf/huashu-design>
- **Styles**: `frame-build-minimal`, `frame-pentagram-stat`, `frame-takram-organic`
- **版权**:alchaincyf(花叔 · 花生)© 2026
- **许可**:MIT
- **来源**:<https://github.com/alchaincyf/huashu-design>
- **风格**:`frame-build-minimal`、`frame-pentagram-stat`、`frame-takram-organic`
#### `hyperframes-student-kit` (Nate Herk) — 1 style
#### `hyperframes-student-kit`(Nate Herk)—— 1 种风格
- **Copyright**: Nate Herk
- **License**: MIT (as recorded in the catalog's `license` field for this entry)
- **Styles**: `frame-product-promo-30s` — recorded upstream as a fork of
`linear-promo-30s`, with brand-specific copy and assets replaced by generic
placeholders.
- **版权**:Nate Herk
- **许可**:MIT(按目录中该条目的 `license` 字段记录)
- **风格**:`frame-product-promo-30s` —— 上游记录为 `linear-promo-30s` 的 fork,
品牌相关的文案与素材已替换为通用占位内容。
html-video's own `ATTRIBUTIONS.md` documents the first two upstreams and states its rule
for all such templates: static slide designs were re-expressed as original CSS/SVG
keyframe timelines, with new sample data and no verbatim source copying, and the studio
names attached to some styles (Pentagram, Build, Takram) are recorded as **inspiration
only, never as affiliation or endorsement**. This project inherits that position.
html-video 自己的 `ATTRIBUTIONS.md` 记录了前两个上游,并给出了它对此类模板的处理原则:静态
幻灯片设计被重新表达为原创的 CSS/SVG 关键帧时间轴,配以新的示例数据,不做逐字源码复制;部分
风格上挂着的设计事务所名字(Pentagram、Build、Takram)记录为**仅表灵感来源,绝不表示隶属或
背书**。本项目沿用这一立场。
MIT License notice for all three upstreams above:
上述三个上游的 MIT 许可声明:
```
Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -139,34 +126,41 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### 3.3 Studio names — inspiration only, no affiliation
### 3.3 设计事务所名称 —— 仅表灵感,无隶属关系
Some style names reference real design studios or publications as a record of stylistic
inspiration (Pentagram, Build, Takram, NYT, Vignelli, Swiss Grid). These are **factual
statements of inspiration only**. This project — and html-video before it — is **not
affiliated with, endorsed by, or sponsored by** any of them. No third-party logo,
wordmark, typeface file, or branded asset is included; all frames are authored from
scratch with system fonts.
部分风格名引用了真实的设计事务所或出版物,作为风格灵感来源的记录(Pentagram、Build、
Takram、NYT、Vignelli、Swiss Grid)。这些**仅为事实性的灵感说明**。本项目 —— 以及它之前的
html-video —— **与其中任何一方均无隶属、背书或赞助关系**。项目中不含任何第三方的 logo、
字标、字体文件或品牌素材;所有画面都用系统字体从零创作。
---
## 4. Design lineage of the workflow
## 4. 方法论上的思路来源
The narration/audio-sync methodology (word-boundary subtitles, two-level clock, speech-rate
calibration, QC criteria) was developed in the author's earlier private projects,
`anything2explainer` and `html-video-workbuddy-driver`. Both are the author's own work.
No code from either is shipped or required at runtime; the names appear only in comments
and historical notes.
解说与音画同步方法论(词边界字幕、两级时钟、语速标定、QC 判据)参考了下列项目,它们
**由不同作者独立开发,与本项目不是同一作者**:
- **[Vincentwei1021/anything2explainer](https://github.com/Vincentwei1021/anything2explainer)**
—— Remotion + TypeScript 路线。本项目从其承继了「解说 + 音画同步」这套工程方法论。
- **[nexu-io/html-video](https://github.com/nexu-io/html-video)** —— Apache-2.0。除第 3.1 节
所述的风格目录外,其「在本机把 HTML 变成 MP4」的整体工程取舍亦有参考价值。
原始思路作者亦在自己的私有工作中做过 WorkBuddy 平台的适配版本(`anything2explainer` 技能的
本地特化版、`html-video-workbuddy-driver`)。**这些适配版不随本仓库分发,运行时也不需要。**
名字仅出现在注释与历史记录中。
**本项目自己的设计**:确定性 seek 渲染器、`B()` 节拍锚定、封面双方案,以及
`lint_frames.py` / `qc_check.py` 的判据。
---
## Summary for redistributors
## 再分发者须知
| Must ship with your copy | Why |
| 必须随你的副本一起提供 | 原因 |
|---|---|
| `LICENSE` | MIT terms for the original code |
| `THIRD_PARTY_NOTICES.md` (this file) | Apache-2.0 §4 + MIT notice retention |
| `licenses/Apache-2.0.txt` | Apache-2.0 §4(a) requires a copy of the license |
| `assets/gsap-README.md` | GSAP license reference; keep the header in `gsap.min.js` intact |
| `LICENSE` | 原创代码的 MIT 条款 |
| `THIRD_PARTY_NOTICES.md`(本文件) | Apache-2.0 §4 + MIT 通知保留要求 |
| `licenses/Apache-2.0.txt` | Apache-2.0 §4(a) 要求附带一份许可副本 |
| `assets/gsap-README.md` | GSAP 许可参考;并保持 `gsap.min.js` 内的版权头完整 |
*Last reviewed: 2026-09-21.*
*最后审阅:2026-09-21。*
+16 -3
View File
@@ -24,7 +24,16 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// 本脚本放在**项目**的 script/ 下(不是技能目录里),所以不能靠相对路径找技能根。
// 解析顺序:env → 向上找含 node/playwright-core 的目录 → ~/.workbuddy/skills/html-explainer
// 解析顺序:env → 向上逐级找含 node/playwright-core 的目录 → 各智能体技能目录下的同名落点。
// 与平台无关:任何一处命中即可,命中不了就报错退出(不会静默用错目录)。
const SKILL_SUBDIRS = [
['.workbuddy', 'skills'], // WorkBuddy
['.claude', 'skills'], // Claude Code
['.codex', 'skills'], // OpenAI Codex
['.gemini', 'skills'], // Gemini CLI
['.cursor', 'skills'], // Cursor
['.agents', 'skills'], // 通用约定
];
function resolveSkillRoot() {
const cands = [];
if (process.env.HTML_EXPLAINER_ROOT) cands.push(process.env.HTML_EXPLAINER_ROOT);
@@ -35,8 +44,12 @@ function resolveSkillRoot() {
if (p === d) break;
d = p;
}
if (process.env.USERPROFILE) cands.push(path.join(process.env.USERPROFILE, '.workbuddy', 'skills', 'html-explainer'));
if (process.env.HOME) cands.push(path.join(process.env.HOME, '.workbuddy', 'skills', 'html-explainer'));
const homes = [process.env.USERPROFILE, process.env.HOME].filter(Boolean);
for (const home of homes) {
for (const seg of SKILL_SUBDIRS) {
cands.push(path.join(home, ...seg, 'html-explainer'));
}
}
for (const c of cands) {
try {
if (fs.existsSync(path.join(c, 'node', 'node_modules', 'playwright-core', 'package.json'))) return c;
+3
View File
@@ -29,6 +29,8 @@ info() { echo " → $1"; }
echo "== html-explainer 环境自检 =="
# ---------- Python ----------
# 候选顺序:WorkBuddy 托管 venv(本机既有依赖,优先复用)→ 系统 python3 / python。
# 前两个是 WorkBuddy 专属路径,其它平台探不到就自动跳过,不影响使用。
PY=""
for c in "$HOME/.workbuddy/binaries/python/envs/default/Scripts/python.exe" \
"$HOME/.workbuddy/binaries/python/envs/default/bin/python" \
@@ -63,6 +65,7 @@ if command -v node >/dev/null 2>&1; then
NV="$(node_major)"
if [ "${NV:-0}" -ge 18 ]; then ok "Node: $(node -v)"; else miss "Node ≥18 需要,当前 $(node -v)"; fi
else
# 兜底:WorkBuddy 托管的 Node(非标准安装,路径带版本号)。仅本机可用,其它平台跳过。
for d in "$HOME/.workbuddy/binaries/node/versions"/*/; do
if [ -x "$d/node.exe" ] || [ -x "$d/node" ]; then
export PATH="$d:$PATH"; ok "Node(托管): $(node -v)"; break