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 name: Bug 报告
description: Something rendered wrong, failed silently, or crashed. description: 画面渲错了、静默失败,或者直接崩了。
title: "[Bug] " title: "[Bug] "
labels: ["bug"] labels: ["bug"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | 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 - type: dropdown
id: stage id: stage
attributes: attributes:
label: Which stage failed? label: 哪个阶段出错了?
options: options:
- "0 · new_project (scaffold)" - "0 · new_project(建项目)"
- "1 · tts_build (voiceover)" - "1 · tts_build(配音)"
- "2 · timeline_build (global timeline)" - "2 · timeline_build(全局时间轴)"
- "3 · subs (subtitles / beats)" - "3 · subs(字幕 / 节拍)"
- "4 · lint_frames (static audit)" - "4 · lint_frames(静态体检)"
- "5 · render_video (rendering)" - "5 · render_video(渲染)"
- "6 · qc_check (audit)" - "6 · qc_check(体检)"
- "7 · cover_build (covers)" - "7 · cover_build(封面)"
- "setup_env / environment" - "setup_env / 环境问题"
- "Not sure" - "不确定"
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: what-happened id: what-happened
attributes: attributes:
label: What happened? label: 发生了什么?
description: Include the exact command you ran and the full output. description: 请附上你执行的完整命令与完整输出。
placeholder: | placeholder: |
Command: node scripts/render_video.mjs . --preview 30 命令:node scripts/render_video.mjs . --preview 30
Expected: 30s preview with animated cards 期望:30 秒预览,卡片有动画
Actual: preview renders, but the number counter never moves 实际:预览渲出来了,但数字计数器一直不动
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: symptom-class id: symptom-class
attributes: attributes:
label: Did it fail loudly or silently? label: 它是报错了,还是静默出错?
description: > 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: validations:
required: false required: false
- type: textarea - type: textarea
id: environment id: environment
attributes: attributes:
label: Environment label: 环境
description: "Run these and paste the result." description: "跑一下这些,把结果贴上来。"
value: | value: |
- OS: - 操作系统:
- `node -v`: - `node -v`:
- `python -V`: - `python -V`:
- Shell (Git Bash / PowerShell / zsh): - Shell(Git Bash / PowerShell / zsh):
- Browser used for rendering (Chrome / Edge / playwright chromium): - 渲染用浏览器(Chrome / Edge / playwright chromium):
- ffmpeg source (PATH / imageio-ffmpeg): - ffmpeg 来源(PATH / imageio-ffmpeg):
validations: validations:
required: true required: true
- type: input - type: input
id: version id: version
attributes: attributes:
label: Version label: 版本
description: The `version:` field in SKILL.md, or the release tag. description: SKILL.md 里的 `version:` 字段,或发布 tag。
placeholder: "1.2.2" placeholder: "1.2.2"
validations: validations:
required: true required: true
@@ -77,7 +76,7 @@ body:
- type: textarea - type: textarea
id: qc-output id: qc-output
attributes: attributes:
label: QC output (if you got as far as rendering) label: QC 输出(如果你已经跑到渲染那一步)
description: Paste `out/qc_report.md`, or attach the contact sheet. description: 贴 `out/qc_report.md`,或附上抽帧速览图。
validations: validations:
required: false required: false
+7 -7
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Read the frame contract first - name: 先读画面契约
url: https://github.com/OneMoh/html-explainer/blob/main/references/frame-contract.md 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. about: 绝大多数「我的画面能渲但看着不对」都能被八条契约规则解释。
- name: Check the lessons ledger - name: 先查踩坑台账
url: https://github.com/OneMoh/html-explainer/blob/main/references/lessons.md 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. about: 已知的静默失败模式及其定位方法。提 issue 之前值得先搜一遍。
- name: Setup and environment issues - name: 安装与环境问题
url: https://github.com/OneMoh/html-explainer/blob/main/README.md#疑难排查 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: 先跑 `bash setup_env.sh`,再对照 README 的「疑难排查」一节。
+18 -20
View File
@@ -1,51 +1,49 @@
name: Feature request name: 功能建议
description: Propose a new capability, style, or workflow improvement. description: 提议一个新能力、新风格,或流程改进。
title: "[Feature] " title: "[Feature] "
labels: ["enhancement"] labels: ["enhancement"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
This project deliberately keeps a small surface: HTML frames in, MP4 out, with 本项目刻意保持很小的接口面:HTML 帧进,MP4 出,音画同步稳得住。会增加构建系统、
audio-video sync that actually holds. Proposals that add a build system, a plugin 插件 API 或云依赖的提议,一般都在范围之外 —— 但如果你认为有重要的例外,请说明。
API, or a cloud dependency are usually out of scope — but say so if you think
there's an important exception.
- type: textarea - type: textarea
id: problem id: problem
attributes: attributes:
label: What problem does this solve? label: 它解决什么问题?
description: Describe the situation you're in, not the API you want. description: 描述你处的场景,而不是你想要的 API。
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: proposal id: proposal
attributes: attributes:
label: Proposed approach label: 你建议的做法
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
id: area id: area
attributes: attributes:
label: Area label: 涉及范围
options: options:
- Visual styles / templates - 画面风格 / 模板
- Narration / TTS - 解说 / TTS
- Subtitles / beat sync - 字幕 / 节拍同步
- Rendering - 渲染
- QC / auditing - QC / 体检
- Covers - 封面
- Docs - 文档
- Other - 其它
validations: validations:
required: true required: true
- type: textarea - type: textarea
id: alternatives id: alternatives
attributes: attributes:
label: Alternatives considered label: 考虑过的替代方案
description: Including "just do it manually" — it's a valid answer. description: 包括「干脆手工做」—— 这也是个有效答案。
validations: validations:
required: false 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 — 「我试了 X,还是坏的;改了 Y,就好了」在这里是真正有用的信息 ——
this pipeline produces plausible-looking output while being wrong. 这条流水线的输出经常看着挺像样,其实是错的。
--> -->
## 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 - [ ] `python scripts/lint_frames.py --project <project>` 对我动过的帧没有 FAIL
- [ ] I actually rendered the change and looked at the output - [ ] 我真的渲染过这次改动,并看过输出
- [ ] No external fonts, CDN links, or hardcoded colour literals introduced - [ ] 没有引入外部字体、CDN 链接或硬编码颜色字面量
- [ ] New lesson (if any) appended to `references/lessons.md` with the next number - [ ] 新增的踩坑记录(如果有)已按下一个编号追加到 `references/lessons.md`
- [ ] `python scripts/check_integrity.py` passes - [ ] `python scripts/check_integrity.py` 通过
- [ ] `README.md` and `README.en.md` updated together if user-facing behaviour changed - [ ] 涉及使用者可见行为的改动,`README.md` 与 `README.en.md` 已同步更新
- [ ] `THIRD_PARTY_NOTICES.md` updated if third-party content was added or re-derived - [ ] 如果新增或再衍生了第三方内容,`THIRD_PARTY_NOTICES.md` 已更新
## Related issues ## 相关 issue
<!-- Closes #... --> <!-- Closes #... -->
+11 -13
View File
@@ -7,7 +7,7 @@ on:
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
# Only the latest run per branch matters; cancel superseded ones. # 每个分支只关心最新一次运行;被取代的自动取消。
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -19,8 +19,8 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
# Windows + Git Bash is the primary development platform, so it is # Windows + Git Bash 是主要开发平台,所以和 Linux 一起测,
# tested alongside Linux rather than treated as an afterthought. # 而不是被当成事后补的。
os: [ubuntu-latest, windows-latest] os: [ubuntu-latest, windows-latest]
python: ["3.9", "3.13"] python: ["3.9", "3.13"]
@@ -35,27 +35,27 @@ jobs:
with: with:
node-version: "22" node-version: "22"
# --- Repo self-consistency: version drift, catalog drift, stray CDN links --- # --- 仓库自洽性:版本号漂移、风格目录漂移、残留外链 ---
- name: Repository integrity check - name: Repository integrity check
run: python scripts/check_integrity.py run: python scripts/check_integrity.py
# --- Python scripts must at least compile on the oldest supported version --- # --- Python 脚本至少在支持的最低版本上要能编译 ---
- name: Compile Python scripts - name: Compile Python scripts
run: python -m compileall -q scripts run: python -m compileall -q scripts
# --- Frame audit tool must be importable and its CLI must respond --- # --- 画面体检工具要能被导入,且命令行要能响应 ---
- name: Lint tool smoke test - name: Lint tool smoke test
run: python scripts/lint_frames.py --help run: python scripts/lint_frames.py --help
- name: Integrity checker smoke test - name: Integrity checker smoke test
run: python scripts/check_integrity.py --root . run: python scripts/check_integrity.py --root .
# --- Shell entrypoint syntax --- # --- Shell 入口语法 ---
- name: Check setup_env.sh syntax - name: Check setup_env.sh syntax
shell: bash shell: bash
run: bash -n setup_env.sh run: bash -n setup_env.sh
# --- Renderer / cover builder / peek tool syntax --- # --- 渲染器 / 封面器 / 单帧速览工具的语法 ---
- name: Check Node module syntax - name: Check Node module syntax
shell: bash shell: bash
run: | run: |
@@ -69,11 +69,9 @@ jobs:
shell: bash shell: bash
run: | run: |
set -e set -e
# Frames must never reach out to a CDN or web font: offline rendering # 画面帧绝不允许引 CDN 或网络字体:离线渲染不会报错,只会静默劣化。
# degrades silently rather than failing. # 只扫 HTML —— markdown 文档里*提到*这些 URL 是合理的
# Scope to HTML only — markdown docs legitimately *mention* these URLs # (比如正是指南在教你要把它们删掉),而 GSAP 自带的 README 也会介绍它的 CDN 用法。
# (e.g. the guide explaining that you must delete them), and GSAP's own
# bundled README documents its CDN option.
hits=$(grep -rInE --include='*.html' \ hits=$(grep -rInE --include='*.html' \
'https?://[^"'"'"' ]*(fonts\.googleapis|fonts\.gstatic|cdn\.)' . \ 'https?://[^"'"'"' ]*(fonts\.googleapis|fonts\.gstatic|cdn\.)' . \
|| true) || 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/), 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
Versions before `1.2.2` were private, internal releases. `1.2.2` is the first public `1.2.2` 之前的版本都是私有内部发布。**`1.2.2` 是 GitHub 上的首个公开版本。**
release on 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 - **`scripts/lint_frames.py`** —— 渲染前的画面静态体检。在花时间渲染整片之前,先检查八条
eight frame-contract rules plus known pitfalls before spending time on a full render: 画面契约与已知坑:外部字体 / CDN 引用、硬编码颜色字面量、缺失 `window.__tl` 注册、
external fonts / CDN links, hardcoded colour literals, missing `window.__tl` CDN 版 GSAP、墙钟逻辑(`setInterval`、`requestAnimationFrame` 计数)、CSS `transition`
registration, CDN GSAP, wall-clock logic (`setInterval`, `requestAnimationFrame` 入场、`B('…') || 数字` 兜底写法、侵入字幕禁区的内容、缺失中文字体族。
counters), CSS `transition` entrances, `B('…') || number` fallbacks, content intruding - **`scripts/peek_frame.mjs`** —— 单帧速览。把一个场景 seek 到若干时点截图,耗时是**秒级**,
into the subtitle safe zone, and missing CJK font families. 于是不用渲全片就能判断画面设计。`--at` 收的是百分比,不是帧号。
- **`scripts/peek_frame.mjs`** — single-frame viewer. Seeks one scene to a few time points - **双语项目 README**(`README.md` 中文为默认,`README.en.md` 英文)。
and screenshots them in seconds, so frame design can be judged without rendering the - **开源治理文件**:`LICENSE`(MIT)、`THIRD_PARTY_NOTICES.md`、`CHANGELOG.md`、
whole video. `--at` takes percentages, not frame numbers. `CONTRIBUTING.md`、`licenses/Apache-2.0.txt`、`.gitignore`、`.gitattributes`、
- **Bilingual project README** (`README.md` in Chinese, `README.en.md` in English). GitHub issue / PR 模板,以及 CI 工作流。
- **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.
### Fixed ### 修复
- **`speech_end_sec` disagreed between a cold run and a cached re-run** (lessons #31). - **`speech_end_sec` 在冷跑与缓存重跑之间不一致**(lessons #31)。`_result()` 量的是已经裁过
`_result()` measured the already-trimmed output file, so the head-trim amount was 静音的输出文件,于是头部裁剪量被减了两次:冷跑报 `7.541s`,缓存重跑报 `7.696s` ——
subtracted twice: a cold run reported `7.541s`, a cached re-run reported `7.696s` — 0.155s 的差异破坏了「两次渲染逐帧一致」的保证,并让末块字幕提前消失。现在统一存**裁后
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.
### Changed ### 变更
- Documentation: `scripts/lint_frames.py` and `scripts/peek_frame.mjs` added to the - 文档:`scripts/lint_frames.py` 与 `scripts/peek_frame.mjs` 补进命令序列与文件表
command sequence and the file reference table (both previously undocumented). (两者此前都没有被文档记录)。
- Documentation: the lint stage is now an explicit step in the workflow, between subtitle - 文档:lint 现在是工作流里的显式阶段,位置在生成字幕之后、渲染之前。
generation and rendering. - 文档:删除对私有客户项目的引用,使技能可以公开发布。
- Documentation: removed references to private client projects so the skill can ship - 许可:风格目录衍生自 Apache-2.0 内容,现已带上完整的来源署名。
publicly.
- Documented licensing: the style catalog is derived from Apache-2.0 content and now
carries full upstream attribution.
### Notes ### 备注
- Backfilled from field work: preview-render frame deletion (`--keep-frames`), the - 从实战回填:预览渲染会清空帧目录(`--keep-frames`)、重渲单个场景的临时项目法、
temporary-project technique for re-rendering a single scene, and the cold-open rule 以及「开场 0.5 秒内就必须出现主角」的冷开场规则(lessons #32–#34)。
that a video's first 0.5s must already show its subject (lessons #32–#34).
--- ---
## [1.2.1] — 2026-09-20 ## [1.2.1] — 2026-09-20
Four rendering / QC correctness fixes, all found by instrumenting the renderer rather 四个渲染 / QC 正确性修复,全部是靠给渲染器加探针找出来的,而不是靠肉眼看。
than by inspection. See `references/lessons.md` #27–#30. 详见 `references/lessons.md` #27–#30。
### Fixed ### 修复
- **`tl.pause(t)` silently suppressed seek callbacks.** GSAP's signature is - **`tl.pause(t)` 会静默吞掉本次 seek 的回调。** GSAP 的签名是
`pause(atTime, suppressEvents)` and the second parameter defaults to `true`, so any `pause(atTime, suppressEvents)`,第二参默认为 `true`,于是 seek 触发的任何
`onUpdate` / `onStart` / `onComplete` fired by a seek was dropped. A counter animated `onUpdate` / `onStart` / `onComplete` 都被丢弃。用 `onUpdate` 写的计数器因此在成片里恒为
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 `tl.pause(t, false)`。文档同时把数字滚动引导向**纯 transform 的「数字卷轴」**,
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. - **传给 `chromium.launch()` 的 `deviceScaleFactor` 被静默忽略。** `viewport` 与
- **`deviceScaleFactor` passed to `chromium.launch()` was silently ignored.** Both `deviceScaleFactor` 都是 context 级选项,只在 `newPage()` 上生效。封面实际写成了 1 倍图,
`viewport` and `deviceScaleFactor` are context-level options and only take effect on 而日志声称 2 倍。现在构建用 PIL 核验输出尺寸,不再信日志。
`newPage()`. Covers were being written at 1× while the log claimed 2×. The build now - **QC 抽样只覆盖了视频前四分之一。** `qc_check.py` 取字幕块起始时间后写成 `pts[:samples]`,
verifies output dimensions with PIL instead of trusting the log. 拿的是**前 N 块**而不是均匀铺开。一条 39 块的视频抽了 12 块,只覆盖 0.25–23.86s,后 6 个
- **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 - **封面排版是在动画中途量的。** 校验紧接页面加载后就跑,读的是 `fromTo` 起始值而不是稳定
than a spread. A 39-block video sampled 12 blocks covering only 0.25–23.86s, leaving six 终态,于是报出幽灵偏移。现在测量前先置 `__MG_RENDER__` 并 seek 到 `tl.duration()`,
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.
--- ---
## [1.2.0] — 2026-09-20 ## [1.2.0] — 2026-09-20
### Added ### 新增
- **Dual-cover system.** Every finished video now produces two independently laid-out - **封面双方案。** 每条成片现在产出两张独立排版的封面而不是一张:`cover_169.png`
covers instead of one: `cover_169.png` (1920×1080, 3840×2160 output) for the feed and (1920×1080,输出 3840×2160)用于信息流与播放器,`cover_34.png`(1440×1080,
player, and `cover_34.png` (1440×1080, 2880×2160 output) for profile grids. 输出 2880×2160)用于主页栅格。
- `scripts/cover_build.mjs` — cover renderer reusing the scene renderer's deterministic - `scripts/cover_build.mjs` —— 封面渲染器,复用场景渲染器的确定性 seek 内核,但有三处
seek core, with three deliberate differences: no subtitle layer, a single seek to one 刻意不同:不带字幕层、只 seek 到单个「封面时刻」(默认 `--at last`)而不是逐帧步进、
"cover moment" (`--at last` by default) rather than per-frame stepping, and 2× output 以及默认 2 倍输出。
by default. - `assets/cover-template.html` —— 封面模板,三个必需元素写在文件头注释里。
- `assets/cover-template.html` — cover template with the three required elements - `references/cover-guide.md` —— 重排对照表、尺寸规则、上传策略与自检清单。
documented in its header. - `scripts/new_project.py` 现在会把两份封面 HTML 一起生成到新项目里。
- `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.
### Notes ### 备注
- The two covers are **sibling layouts sharing one visual DNA, never a crop pair**. - 两张封面是**共享同一套视觉基因的姊妹版式,绝不是一对裁切**。从 16:9 裁 3:4 会丢掉
Cropping 3:4 out of 16:9 discards 57.8% of the frame width (1920 → 810px), which 57.8% 的画面宽度(1920 → 810px),必然把大字钩子切掉。重排改的是构型本身:悖论视觉
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.
--- ---
## [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` → - 核心流水线:`new_project.py` → `tts_build.py` → `timeline_build.py` → `subs.py` →
`render_video.mjs` → `qc_check.py`. `render_video.mjs` → `qc_check.py`。
- **Deterministic seek renderer** (`render_video.mjs`). Instead of screen-recording a - **确定性 seek 渲染器**(`render_video.mjs`)。不录活页面的屏,而是每一帧都靠 seek
live page, every frame is produced by seeking the GSAP timeline (`tl.pause(t, false)`) GSAP 时间轴(`tl.pause(t, false)`)并同步 CSS 动画
and synchronising CSS animations (`document.getAnimations().currentTime = t*1000`) (`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('块文本')` 节拍同步。** 画面动画按**匹配字幕块文本**定位,而不是写死帧号,于是改解说词
- **`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 - `scripts/subs.py` —— 字幕三出口(运行时 JSON、SRT/VTT 外挂、渲染器注入的画面内层),
video without touching a single frame file. 外加每场景生成一份 `beats.js`。
- `scripts/subs.py` — three subtitle outputs (runtime JSON, SRT/VTT sidecars, and the - `scripts/make_theme.py` —— 四个预设 + 按主题词推色,写出唯一颜色来源 `theme.css`。
in-frame layer injected by the renderer) plus generated `beats.js` per scene. - `scripts/qc_check.py` —— 流 / 时长 / 响度 / 抽帧体检,附抽帧速览图。
- `scripts/make_theme.py` — four presets plus topic-word colour derivation, writing a - `scripts/import_styles.py` —— 移植期一次性工具,把模板设计规范转写成
single `theme.css` source of truth for all colours. `references/style-catalog.md` / `.json`。
- `scripts/qc_check.py` — stream, duration, loudness, and sampled-frame auditing with a - 23 种画面风格目录,8 个类别,按改编成本分类。
contact sheet. - `setup_env.sh` 做首次环境自检,`--install` 装缺失依赖;`package_skill.py` 打可移植 zip。
- `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.
[1.2.2]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.2 [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 [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 | | 报告 Bug | 用 bug 模板[开一个 issue](https://github.com/OneMoh/html-explainer/issues/new/choose) |
| Rendering / sync bug you diagnosed | Add a numbered entry to `references/lessons.md` **and** fix the code | | 你已定位的渲染 / 同步问题 | 在 `references/lessons.md` 追加一条编号记录,**并且**修代码 |
| New visual style or a better adaptation recipe | `references/style-catalog.md` notes + a documented example | | 新的画面风格,或更好的改编配方 | 更新 `references/style-catalog.md`,并附一个可运行的示例 |
| Documentation fix | Direct PR — typo fixes need no prior discussion | | 文档修正 | 直接提 PR —— 改错别字不需要先讨论 |
| New feature | Open an issue first so the approach can be agreed before you write it | | 新功能 | 先开 issue 把方案谈拢,再动手写 |
| Platform-specific fix (macOS / Linux paths, shell variants) | Very welcome — the project is developed on Windows + Git Bash | | 平台相关修复(macOS / Linux 路径、其它 shell) | 非常欢迎 —— 本项目在 Windows + Git Bash 上开发 |
## Development setup ## 开发环境
```bash ```bash
git clone https://github.com/OneMoh/html-explainer.git git clone https://github.com/OneMoh/html-explainer.git
cd html-explainer cd html-explainer
# Check the environment, then install anything missing # 先自检,再装缺的东西
bash setup_env.sh # report only bash setup_env.sh # 只报告
bash setup_env.sh --install # fetch missing dependencies bash setup_env.sh --install # 安装缺失依赖
# Confirm the toolchain is healthy # 确认工具链正常
node -v && python -V node -v && python -V
``` ```
Requirements: Python ≥ 3.9, Node ≥ 18, and either Chrome or Edge (both are almost always 要求:Python ≥ 3.9、Node ≥ 18,以及 Chrome 或 Edge(两者几乎总是已经装好)。
already present). `ffmpeg` is used from `PATH` or falls back to the static binary bundled `ffmpeg` 优先从 `PATH` 取,取不到则回退到 `imageio-ffmpeg` 自带的静态二进制。
with `imageio-ffmpeg`.
### Running an end-to-end check ### 跑一次端到端自检
```bash ```bash
python scripts/new_project.py demo --topic "人工智能" python scripts/new_project.py demo --topic "人工智能"
cd demo 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/tts_build.py --project .
python ../scripts/timeline_build.py --project . python ../scripts/timeline_build.py --project .
python ../scripts/subs.py --project . python ../scripts/subs.py --project .
@@ -46,95 +44,79 @@ python ../scripts/lint_frames.py --project .
node ../scripts/render_video.mjs . --preview 30 node ../scripts/render_video.mjs . --preview 30
``` ```
If all of that completes and `out/preview.mp4` plays with synchronised audio, your 如果这些全部跑完、`out/preview.mp4` 播放时音画同步,你的环境就没问题。
environment is good.
## 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 1. **任何画面帧里都不许引外部字体或 CDN 资源。** 网络字体在很多网络下不可达,而且超时是
many networks and time out silently. Use system font stacks only, and reference GSAP 静默的。只用系统字体栈,GSAP 一律从随包的 `../assets/gsap.min.js` 引用。
from the bundled `../assets/gsap.min.js`. 2. **颜色只能取 `theme.css` 的 CSS 变量。** 画面代码里不许出现十六进制字面量 —— 这正是
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. **不许有墙钟逻辑。** `setInterval` 与 `requestAnimationFrame` 计数在确定性 seek 下不可能
3. **No wall-clock logic.** `setInterval` and `requestAnimationFrame` counters cannot 工作。动画用 GSAP 或 CSS `@keyframes`。
work under deterministic seeking. Animate with GSAP or CSS `@keyframes`. 4. **动画用 GSAP 时间轴,并注册到 `window.__tl`。** 渲染器每帧 seek 这个时间轴;没有它,
4. **Animate with GSAP timelines, registered on `window.__tl`.** The renderer seeks this 什么都不动。
timeline once per frame; without it, nothing animates. 5. **动画定位用 `B('块文本')`,绝不用写死的时间。** 兜底写法 `B('x') || 3.2` 是被禁止的 ——
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, 6. **保持字幕禁区干净。** 底部 80–170px 不许有任何内容渲染,入场路径也不许穿过它。
not silently reuse a stale number. 7. **项目目录与输出文件一律用 ASCII 路径。** 非 ASCII 路径会让 Windows 上的 ffmpeg 出错。
6. **Keep the subtitle safe zone clear.** Nothing may render in the bottom 80–170px, and 项目名与输出名保持 ASCII,要展示用的名字以后再改。
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.
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 `references/lessons.md` 是这个仓库里最值钱的文件。它之所以存在,是因为这条流水线的失败方式
this kind of pipeline fails *silently* — a bug produces a plausible-looking video rather 是**静默**的 —— 出问题不会报错,只会产出一条看着挺像样的视频。
than an error.
A good entry contains, in this order: 一条好的记录按这个顺序写:
1. **The symptom**, as you first observed it — including how it misled you. 1. **症状**,就按你最初观察到的样子 —— 包括它是怎么把你带偏的。
2. **The diagnosis**, and the evidence that proved it (a probe, a diff, a measurement). 2. **定位过程**,以及证明病因的证据(探针、diff、测量值)。
3. **The fix**, and what to check afterwards to confirm it. 3. **修法**,以及修完该检查什么来确认。
4. **The general rule** this implies, if it implies one. 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 1. 在 `references/style-catalog.md` 与 `references/style-catalog.json` 里记录规范
structure, colour discipline) and `references/style-catalog.json`. (画布、字阶、时间轴结构、配色纪律)。
2. Classify it as `rich` (single file, CSS `@keyframes` timeline — drivable by the seek 2. 分类为 `rich`(单文件,CSS `@keyframes` 时间轴 —— seek 渲染器零改动可驱动)或 `gsap`
renderer with no changes) or `gsap` (multi-composition — must be re-expressed, not (多 composition —— 必须重写表达,不能照搬)。
copied). 3. 如果这种风格需要超出标准三步(换字体栈、把底部元素抬出字幕带、填真实内容)之外的
3. Add an adaptation recipe to `references/template-guide.md` if the style needs anything 处理,在 `references/template-guide.md` 里补一条改编配方。
beyond the standard three steps (swap the font stack, lift bottom elements out of the 4. 验证它能渲出来:`node scripts/peek_frame.mjs <project> <id> --at 25,50,85`。
subtitle zone, fill in real content).
4. Verify it renders: `node scripts/peek_frame.mjs <project> <id> --at 25,50,85`.
Style identifiers are `kebab-case` and match the pattern already in use (`frame-*`, 风格标识符用 `kebab-case`,并沿用已有的命名模式(`frame-*`、`vfx-*`)。
`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 - [ ] `python scripts/lint_frames.py --project <project>` 对你动过的每一帧都没有 FAIL
you touched - [ ] 你真的渲染过你的改动。画面代码「看着对」经常是不对的 —— seek 渲染有自己的失败模式
- [ ] You have actually rendered your change. Frame code that "looks right" frequently - [ ] 新增的踩坑记录已带编号写进 `references/lessons.md`
is not — seek-based rendering has its own failure modes - [ ] 没有引入外部字体、CDN 引用或硬编码颜色
- [ ] Any new lesson is added to `references/lessons.md` with a number - [ ] 如果有再分发或再衍生的第三方内容,`THIRD_PARTY_NOTICES.md` 已更新
- [ ] No external fonts, CDN references, or hardcoded colours were introduced - [ ] 涉及使用者可见行为的改动,`README.md` 与 `README.en.md` 已同步更新
- [ ] 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
## Commit messages ## 提交信息
Conventional Commits are preferred (`fix:`, `feat:`, `docs:`, `chore:`). A body is 建议使用 Conventional Commits(`fix:`、`feat:`、`docs:`、`chore:`)。如果这次改动修的是一个
encouraged when the change fixes a silent failure — explain what was misbehaving and how 静默失败,请写正文说明它原来错在哪、你是怎么验证修好了的。
you verified the fix.
## Scope note ## 范围说明
This project intentionally does **not** aim to be a framework. It is a pipeline with a 本项目刻意**不**打算成为一个框架。它是一条流水线,接口很小、很无聊:HTML 帧进,MP4 出,
small, boring surface: HTML frames in, MP4 out, with audio-video sync that holds. Features 音画同步稳得住。会增加构建系统、插件 API,或对云服务产生运行时依赖的功能,一般都在范围之外
that add a build system, a plugin API, or a runtime dependency on a cloud service are —— 如果你认为有重要的例外,开个 issue 聊聊。
generally out of scope — say hello in an issue if you think there's an important exception.
## License ## 许可
By contributing, you agree that your contributions are licensed under the MIT License 提交贡献即表示你同意你的贡献以 MIT 许可授权(见 [LICENSE](LICENSE)),并确认你有权提交它们。
(see [LICENSE](LICENSE)), and you confirm you have the right to submit them. If you are 如果你从别的项目移植了内容,请在同一个 PR 里把它写进 `THIRD_PARTY_NOTICES.md`。
porting content from another project, add it to `THIRD_PARTY_NOTICES.md` in the same PR.
+261 -423
View File
@@ -2,17 +2,19 @@
# html-explainer # 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. Write scenes in HTML → deterministic frame-by-frame rendering → a real MP4. Everything runs
No Remotion. No cloud render queue. No per-render fees. No API keys for the core path. 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) [![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) [![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) [![Agent Skill](https://img.shields.io/badge/Agent%20Skill-SKILL.md-8A2BE2.svg)](SKILL.md)
[![Node](https://img.shields.io/badge/Node-18%2B-339933.svg?logo=node.js&logoColor=white)](https://nodejs.org) [![Claude Code](https://img.shields.io/badge/Claude%20Code-%E2%9C%93-D97757.svg)](#install)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-6e6e6e.svg)](#requirements) [![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#install)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![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) [简体中文](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 ## What it is
`html-explainer` produces explainer and knowledge videos — the kind with a voiceover, hard `html-explainer` turns a topic into a **narrated explainer video with burned-in subtitles and a
subtitles, a progress bar, and motion-graphics scenes built in HTML/CSS/GSAP. 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, **It is an Agent Skill** (`SKILL.md` + `scripts/` + `references/`), following the
writes one self-contained HTML file per beat, and this pipeline renders it to a real MP4 [Agent Skills](https://code.claude.com/docs/en/skills) convention, so Claude Code, OpenAI Codex,
with frame-accurate subtitle timing and two platform-ready covers. 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, You say one sentence:
web research for the script.
It is packaged as an **Agent Skill** (`SKILL.md` + `scripts/` + `references/`), so it drops > Make a 3-minute explainer video about why the sky is blue.
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. 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 ### One-line install (recommended)
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 Send this to your agent:
so half the video uses the wrong typeface, the first few frames capture a pre-animation
```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. 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: The trade-off: **every animation must be seekable.** Wall-clock animation (`setInterval`,
`requestAnimationFrame` counters, CSS `transition` entrances) cannot work and is rejected by
1. sets `window.__MG_RENDER__` so the frame knows not to autoplay, `lint_frames.py`.
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.
--- ---
@@ -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.** | | **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, not character-count interpolation. In Chinese, two same-length phrases can differ 3× in duration, so interpolation drifts badly. | | **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 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. | | **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** | 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. | | **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 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. | | **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 linting** | `lint_frames.py` audits all eight frame-contract rules before you spend 15 minutes rendering. | | **Pre-render audit + QC** | `lint_frames.py` reports contract violations before you spend render time; `qc_check.py` checks loudness, drift and samples frames. |
| **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. | | **Structurally offline** | GSAP vendored; browser auto-detected; ffmpeg falls back to the `imageio-ffmpeg` static binary; web fonts are banned by contract. |
| **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.
--- ---
@@ -107,339 +182,110 @@ determinism, and it is enforced rather than merely advised.
```mermaid ```mermaid
flowchart LR flowchart LR
A["Topic"] --> B["Research<br/><i>facts with sources</i>"] A["Topic"] --> B["Research<br/><i>every figure sourced</i>"]
B --> C["narration.json<br/><i>text, | splits subtitle blocks</i>"] B --> C["narration.json<br/><i>pipes split subtitle blocks</i>"]
C --> D["tts_build.py<br/><i>edge-tts → MP3 + word boundaries</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>"] 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>"] 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>"] F --> G["frames/*.html<br/><i>one scene per line, styled from the library</i>"]
G --> H{"lint_frames.py<br/>8 contract rules"} G --> H{"lint_frames.py<br/>eight contract rules"}
H -->|fail| G 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>"] I --> J["ffmpeg<br/><i>H.264 + AAC mux</i>"]
J --> K[("out/slug.mp4")] J --> K[("out/slug.mp4")]
I --> L["qc_check.py<br/><i>loudness · drift · contact sheet</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>"] K --> M["cover_build.mjs<br/><i>cover_169.png, cover_34.png</i>"]
style K fill:#1f6feb,color:#fff style K fill:#1f6feb,color:#fff
style H fill:#8957e5,color:#fff style H fill:#8957e5,color:#fff
style M fill:#238636,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 ## Command reference
### Pipeline | Command | Purpose |
| Command | Does |
|---|---| |---|---|
| `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 | | `new_project.py <dir> <slug> --topic "..."` | Scaffold a project |
| `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 | | `tts_build.py --project .` | Synthesise voiceover (caching, timeouts, backoff retries, silence trim) |
| `python scripts/timeline_build.py --project . [--gap SEC]` | Builds the global timeline and stitches `narration-full.mp3` | | `timeline_build.py --project .` | Global timeline + stitched `narration-full.mp3` |
| `python scripts/subs.py --project . [--fps N]` | Emits `subs.json`, `srt`/`vtt` sidecars, and one `beats.js` per scene | | `subs.py --project .` | Three subtitle outputs + 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 | | `lint_frames.py --project .` | Audit frames 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 | | `render_video.mjs <dir>` | Render and mux the MP4 |
| `python scripts/qc_check.py --project . [--samples N]` | Loudness / duration / stream audit plus a contact sheet | | `qc_check.py --project .` | Loudness / duration / stream checks + 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 | | `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 | 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
| `node scripts/peek_frame.mjs <dir> <id> [--at 40,80]` | Screenshots one scene at percentage points — seconds, not minutes. `--all` for every scene | word), `package_skill.py` (build a portable zip).
| `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) | **Full flag reference is in [`SKILL.md`](SKILL.md), or pass `--help` to any script.**
| `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) |
--- ---
## The `B()` beat system ## The `B()` beat system
This is the part that changes how the work feels. `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:
A generated `beats.js` per scene exposes every subtitle block by its text:
```js ```js
B('右边卡片') // the second the phrase "右边卡片" starts being spoken tl.fromTo('.card', { y: 60, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.7 }, B('right-hand card'));
Be('证据') // the second that phrase finishes 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 This is this project's own design, and it is the part that changes how the work feels: edit the
var tl = gsap.timeline({ paused: true }); narration once, re-run three commands, and every scene re-times itself. Compare with hardcoded
window.__tl = tl; 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('右边卡片')); **Fallbacks are forbidden.** `B('x') || 3.2` hides "beat not found" behind a stale number. A miss
tl.fromTo('.verdict',{ scale: 0.8 }, { scale: 1, duration: 0.6, ease: 'back.out(2)' }, B('结论句') + 0.2); should fail the build — a mis-timed video is far worse than a failed build.
```
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.
--- ---
## Visual style library ## Visual style library
**Do not design scenes from a blank page.** 23 styles across 8 categories, each documented **Do not design scenes from a blank page.** 23 styles across 8 categories, each recording its
with its canvas, type scale, timeline structure, and colour discipline canvas, type scale, timeline structure and colour discipline — full catalogue in
([`references/style-catalog.md`](references/style-catalog.md)). [`references/style-catalog.md`](references/style-catalog.md) (Chinese).
| Category | Styles | 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
| Presentation / title cards | Bold Poster, Bold Signal, Build Minimal, Creative Voltage, Electric Studio, Glitch Title, Kinetic Type, Swiss Grid, Warm Grain | social formats, product promos and more.
| 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 |
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 - **`rich` (12)** — single file, pure CSS `@keyframes`. The seek renderer drives them **with no
these **with zero code changes**; adapting one is three steps: swap the web font for a changes**; adaptation is three steps: swap web fonts for a system stack, lift bottom elements out
system stack, lift bottom elements out of the subtitle zone, fill in real content. of the subtitle band, drop in real content.
- **`gsap` (11)** — multi-composition, CDN-loaded. **Do not port the code.** A multi-scene - **`gsap` (11)** — multiple compositions loaded from a CDN. **Do not port the code.** Take only
composition has no playback hook inside a single seekable frame, so importing it yields a the visual DNA and re-express it in CSS keyframes.
static first frame and no error whatsoever. Take the visual DNA and re-express it as CSS
keyframes.
Rotate two to four styles across a video. Eight scenes sharing one template looks stale; Rotate 2–4 styles per project. Eight scenes sharing one look reads as monotonous.
mixing an aggressive opener, a spare mid-section, and a data-driven conclusion does not.
--- ---
## Dual-cover system ## Dual covers
A finished video produces **two covers that are siblings, not a crop pair**. 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
Cropping 3:4 out of 16:9 yields 810px from 1920px — discarding **57.8% of the frame width**. share one visual DNA, but each is **laid out again from scratch**:
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:
| Element | 16:9 (`1920×1080`) | 3:4 (`1440×1080`) | | Element | 16:9 (`1920×1080`) | 3:4 (`1440×1080`) |
|---|---|---| |---|---|---|
| Paradox visual | Right side, side-by-side | Top, vertically stacked | | Paradox visual | Right side, side by side | Top, stacked vertically |
| Hook | Bottom-left, 2 lines, ≥96px | Bottom, 3 lines, ≥120px | | Hook | Bottom-left, two lines, ≥96px | Bottom, three lines, ≥120px |
| Corner label | Top-left | Top-centre or dropped |
| Max characters per line | 7 | 5 | | Max characters per line | 7 | 5 |
Three elements are mandatory, and a missing one means rework: a large hook with genuine `new_project.py` generates the templates for both sizes directly; output is 2× by default. Full
tension, a central visual that *argues a contradiction* (two figures moving opposite ways, rules and the pre-upload checklist: [`references/cover-guide.md`](references/cover-guide.md)
a split line, light against dark — not a decorative shape), and margin to spare (Chinese).
(≥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.
--- ---
@@ -447,99 +293,90 @@ your target duration, rewrite sentences — do not distort the speech rate.
| Symptom | Cause | Fix | | 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 | | 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 |
| 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 comes out 1280×720 | `viewport` was passed to `browser.launch()` — it is a *context*-level option | Pass it to `newPage()` |
| Video is 1280×720 instead of 1920×1080 | `viewport` passed to `browser.launch()` — it is a *context* option | Pass it to `newPage()`. Lessons #18 | | Cover renders at 1× | Same trap, `deviceScaleFactor` twin | Pass to `newPage()` + `screenshot({ scale: 'device' })` |
| 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 | | 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 — ffmpeg on Windows reads UTF-8 as ANSI | Keep project, output, and frame paths ASCII. Lessons #11 | | `No such file or directory` on Windows | Non-ASCII path — Windows ffmpeg reads UTF-8 as ANSI | Keep paths ASCII |
| Node can't find `/tmp/...` | Git Bash's `/tmp` ≠ Node's `/tmp` on Windows | Pass `C:/...` forward-slash absolute paths. Lessons #12 | | Preview finished, frame PNGs are gone | Preview mode clears the frame directory by design | Pass `--keep-frames` |
| 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 |
**`references/lessons.md` is the most valuable file here.** 34 numbered entries, each **`references/lessons.md` is the most valuable file in this repository.** 34 numbered entries, each
recording a bug that produced a plausible-looking video while being wrong — including how one a bug where "the video looked fine but was wrong" — including how it was misdiagnosed at first.
it was misdiagnosed first. Read it before debugging from scratch. Read it before debugging from scratch.
--- ---
## Who this is for ## 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. **Poor fit:** live-action editing, talking-head footage, re-creating an existing video; wanting a
- You already work with an AI coding agent and would rather express scenes as HTML than drag-and-drop editor (scenes are code, deliberately); wanting React component animation as the
learn a motion-graphics suite. authoring model — that is the domain of the sibling project
- You need reproducible output — re-render the same project and get the same frames. [`anything2explainer`](https://github.com/Vincentwei1021/anything2explainer).
- 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** ---
- Real-footage editing, talking-head video, or recreating an existing video. ## Prior work and sibling projects
- React component animation as the authoring model (that is the job of the Remotion-based
`anything2explainer`, this project's sibling). `html-explainer` did not invent its methodology. It has two clear sources of ideas. Both are
- A graphical drag-and-drop editor. Scenes are code, deliberately. **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 ## Roadmap
- [ ] Per-scene render switch (`--only <id>`) — today this needs the temporary-project workaround - [ ] 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 - [ ] Cover layout measurement tool (line count / margins / overlap / hook font size in one pass)
- [ ] Additional cover aspect ratios via the spec table in `cover_build.mjs` - [ ] More cover aspect ratios
- [ ] English-first style notes in the catalog (currently Chinese descriptions throughout) - [ ] English descriptions in the style catalogue
- [ ] Optional background-music bed with automatic ducking under narration - [ ] Optional 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)
--- ---
## Contributing ## Contributing
Issues and PRs are welcome. The most valuable contribution is a **diagnosed silent Issues and PRs are welcome. The most valuable contribution is a **diagnosed silent failure** added
failure** added to `references/lessons.md` — the symptom as it first misled you, the to `references/lessons.md` — write down how it first misled you, the evidence that proved the cause,
evidence that proved the cause, the fix, and the resulting rule. the fix, and the general rule that follows. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) first
(Chinese).
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.
--- ---
## Acknowledgements ## Licence and 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
[MIT](LICENSE) © 2026 Moh [MIT](LICENSE) © 2026 Moh
The MIT licence covers the original code and documentation. Third-party components and the MIT covers the original code and documentation. Bundled third-party components and the derived
derived style specification remain under their own terms — see style specifications remain under their own terms; GSAP is bundled under GreenSock's
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). [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** **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> </div>
+212 -376
View File
@@ -2,17 +2,18 @@
# html-explainer # html-explainer
**把一个主题做成有配音、有硬字幕、有封面的讲解视频 —— 全本地、用 HTML 写画面,音画同步稳得住。** **一个 Agent Skill:给任意主题,产出一条带配音、硬字幕、封面的讲解视频。**
面向 AI 编程智能体的确定性 HTML → MP4 渲染流水线。 HTML 写画面 → 确定性逐帧渲染 → 真 MP4。全本地跑,核心链路零 API key、零按次计费。
不需要 Remotion,不需要云端渲染队列,没有按次计费,核心链路不需要任何 API key。
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![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) [![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) [![Agent Skill](https://img.shields.io/badge/Agent%20Skill-SKILL.md-8A2BE2.svg)](SKILL.md)
[![Node](https://img.shields.io/badge/Node-18%2B-339933.svg?logo=node.js&logoColor=white)](https://nodejs.org) [![Claude Code](https://img.shields.io/badge/Claude%20Code-%E2%9C%93-D97757.svg)](#安装)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-6e6e6e.svg)](#环境要求) [![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#安装)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) [![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) [简体中文](README.md) · [English](README.en.md)
@@ -20,43 +21,138 @@
--- ---
## 这是什么 ## 它是什么
`html-explainer` 用来生产科普 / 讲解 / 知识类视频 —— 就是那种带配音、带硬字幕、带进度条, `html-explainer` 把一个主题做成**带配音、带硬字幕、带进度条**的讲解视频,画面用
画面用 HTML/CSS/GSAP 做的动效视频。 HTML/CSS/GSAP 写。
你写一份解说词。智能体(或者你自己)从 23 种画面风格里挑一个,为每个节拍写一个自包含的 它**是一个 Agent Skill**(`SKILL.md` + `scripts/` + `references/`),遵循
HTML 文件,然后这条流水线把它渲染成真正的 MP4:字幕逐字对齐,外加两张可直接上传的封面。 [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/`),可以直接放进 > 把「为什么天空是蓝色的」做成一条 3 分钟的讲解视频。
Claude Code、WorkBuddy、Cursor,或任何能读技能文件的智能体 —— 但脚本本身只是普通的
Python 和 Node,手工跑完全没问题。 技能会带着智能体走完:调研 → 解说词 → 配音 → 字幕与节拍 → 挑风格写画面 → 渲染 → 体检 → 封面。
--- ---
## 核心思路:确定性 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__`,让画面知道自己不该自动起播; 它会自己 clone 到对应目录、跑环境自检、把缺的东西装上。装完**新开一个会话**,让智能体重新
2. 用 `tl.pause(t, false)` 把 GSAP 时间轴定位到那一瞬间; 扫描技能目录。
3. 用 `document.getAnimations().currentTime = t * 1000` 同步所有 CSS 动画;
4. 等两个动画帧后截图。
渲染出来的第 1204 帧,和下一次渲染的第 1204 帧,逐像素一致。正是这个性质让后续的一切成为 ### 手动安装
可能 —— QC 可以定点抽查某一帧、单个场景可以独立重渲,而一整类时序 bug 从根本上无法发生。
> 第二个参数不是可选项。`pause(atTime, suppressEvents)` 的 `suppressEvents` 默认为 `true`, 仓库根目录**就是**技能目录(`SKILL.md` 在根),直接 clone 进技能目录即可,不用再拷子目录:
> 它会**静默丢弃**这次 seek 触发的所有回调 —— 用 `onUpdate` 写的数字滚动会永远停在初值,
> 且零报错。这个坑花掉了真实的调试时间,见 [`references/lessons.md`](references/lessons.md) #27。 ```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('块文本')`),绝不硬编码帧号。改解说词,全片自动重排时间 —— **画面代码一行都不用改。** | | **节拍锚定解说词** | 画面按**匹配字幕文本**定位动画(`B('块文本')`),绝不硬编码帧号。改解说词,全片自动重排时间,画面代码零改动。 |
| **词边界字幕** | 时序来自 edge-tts 的 `WordBoundary` 事件,不按字数插值。中文里两个同字数的短语时长可以差 3 倍,插值会明显错位。 | | **词边界字幕** | 时序来自 edge-tts 的 `WordBoundary` 事件,不按字数插值 —— 中文里两个同字数的短语时长可以差 3 倍。 |
| **两级时钟** | 帧长用 MP3 **容器时长**(保证音画同步);末块字幕按**语音真实结束**收尾。两者混用会让每场景字幕多停约 0.8 秒。 | | **两级时钟** | 帧长用 MP3 **容器时长**(保音画同步);末块字幕按**语音真实结束**收尾。 |
| **23 种画面风格** | 8 个类别 —— 大胆信号卡、奢华极简、NYT 编辑级数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、品牌收尾等,每种都记录了画布、字阶、时间轴与配色纪律。 | | **23 种画面风格** | 8 个类别,每种都记录了画布、字阶、时间轴与配色纪律。不用对着空白页从零设计。 |
| **封面双方案** | 每条成片都附带 16:9 信息流封面与**独立排版**的 3:4 主页封面 —— 不是裁切,裁切会丢掉 57.8% 的画面宽度。 | | **封面双方案** | 每条成片附带 16:9 与**独立重排**的 3:4 封面 —— 不是裁切,裁切会丢掉 57.8% 的画面宽度。 |
| **渲染前静态体检** | `lint_frames.py` 在你花 15 分钟渲染之前,先把八条画面契约的违规逐条报出来。 | | **渲染前体检 + QC** | `lint_frames.py` 在渲染前报出契约违规;`qc_check.py` 查响度、时长漂移、抽帧速览。 |
| **自适应 QC** | 响度、时长漂移、流完整性、抽帧速览图 —— 抽帧体积阈值取本片自身中位数推导,暗色主题不会产生假阳性。 | | **结构性离线** | GSAP 内置;浏览器自动探测;ffmpeg 缺失时回退到 `imageio-ffmpeg` 静态二进制;网络字体被契约禁止。 |
| **结构性离线** | 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 直接拒绝。
这个约束换来了确定性 —— 而且它是被强制执行的,不只是写在文档里的建议。
--- ---
@@ -119,309 +193,84 @@ flowchart LR
style M fill:#238636,color:#fff 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 | | `new_project.py <dir> <slug> --topic "主题"` | 建项目脚手架 |
| `python scripts/tts_build.py --project . [--voice V] [--rate +8%] [--no-trim] [--no-cache]` | 合成配音;带缓存、超时、退避重试、裁静音;写两种时长口径 | | `tts_build.py --project .` | 合成配音(带缓存、超时、退避重试、裁静音) |
| `python scripts/timeline_build.py --project . [--gap SEC]` | 生成全局时间轴并拼接 `narration-full.mp3` | | `timeline_build.py --project .` | 全局时间轴 + 拼接 `narration-full.mp3` |
| `python scripts/subs.py --project . [--fps N]` | 产出 `subs.json`、`srt`/`vtt` 外挂字幕,以及每场景一份 `beats.js` | | `subs.py --project .` | 字幕三出口 + 每场景 `beats.js` |
| `python scripts/lint_frames.py --project . [--only id,id] [--include-covers]` | 按八条契约静态体检所有画面帧 | | `lint_frames.py --project .` | 按八条契约静态体检画面帧 |
| `node scripts/render_video.mjs <dir> [--out PATH] [--preview SEC] [--fps N] [--concurrency N] [--jpeg] [--scale N] [--keep-frames] [--browser PATH]` | 渲染并合成 MP4 | | `render_video.mjs <dir>` | 渲染并合成 MP4 |
| `python scripts/qc_check.py --project . [--samples N]` | 响度 / 时长 / 流体检 + 抽帧速览图 | | `qc_check.py --project .` | 响度 / 时长 / 流体检 + 抽帧速览图 |
| `node scripts/cover_build.mjs <dir> [--at last\|SEC] [--only 169\|34] [--scale N] [--jpg] [--keep-frames]` | 渲两张封面,默认 2 倍图 | | `cover_build.mjs <dir>` | 渲染封面双方案,默认 2 倍图 |
### 辅助工具 常用可选参数:`--preview N` 只渲前 N 秒样片、`--keep-frames` 保留帧 PNG、
`--only id,id` 只体检指定场景、`--scale N` 输出倍率。
| 命令 | 作用 | 另有几个顺手的小工具:`peek_frame.mjs <dir> <id>`(单场景秒级截图,改画面时最常用)、
|---|---| `make_theme.py --topic "医疗" --use`(按主题词推配色)、`package_skill.py`(打成可移植 zip)。
| `node scripts/peek_frame.mjs <dir> <id> [--at 40,80]` | 给单个场景在若干百分比时点截图 —— 是「秒级」而不是「分钟级」。`--all` 可覆盖全部场景 |
| `python scripts/make_theme.py --topic "医疗" --use` | 按主题词推导配色,或用 `--preset violet\|cyan\|amber\|mono` | **完整参数表见 [`SKILL.md`](SKILL.md),或对任一脚本加 `--help`。**
| `python scripts/check_integrity.py` | 版本号 / 风格目录 / 计数一致性 + 外链扫描(CI 也会跑) |
| `bash setup_env.sh --install` | 环境自检与依赖安装 |
| `python scripts/package_skill.py [--with-deps]` | 打成可移植 zip(不加 `--with-deps` 约 110KB) |
--- ---
## `B()` 节拍系统 ## `B()` 节拍系统
这是让整个工作方式变掉的那一块。 `subs.py` 为每个场景生成一份 `beats.js`,把每块字幕按**原文**暴露出来,于是画面动画可以直接
锚在吐字时刻上:
`subs.py` 为每个场景生成一份 `beats.js`,把每块字幕按**原文**暴露出来:
```js ```js
B('右边卡片') // 「右边卡片」这几个字开始被念出的那一秒 tl.fromTo('.card', { y: 60, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.7 }, B('右边卡片'));
Be('证据') // 这句话念完的那一秒 tl.fromTo('.verdict', { scale: 0.8 }, { scale: 1, duration: 0.6 }, B('结论句') + 0.2);
``` ```
于是画面动画可以这样写: `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('右边卡片')); **兜底写法是被禁止的。** `B('x') || 3.2` 会把「节拍查不到」藏在一个过期的数字后面。查不到
tl.fromTo('.verdict',{ scale: 0.8 }, { scale: 1, duration: 0.6, ease: 'back.out(2)' }, B('结论句') + 0.2); 就应该让构建直接失败 —— 一条对错了时间的视频,比一次构建失败糟糕得多。
```
改一次解说词,重跑三条命令。节拍随之平移,所有场景自行重排对时,没有一个画面文件被改动。
对比帧号硬编码的写法:改一句话意味着整片手工重新对位。
**兜底写法是被禁止的。** `B('x') || 3.2` 会把「节拍查不到」这件事藏在一个过期的数字后面。
查不到就应该让构建直接失败 —— 一条对错了时间的视频,比一次构建失败糟糕得多。
--- ---
## 画面风格库 ## 画面风格库
**不要对着空白页从零设计画面。** 23 种风格、8 个类别,每种都记录了画布、字阶、时间轴结构与 **不要对着空白页从零设计画面。** 23 种风格、8 个类别,每种都记录了画布、字阶、时间轴结构与
配色纪律([`references/style-catalog.md`](references/style-catalog.md))。 配色纪律 —— 完整目录见 [`references/style-catalog.md`](references/style-catalog.md)。
| 类别 | 风格 | 涵盖:大胆信号卡 / 奢华极简留白 / NYT 编辑级数据图表 / 瑞士网格 / 故障艺术 / 胶片漏光 /
|---|---| 流体 Hero / 品牌 Logo 收尾 / VFX 文字光标 / 社媒竖版(9:16)/ 产品演示等。
| 演示 / 标题卡 | 大胆海报帧、大胆信号卡帧、奢华极简留白帧、创意电压分屏帧、电光工作室分屏帧、故障艺术标题帧、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 文字光标 |
分成两类,改编成本差别很大: 分成两类,改编成本差别很大:
- **`rich`(12 个)** —— 单文件 + 纯 CSS `@keyframes` 驱动。seek 渲染器**零改动即可驱动**; - **`rich`(12 个)** —— 单文件 + 纯 CSS `@keyframes`。seek 渲染器**零改动即可驱动**;改编
改编只要三步:把网络字体换成系统字体栈、把底部元素抬出字幕带、填进真实内容。 只要三步:换系统字体栈、把底部元素抬出字幕带、填进真实内容。
- **`gsap`(11 个)** —— 多 composition + CDN 加载。**不要搬代码。** 多场景组合在单个可 - **`gsap`(11 个)** —— 多 composition + CDN 加载。**不要搬代码。** 只取它的视觉 DNA,用
seek 的帧里没有起播钩子,整段搬进来只会得到静止首帧,而且零报错。只取它的视觉 DNA, CSS keyframes 重新表达。
用 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`) | | 元素 | 16:9(`1920×1080`) | 3:4(`1440×1080`) |
|---|---|---| |---|---|---|
| 悖论视觉 | 右侧,左右并置 | 上方,竖排堆叠 | | 悖论视觉 | 右侧,左右并置 | 上方,竖排堆叠 |
| 钩子 | 左下,两行,≥96px | 下方,三行,≥120px | | 钩子 | 左下,两行,≥96px | 下方,三行,≥120px |
| 角标 | 左上 | 顶部居中或省略 |
| 每行字数上限 | 7 字 | 5 字 | | 每行字数上限 | 7 字 | 5 字 |
三个要素缺一即返工:一句**真正有张力**的大字钩子、一个**在讲矛盾**的主视觉(两个数字反向 两个尺寸的输出都由 `new_project.py` 直接生成模板,默认出 2 倍图。完整规则与上传前自检清单:
运动、一道分裂线、一明一暗 —— 不是装饰图形)、以及四边都留有余量(≥96px / ≥110px)。 [`references/cover-guide.md`](references/cover-guide.md)。
默认输出 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%,改句子 —— 不要扭曲语速去硬凑。
--- ---
@@ -429,18 +278,12 @@ my-video/
| 症状 | 原因 | 处理 | | 症状 | 原因 | 处理 |
|---|---|---| |---|---|---|
| 每一帧都是静止首帧,且不报错 | 时间轴没注册到 `window.__tl`;或 `page.evaluate` 传了**字符串**而不是函数字面量(Playwright 只把字符串求值一次,返回的是函数对象,函数体根本不执行) | 注册时间轴;传真正的函数字面量。lessons #17 | | 每一帧都是静止首帧,且不报错 | 时间轴没注册到 `window.__tl`;或 `page.evaluate` 传了**字符串**而非函数字面量(Playwright 只求值一次,函数体根本不执行) | 传真正的函数字面量 |
| 数字能渲染,但永远不动 | seek 抑制了 `onUpdate` 回调 | 渲染器已修(`pause(t, false)`)。帧里改用 transform 数字卷轴。lessons #27 | | 视频变成 1280×720 | `viewport` 传给了 `browser.launch()` —— 它是 *context* 级选项 | 传给 `newPage()` |
| 视频变成 1280×720 而不是 1920×1080 | `viewport` 传给了 `browser.launch()` —— 它是 *context* 级选项 | 传给 `newPage()`。lessons #18 | | 封面出成 1 倍图 | 同上,`deviceScaleFactor` 的孪生坑 | 传 `newPage()` + `screenshot({ scale: 'device' })` |
| 封面出成 1 倍图,但日志写着 2 倍 | 同上,`deviceScaleFactor` 的孪生坑 | 传给 `newPage()` + `screenshot({ scale: 'device' })`;用 PIL 核尺寸。lessons #28 | | 数字能渲染但永远不动 | seek 抑制了 `onUpdate` 回调 | 渲染器已修(`pause(t, false)`);帧里改用 transform 数字卷轴 |
| Windows 上 `No such file or directory` | 非 ASCII 路径 —— Windows 的 ffmpeg 把 UTF-8 当 ANSI 读 | 项目、输出、抽帧路径全部保持 ASCII。lessons #11 | | Windows 上 `No such file or directory` | 非 ASCII 路径 —— Windows 的 ffmpeg 把 UTF-8 当 ANSI 读 | 路径保持 ASCII |
| Node 找不到 `/tmp/...` | Git Bash 的 `/tmp` ≠ Node 的 `/tmp` | 传 `C:/...` 正斜杠绝对路径。lessons #12 | | 预览渲完,帧 PNG 不见了 | 预览模式按设计会清空帧目录 | 加 `--keep-frames` |
| 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 |
**`references/lessons.md` 是这个仓库里最值钱的文件。** 34 条编号记录,每一条都是一个 **`references/lessons.md` 是这个仓库里最值钱的文件。** 34 条编号记录,每一条都是一个
「成片看着挺正常、其实是错的」的 bug —— 包括它一开始是怎么被误判的。从零开始 debug 之前, 「成片看着挺正常、其实是错的」的 bug —— 包括它一开始是怎么被误判的。从零开始 debug 之前,
@@ -450,71 +293,64 @@ my-video/
## 适合谁 ## 适合谁
**适合** **适合**:想成规模地把文章 / 文档 / 某个主题做成带解说的视频;已经在用 AI 编程智能体,
且宁愿用 HTML 描述画面也不想学动效软件;需要可复现的输出;需要离线跑或拒绝按次计费。
- 想成规模地把文章、文档或某个主题做成带解说的视频。 **不适合**:实拍剪辑、真人口播、复刻已有视频;想要图形化拖拽编辑器(画面是代码,这是刻意
- 已经在用 AI 编程智能体,并且宁愿用 HTML 描述画面,也不想学一套动效软件。 选择);想把 React 组件动画作为创作模型 —— 那是姊妹项目
- 需要可复现的输出 —— 同一个项目重渲一次,帧应当一致。 [`anything2explainer`](https://github.com/Vincentwei1021/anything2explainer) 的领域。
- 需要离线跑,或者不接受按次计费。
- 想把字幕、音画同步、封面、QC 这些基础工程直接拿走用。
**不适合** ---
- 实拍剪辑、真人口播,或者复刻一条已有的视频。 ## 参考思路与姊妹项目
- 想把「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>`)—— 目前需要临时项目法绕过 - [ ] 单场景渲染开关(`--only <id>`)—— 目前需要临时项目法绕过
- [ ] 封面排版终态测量器(行数 / 四边边距 / 元素重叠 / 钩子字号一次判完)—— 目前需自行写十几行 - [ ] 封面排版终态测量器(行数 / 四边边距 / 元素重叠 / 钩子字号一次判完)
- [ ] 通过 `cover_build.mjs` 里的规格表支持更多封面比例 - [ ] 更多封面比例
- [ ] 风格目录补英文说明(目前描述以中文为主) - [ ] 风格目录补英文说明
- [ ] 可选背景音乐轨,并对解说自动做闪避(ducking) - [ ] 可选背景音乐轨,并对解说自动做闪避(ducking)
- [ ] 「焦点 / 发光纪律」的 lint 规则(目前只能靠肉眼)
- [ ] 常见视频形态的项目模板(60 秒短片、3 分钟讲解)
--- ---
## 参与贡献 ## 参与贡献
欢迎提 issue 和 PR。最有价值的贡献,是一条**已被定位的静默失败**,加进 欢迎提 issue 和 PR。最有价值的贡献,是一条**已被定位的静默失败**,加进
`references/lessons.md` —— 写下它一开始是怎么误导你的、证明病因的证据、修法,以及由此 `references/lessons.md` —— 写下它一开始是怎么误导你的、证明病因的证据、修法,以及由此得出的
得出的通用规则。 通用规则。请先读 [`CONTRIBUTING.md`](CONTRIBUTING.md)。
请先读 [`CONTRIBUTING.md`](CONTRIBUTING.md),里面有本项目的硬性规则、端到端自检方法,
以及 PR 检查清单。
CI 会在 Linux 与 Windows 上、Python 3.9 与 3.13 上检查版本号漂移、风格目录漂移和外链引用。
--- ---
## 致谢 ## 许可与致谢
本项目的解说与音画同步方法论 —— 词边界字幕、两级时钟、语速标定、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](LICENSE) © 2026 Moh
MIT 许可覆盖原创代码与文档。第三方组件与衍生出的风格规范仍适用其各自的条款 —— 见 MIT 覆盖原创代码与文档。第三方组件与衍生出的风格规范仍适用其各自的条款;GSAP 按 GreenSock
的 [standard "no charge" 许可](https://gsap.com/standard-license) 内置。完整声明见
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。 [`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/frame-template.html` | 场景模板(契约注释在文件头,B() 用法示例) |
| `assets/cover-template.html` | **封面模板**(封面三要素注释在文件头,可改尺寸复用为两份) | | `assets/cover-template.html` | **封面模板**(封面三要素注释在文件头,可改尺寸复用为两份) |
| `assets/gsap.min.js` | GSAP 3.13 本地内置(离线渲染;License 见同目录 `gsap-README.md`) | | `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` | 版本变更史 | | `CHANGELOG.md` | 版本变更史 |
| `THIRD_PARTY_NOTICES.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`)。 技能目录自包含,不引用本机任何绝对路径(脚本按自身位置定位 `SKILL_ROOT`)。
仅有两处**兜底探测**会去探 WorkBuddy 托管的运行时目录(`setup_env.sh` 的 Python/Node
候选、`peek_frame.mjs` 的技能根候选)—— 探不到就自动跳过,不影响其它平台。
**零依赖声明(重要)**:本技能**不需要** html-video 或 anything2explainer 存在。 **零依赖声明(重要)**:本技能**不需要** html-video 或 anything2explainer 存在。
两个来源项目的名字只出现在:① 代码注释的出处署名 ② `scripts/import_styles.py` 两个来源项目的名字只出现在:① 代码注释的出处署名 ② `scripts/import_styles.py`
@@ -237,7 +273,7 @@ qc → cover)**完全不碰这两个项目**。风格库是抄成纯文本的
python package_skill.py # → dist/html-explainer-v<版本>.zip python package_skill.py # → dist/html-explainer-v<版本>.zip
python package_skill.py --with-deps # 含 playwright-core,~14MB(目标机全程离线) python package_skill.py --with-deps # 含 playwright-core,~14MB(目标机全程离线)
# ② 新机器:解压到任意目录(推荐 ~/.workbuddy/skills/) # ② 新机器:解压到任意目录(放进上表任一「个人级」技能目录即可被该智能体识别)
bash setup_env.sh --install # 自检 + 装缺项(先装后判定,装成功即算就绪) bash setup_env.sh --install # 自检 + 装缺项(先装后判定,装成功即算就绪)
# ③ 跑一遍端到端(可选,验证链路) # ③ 跑一遍端到端(可选,验证链路)
@@ -249,8 +285,8 @@ python scripts/qc_check.py --project . && node scripts/cover_build.mjs .
``` ```
**依赖探测顺序**(都尽量用系统已有的,避免下载): **依赖探测顺序**(都尽量用系统已有的,避免下载):
- Python:先找托管 venv,再 `python3` / `python` - Python:先找 WorkBuddy 托管 venv,再 `python3` / `python`
- Node:`node -v` ≥18;没有则扫 `~/.workbuddy/binaries/node/versions/*` - Node:`node -v` ≥18;没有则扫 `~/.workbuddy/binaries/node/versions/*`(WorkBuddy 托管路径)
- 浏览器:Chrome → Edge → playwright chromium → `BROWSER_PATH` 环境变量 - 浏览器:Chrome → Edge → playwright chromium → `BROWSER_PATH` 环境变量
- ffmpeg:PATH → `imageio_ffmpeg.get_ffmpeg_exe()`(pip 装依赖时自带静态二进制) - ffmpeg:PATH → `imageio_ffmpeg.get_ffmpeg_exe()`(pip 装依赖时自带静态二进制)
- GSAP:包内 `assets/gsap.min.js`(离线,不外链) - GSAP:包内 `assets/gsap.min.js`(离线,不外链)
@@ -263,6 +299,9 @@ python scripts/qc_check.py --project . && node scripts/cover_build.mjs .
- 本技能**原创代码与文档**:MIT © 2026 **Moh**(见 `LICENSE`)。 - 本技能**原创代码与文档**:MIT © 2026 **Moh**(见 `LICENSE`)。
- **画面风格目录**(`references/style-catalog.md` / `.json`)由 [nexu-io/html-video](https://github.com/nexu-io/html-video) - **画面风格目录**(`references/style-catalog.md` / `.json`)由 [nexu-io/html-video](https://github.com/nexu-io/html-video)
(Apache-2.0)的模板设计规范转写而来,已保留署名;转写文本按 Apache-2.0 分发。 (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`)、 - **打包内置**:GSAP 3.13(GreenSock 标准「no charge」许可,见 `assets/gsap-README.md`)、
playwright-core(Apache-2.0)。 playwright-core(Apache-2.0)。
- 完整清单与逐条授权见 **`THIRD_PARTY_NOTICES.md`**。发布/再分发前请连同该文件一起带上。 - 完整清单与逐条授权见 **`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 `html-explainer` 以 MIT 许可发布(见 [LICENSE](LICENSE))。该许可覆盖本项目原创的
**original code and documentation** authored for this project. **代码与文档**。
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 > **如果你要再分发本项目(fork、打包 zip、发布为包、镜像,或放进某个产品里),必须把本文件
> product), you must carry this file along with it.** > 一起带上。**
--- ---
## 1. Bundled in this repository ## 1. 随仓库打包的组件
### GSAP 3.13.0 — `assets/gsap.min.js` ### GSAP 3.13.0 — `assets/gsap.min.js`
- **Copyright**: © 2025 GreenSock. All rights reserved. Author: Jack Doyle. - **版权**:© 2025 GreenSock. All rights reserved. 作者 Jack Doyle。
- **License**: GreenSock standard "no charge" license — <https://gsap.com/standard-license> - **许可**:GreenSock standard "no charge" 许可 —— <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, - **说明**:这**不是**一个 OSI 许可。GSAP 在其标准许可条款下可免费使用(含商业用途)。
under its standard license terms. The copyright header inside `assets/gsap.min.js` `assets/gsap.min.js` 里的版权头不得移除。上游 README 一并保留在 `assets/gsap-README.md`。
must not be removed. Upstream README is kept alongside it at
`assets/gsap-README.md`.
--- ---
## 2. Runtime dependencies (installed, not vendored) ## 2. 运行时依赖(安装,而非随包内置)
These are declared as dependencies and fetched by `bash setup_env.sh --install`. 这些在依赖清单中声明,由 `bash setup_env.sh --install` 拉取,**不**提交进本仓库。
They are not committed to this repository.
| 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> | | `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> | | `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> | | `pillow` | 12.3.0 | MIT-CMU | <https://python-pillow.org> |
| `imageio-ffmpeg` | 0.6.0 | BSD-2-Clause | <https://github.com/imageio/imageio-ffmpeg> | | `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 **关于 `edge-tts`(LGPL-3.0)与 `ffmpeg`**:本项目以**独立进程调用 / 作为未修改的库导入**
processes / imported as unmodified libraries**; this project does not statically link 的方式使用它们,不做静态链接,也不分发修改过的副本。如果你打算再分发一个**打包的** ffmpeg
them or ship modified copies. If you plan to redistribute a *bundled* binary build of 二进制构建,请先看 FFmpeg 自己的许可页面 —— 编译选项决定该二进制属于 LGPL 还是 GPL。
ffmpeg, review FFmpeg's own licensing page — build flags determine whether a given
binary is LGPL or GPL.
`edge-tts` is pinned to `7.2.8` intentionally: the 7.x series has had breaking changes `edge-tts` 是**刻意**钉在 `7.2.8` 的:7.x 系列在其语音边界 API 上有过破坏性变更,而本项目
in its speech-boundary API, and this project depends on `WordBoundary` events. 依赖 `WordBoundary` 事件。
--- ---
## 3. Derived content — the style catalog ## 3. 衍生内容 —— 风格目录
`references/style-catalog.md` and `references/style-catalog.json` are a **transcription `references/style-catalog.md` 与 `references/style-catalog.json` 是**对第三方模板设计规范的
of third-party template design specifications** — canvas size, typography, timeline 转写** —— 画布尺寸、字体排印、时间轴结构、配色纪律 —— 覆盖 23 种具名风格。其中**不包含**任何
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.
This catalog was produced by `scripts/import_styles.py`, which reads an installed copy of 该目录由 `scripts/import_styles.py` 产出:它读取一份**已安装的** html-video,把规范转写成纯
html-video and writes the specification out as plain text. **It is a one-time porting 文本。**它是一个移植期的一次性工具。** 跑一条 html-explainer 视频从不读取 html-video,
tool.** Running an html-explainer video never reads html-video, and no html-video code is 任何环节都不会执行或链接 html-video 的代码。
executed or linked at any point.
### 3.1 `nexu-io/html-video` — 23 templates ### 3.1 `nexu-io/html-video` —— 23 个模板
- **Copyright**: the html-video authors (Open Design / nexu-io) - **版权**:html-video 的作者(Open Design / nexu-io)
- **License**: Apache-2.0 - **许可**:Apache-2.0
- **Source**: <https://github.com/nexu-io/html-video> - **来源**:<https://github.com/nexu-io/html-video>
- **What was derived**: design specifications and style identifiers for all 23 templates - **衍生了什么**:全部 23 个模板的设计规范与风格标识符(`frame-bold-poster` …
(`frame-bold-poster` … `vfx-text-cursor`), transcribed into `references/style-catalog.*`. `vfx-text-cursor`),转写进 `references/style-catalog.*`。
- **Changes made**: specifications reformatted into a flat Markdown/JSON catalog; each - **做了哪些改动**:规范重排为扁平的 Markdown / JSON 目录;每个模板分类为 `rich`
template classified as `rich` (single-file CSS `@keyframes`) or `gsap` (multi-composition); (单文件 CSS `@keyframes`)或 `gsap`(多 composition);补充了改编成本与字幕禁区约束的
annotated with adaptation cost and subtitle-safe-zone constraints. Original template 标注。原始模板代码、composition 与素材**没有**被复制。
code, compositions, and assets were **not** copied. - Apache-2.0 要求衍生作品的接收者获得一份许可副本。逐字副本见
- Apache-2.0 requires that recipients of a derivative work receive a copy of the license. [`licenses/Apache-2.0.txt`](licenses/Apache-2.0.txt)。上游仓库不含 `NOTICE` 文件,
A verbatim copy is included at [`licenses/Apache-2.0.txt`](licenses/Apache-2.0.txt). 因此不涉及 `NOTICE` 传递(Apache-2.0 §4(d))。
The upstream repository ships no `NOTICE` file, so no `NOTICE` propagation applies
(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 `references/style-catalog.json` 为每种风格记录了 `via` 字段,保留了 html-video 为其模板附带的
provenance that html-video attaches to each of its templates. Seven of the 23 styles 来源信息。23 种风格中有 7 种可追溯到更早的 MIT 许可项目。由于本项目的目录描述了同样的风格,
trace back to an earlier MIT-licensed project. Those notices are reproduced here because 这些声明在此一并复现。
this project's catalog describes the same styles.
Exact mapping, as recorded in the catalog: 按目录中记录的精确对应关系:
#### `frontend-slides` — 4 styles #### `frontend-slides` —— 4 种风格
- **Copyright**: Zara Zhang © 2025 - **版权**:Zara Zhang © 2025
- **License**: MIT - **许可**:MIT
- **Source**: <https://github.com/zarazhangrui/frontend-slides> - **来源**:<https://github.com/zarazhangrui/frontend-slides>
- **Styles**: `frame-bold-poster`, `frame-bold-signal`, `frame-creative-voltage`, - **风格**:`frame-bold-poster`、`frame-bold-signal`、`frame-creative-voltage`、
`frame-electric-studio` `frame-electric-studio`
#### `huashu-design` — 3 styles #### `huashu-design` —— 3 种风格
- **Copyright**: alchaincyf (花叔 · 花生) © 2026 - **版权**:alchaincyf(花叔 · 花生)© 2026
- **License**: MIT - **许可**:MIT
- **Source**: <https://github.com/alchaincyf/huashu-design> - **来源**:<https://github.com/alchaincyf/huashu-design>
- **Styles**: `frame-build-minimal`, `frame-pentagram-stat`, `frame-takram-organic` - **风格**:`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 - **版权**:Nate Herk
- **License**: MIT (as recorded in the catalog's `license` field for this entry) - **许可**:MIT(按目录中该条目的 `license` 字段记录)
- **Styles**: `frame-product-promo-30s` — recorded upstream as a fork of - **风格**:`frame-product-promo-30s` —— 上游记录为 `linear-promo-30s` 的 fork,
`linear-promo-30s`, with brand-specific copy and assets replaced by generic 品牌相关的文案与素材已替换为通用占位内容。
placeholders.
html-video's own `ATTRIBUTIONS.md` documents the first two upstreams and states its rule html-video 自己的 `ATTRIBUTIONS.md` 记录了前两个上游,并给出了它对此类模板的处理原则:静态
for all such templates: static slide designs were re-expressed as original CSS/SVG 幻灯片设计被重新表达为原创的 CSS/SVG 关键帧时间轴,配以新的示例数据,不做逐字源码复制;部分
keyframe timelines, with new sample data and no verbatim source copying, and the studio 风格上挂着的设计事务所名字(Pentagram、Build、Takram)记录为**仅表灵感来源,绝不表示隶属或
names attached to some styles (Pentagram, Build, Takram) are recorded as **inspiration 背书**。本项目沿用这一立场。
only, never as affiliation or endorsement**. This project inherits that position.
MIT License notice for all three upstreams above: 上述三个上游的 MIT 许可声明:
``` ```
Permission is hereby granted, free of charge, to any person obtaining a copy 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. 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 部分风格名引用了真实的设计事务所或出版物,作为风格灵感来源的记录(Pentagram、Build、
inspiration (Pentagram, Build, Takram, NYT, Vignelli, Swiss Grid). These are **factual Takram、NYT、Vignelli、Swiss Grid)。这些**仅为事实性的灵感说明**。本项目 —— 以及它之前的
statements of inspiration only**. This project — and html-video before it — is **not html-video —— **与其中任何一方均无隶属、背书或赞助关系**。项目中不含任何第三方的 logo、
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.
--- ---
## 4. Design lineage of the workflow ## 4. 方法论上的思路来源
The narration/audio-sync methodology (word-boundary subtitles, two-level clock, speech-rate 解说与音画同步方法论(词边界字幕、两级时钟、语速标定、QC 判据)参考了下列项目,它们
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 - **[Vincentwei1021/anything2explainer](https://github.com/Vincentwei1021/anything2explainer)**
and historical notes. —— 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 | | `LICENSE` | 原创代码的 MIT 条款 |
| `THIRD_PARTY_NOTICES.md` (this file) | Apache-2.0 §4 + MIT notice retention | | `THIRD_PARTY_NOTICES.md`(本文件) | Apache-2.0 §4 + MIT 通知保留要求 |
| `licenses/Apache-2.0.txt` | Apache-2.0 §4(a) requires a copy of the license | | `licenses/Apache-2.0.txt` | Apache-2.0 §4(a) 要求附带一份许可副本 |
| `assets/gsap-README.md` | GSAP license reference; keep the header in `gsap.min.js` intact | | `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)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
// 本脚本放在**项目**的 script/ 下(不是技能目录里),所以不能靠相对路径找技能根。 // 本脚本放在**项目**的 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() { function resolveSkillRoot() {
const cands = []; const cands = [];
if (process.env.HTML_EXPLAINER_ROOT) cands.push(process.env.HTML_EXPLAINER_ROOT); if (process.env.HTML_EXPLAINER_ROOT) cands.push(process.env.HTML_EXPLAINER_ROOT);
@@ -35,8 +44,12 @@ function resolveSkillRoot() {
if (p === d) break; if (p === d) break;
d = p; d = p;
} }
if (process.env.USERPROFILE) cands.push(path.join(process.env.USERPROFILE, '.workbuddy', 'skills', 'html-explainer')); const homes = [process.env.USERPROFILE, process.env.HOME].filter(Boolean);
if (process.env.HOME) cands.push(path.join(process.env.HOME, '.workbuddy', 'skills', 'html-explainer')); for (const home of homes) {
for (const seg of SKILL_SUBDIRS) {
cands.push(path.join(home, ...seg, 'html-explainer'));
}
}
for (const c of cands) { for (const c of cands) {
try { try {
if (fs.existsSync(path.join(c, 'node', 'node_modules', 'playwright-core', 'package.json'))) return c; 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 环境自检 ==" echo "== html-explainer 环境自检 =="
# ---------- Python ---------- # ---------- Python ----------
# 候选顺序:WorkBuddy 托管 venv(本机既有依赖,优先复用)→ 系统 python3 / python。
# 前两个是 WorkBuddy 专属路径,其它平台探不到就自动跳过,不影响使用。
PY="" PY=""
for c in "$HOME/.workbuddy/binaries/python/envs/default/Scripts/python.exe" \ for c in "$HOME/.workbuddy/binaries/python/envs/default/Scripts/python.exe" \
"$HOME/.workbuddy/binaries/python/envs/default/bin/python" \ "$HOME/.workbuddy/binaries/python/envs/default/bin/python" \
@@ -63,6 +65,7 @@ if command -v node >/dev/null 2>&1; then
NV="$(node_major)" NV="$(node_major)"
if [ "${NV:-0}" -ge 18 ]; then ok "Node: $(node -v)"; else miss "Node ≥18 需要,当前 $(node -v)"; fi if [ "${NV:-0}" -ge 18 ]; then ok "Node: $(node -v)"; else miss "Node ≥18 需要,当前 $(node -v)"; fi
else else
# 兜底:WorkBuddy 托管的 Node(非标准安装,路径带版本号)。仅本机可用,其它平台跳过。
for d in "$HOME/.workbuddy/binaries/node/versions"/*/; do for d in "$HOME/.workbuddy/binaries/node/versions"/*/; do
if [ -x "$d/node.exe" ] || [ -x "$d/node" ]; then if [ -x "$d/node.exe" ] || [ -x "$d/node" ]; then
export PATH="$d:$PATH"; ok "Node(托管): $(node -v)"; break export PATH="$d:$PATH"; ok "Node(托管): $(node -v)"; break