# 设计与实现

> `README.md` 的深水区：这里解释**为什么这样做**，以及那些不写下来就会被反复踩的约束。
>
> - 想知道怎么用 → [README.md](README.md)
> - 想看命令与确认点 → [SKILL.md](SKILL.md)
> - 想看踩过的坑（133 条） → [references/lessons.md](references/lessons.md)

**目录**

1. [渲染原理：为什么不用录屏](#渲染原理为什么不用录屏)
2. [`B()` 节拍系统](#b-节拍系统)
3. [画面风格库](#画面风格库)
4. [动效库](#动效库)
5. [模板编排](#模板编排)
6. [封面多画幅](#封面多画幅)
7. [渲染性能实测](#渲染性能实测)
8. [配音细节](#配音细节)
9. [思想来源](#思想来源)

---

## 渲染原理：为什么不用录屏

多数「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` 直接拒绝。这也正是动效库被设计成
「`t` → 一组数字」的纯函数的原因：它天生可 seek。

---

## `B()` 节拍系统

`subs.py` 为每个场景生成一份 `beats.js`，把每块字幕按**原文**暴露出来，于是画面动画可以直接
锚在吐字时刻上：

```js
tl.fromTo('.card',    { y: 60, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 0.7 }, B('右边卡片'));
tl.fromTo('.verdict', { scale: 0.8 },          { scale: 1, duration: 0.6 },        B('结论句') + 0.2);
```

`B('块文本')` 是「这几个字开始被念出」的时刻，`Be('块文本')` 是念完的时刻。

这是本项目自己的设计，也是让工作方式变掉的那一块：改一次解说词，重跑三条命令，所有场景自行
重排对时。对比帧号硬编码 —— 改一句话就是整片手工重新对位。

**兜底写法是被禁止的。** `B('x') || 3.2` 会把「节拍查不到」藏在一个过期的数字后面。查不到
就应该让构建直接失败 —— 一条对错了时间的视频，比一次构建失败糟糕得多。

---

## 画面风格库

**不要对着空白页从零设计画面。** 23 种风格、8 个类别，每种都记录了画布、字阶、时间轴结构与
配色纪律 —— 完整目录见 [`references/style-catalog.md`](references/style-catalog.md)。

涵盖：大胆信号卡 / 奢华极简留白 / NYT 编辑级数据图表 / 瑞士网格 / 故障艺术 / 胶片漏光 /
流体 Hero / 品牌 Logo 收尾 / VFX 文字光标 / 社媒竖版（9:16）/ 产品演示等。

分成两类，改编成本差别很大：

- **`rich`（12 个）** —— 单文件 + 纯 CSS `@keyframes`。seek 渲染器**零改动即可驱动**；改编
  只要三步：换系统字体栈、把底部元素抬出字幕带、填进真实内容。
- **`gsap`（11 个）** —— 多 composition + CDN 加载。**不要搬代码。** 只取它的视觉 DNA，用
  CSS keyframes 重新表达。

一个项目建议轮换 2–4 种风格。8 个场景共用同一张脸会显得单调 —— 编排器会自动把这件事做掉（见下）。

---

## 动效库

`assets/motion.js`（浏览器里挂到 `window.HXM`）把 23 个模板里反复出现的动作抽象成
**40+ 动作词汇**，分 5 组：

| 组 | 动作（节选） |
|---|---|
| `enter` 到场 | `riseWord` `dropLetters` `springIn` `blurAway` `riseFromMask` `typeChars` `checkOff` `flyPlane` |
| `carry` 承接 | `morphBox` `irisOpen` `diveInto` `arcHop` `gatherTo` `railShift` `sealDisc` `burstWord` |
| `contact` 接触 | `landHit` `splitOnHit` `tapPress` `pointer` `stretch2` `sim.*`（磁吸 / 跟随 / 软体 / 绳索） |
| `camera` 运镜 | `camTrack` `layerMatrix` `depthBlur` `whipPan` `camShake` `slowPush` `gridDots` + 坐标互转 |
| `ambience` 环境 | `swiftSpring` `glowField` `floodRings` `noiseField`（5 套色带） `beltLoop` |

每个动作都是 **`t`（绝对秒）→ 一组数字**：无状态、不碰 DOM、不碰 canvas，所以能被**逐帧 seek**
—— 同一个 `t` 永远给出同一组数。所有随机数走 `seededRng`，渲染路径上**不出现 `Math.random()`**，
逐帧渲染与实时预览结果一致。`tests/test_motion.mjs` 有 148 条断言。

写画面时不再是从零调缓动曲线，而是从这套词汇里挑选、组合、调参 —— 一个场景通常由
「入场 + 承接 + 一点运镜 + 环境光」几层叠出来。完整词汇表见
[`references/motion-library.md`](references/motion-library.md)。

---

## 模板编排

`scripts/style_director.py` **读解说词本身**，先推断每场在片子里扮演什么角色
（开场 / 陈述 / 数据 / 原理 / 例证 / 反差 / 收束 / 落版），再按角色 × 子类别 × 时长 × 内容词 ×
受众 × 节奏打分挑**主风格**，按能量预算给部分场搭**次风格**做局部元素替换，并决定**开场变体**
（由主题哈希轮换）与**场间转场**（由相邻两场能量差决定）：

```bash
# 先 dry-run 看结果，满意再去掉 --dry-run 落盘
python scripts/style_director.py --project . --dry-run
python scripts/style_director.py --project . --mood calm --pace 慢 --audience 大众 --pin hook=bold-signal
```

产物 `style-plan.json` 每场带 `role / primary / motion_intensity / transition_out / why` ——
「这场为什么被挑中」是可查的。多样性约束（风格数上限 / 连续同风格上限 / 开场 ≠ 第二场）与
`--pin` 硬约束，保证它不会又退化成「一片一模板」。`--seed` 让同一题材每次换一批画面，
`--band 0` 则完全确定（供回归）。完整规则见 [`references/style-director.md`](references/style-director.md)。

**判据**：成片能被认出「这是 bold-signal 模板」= 吸纳过头；只能说「颜色像 xx、节奏像 yy」= 深度对了。
核心是让模板服务于主题，而不是让主题被模板限制。

---

## 封面多画幅

每条成片产出**互为姊妹、而非裁切关系**的封面。从 16:9 居中裁 3:4，1920px 只剩 810px ——
丢掉 57.8% 的画面宽度，任何横跨全宽的标题都会被切掉一半。所以各画幅共享同一套视觉基因，
但各自**重新排一次版**：

| 元素 | 16:9（`1920×1080`） | 3:4（`1440×1080`） | 9:16（`1080×1920`） |
|---|---|---|---|
| 用途 | 信息流 / 播放页 | 主页栅格 | 竖版全屏 / 小红书 / 视频号竖版 |
| 构型 | 左右并置 | 上方竖排堆叠 | 三段式，重心偏上 |
| 钩子 | 左下，两行，≥96px | 下方，三行，≥120px | 中下，三行，≥130px |
| 每行字数上限 | 8 字 | 8 字 | 6 字 |
| 余量 | 四边 ≥96px | 四边 ≥110px | 上 ≥180px · 下 ≥160px · 左右 ≥90px |

**默认出 16:9 + 3:4 两张；竖版投放再加 9:16** —— 加一张 `frames/cover_916.html` 即可，
渲染器**缺哪张就跳哪张**。9:16 的上下边距是**「不可裁区」而不是「安全边距」**：顶部是账号与
时长层、底部是标题栏与互动按钮，压上去等于自杀。

模板由 `new_project.py` 生成，默认出 2 倍图。出图后跑一次**终态几何实测** —— 肉眼只能看出
明显问题，差 20px 的贴边、多出一个字的孤行全靠它抓：

```bash
node scripts/cover_build.mjs .          # 渲染 → out/cover_{169,34,916}.png
node scripts/check_cover.mjs .          # 边距 / 钩子字号 / 行宽 / 孤字 / 9:16 禁两栏
```

完整规则与上传前自检清单：[`references/cover-guide.md`](references/cover-guide.md)。

---

## 渲染性能实测

一条 **3 分 20 秒 · 1080p30 · 5982 帧**的真实片子（本机 Windows 10 · 16 逻辑核 · Chrome ·
6 个浏览器进程 · 快门 180°/8 样本，只开在 3 场 = 35.3% 的帧上）：

| 阶段 | `--png-fast`（默认） | `--jpeg --jpeg-quality 95` |
|---|---|---|
| 截图（5982 帧 · 11995 次采样） | 365.4 s | 365.8 s |
| 快门积分 | 67.1 s | 63.0 s |
| 编码 + 收尾 | 36.8 s | 31.9 s |
| **端到端** | **469.2 s ≈ 7 分 49 秒** | 460.7 s ≈ 7 分 41 秒 |
| 成片体积 | **10.81 MB** | 11.02 MB |

派生指标：全片平均截图吞吐 **16.4 帧/秒**、平均单次截图 **30.5 ms**（含换场与预载开销）；
单场景热身后的峰值吞吐 **26.65 帧/秒**。**快门只吃掉 13.7% 的耗时** —— 因为它只落在 35.3% 的帧上。

### 快的四个来源（按杠杆大小排）

1. **`--workers N` 真·多进程** —— 截图是纯 CPU 活，能拉满多核。`--concurrency` 完全无效
   （中间帧编码在浏览器进程内串行，实测并发 1/3/6 路吞吐 1.80 / 1.86 / 1.87 帧/秒）。
2. **换中间帧通道** —— 与并行同级的编码杠杆；`png-fast` 在纯图形帧上等于「同速 + 更小 + 无损」。
3. **`--shutter-only` 白名单** —— 快门是唯一按倍数付费的项。只给需要拖影的场景开，其余场零成本：
   本片 35.3% 的帧开了快门，却只贡献 13.7% 的耗时。
4. **`--profile draft` + `--preview`** —— 迭代期只看节奏：去掉快门、只渲一段，秒级出片。

> 上面是**一条真片子的端到端实测**，比合成微基准可信 —— 微基准的信噪比不足以预测全片收尾开销。
> 绝对值随机器与帧内容变化很大；换机器后请用自己的片子跑两遍同通道来标定基线。

### 中间帧通道的完整口径

| 通道 | 相对速度（纯图形帧 / 满幅照片帧） | 画质 | 体积（1080p 纯图形帧） | 定位 |
|---|---|---|---|---|
| **`--png-fast`** ★**默认** | **×1.02 / ×4.4** | **逐像素无损** | **0.10 MB/帧** | 纯 CSS/MG 图形帧上与 JPEG q95 同速、体积更小 → 默认用它 |
| `--jpeg --jpeg-quality 95` | ×1.00 / **×13** | PSNR 41.65 dB（低于成片自身失真） | 0.12 MB/帧 | **含满幅照片 / 重合成帧、或 4K 终稿**时的首选 |
| `--png` | ×1.16 / ×1.0 | 逐像素无损 | 0.08 MB/帧 | 只有 `legacy` 复现 / `master` 极限档用 |
| `--jpeg --jpeg-quality 82` | 最快 | 略低 | ~0.08 MB/帧 | 只做打样预览 |

> **口径陷阱：「×13 / ×4.4」是满幅照片帧的倍率，不要套到纯图形帧上。** 本技能绝大多数片子是
> 大面积平色 + 锐利文字的**纯 CSS/MG 图形帧**，PNG 的 deflate 对这类帧极其高效 —— 实测
> **JPEG q95 与 PNG-fast 同速**（27.14 vs 26.65 帧/秒，×1.02），而且 PNG-fast **体积更小、还无损**。
> 所以默认推荐是 `--png-fast`；`--jpeg q95` 只在**含照片 / 4K 终稿**时才是首选
> （照片帧上精细 PNG 是 582ms/帧的黑洞）。
>
> 端到端代价：同一条 5982 帧片子，JPEG q95 实测 460.7 s，PNG-fast **实测 469.2 s（+1.85%）** ——
> 逐像素无损几乎没有代价，成片体积还小了 0.21 MB。

完整档位表、基准读法与快门磁盘护栏见 [`references/render-profiles.md`](references/render-profiles.md)。

---

## 配音细节

流水线开始前，智能体会**先问你用哪个 TTS**，再列候选音色让你挑（也可以直接给它音色 ID）。
两种引擎产出的 `audio-manifest.json` **结构完全一致**，所以时间轴、字幕、渲染**全都不用改** ——
换引擎只是换一个字段的事。

### 火山引擎音色

内置常用音色（完整列表见[官方音色文档](https://docs.volcengine.com/docs/6561/1257544)）：

| 男声 | 女声 |
|---|---|
| 云舟 2.0（默认）· 温暖阿虎 2.0 · 解说小明 2.0 · 磁性解说男声 2.0<br>悬疑解说 2.0 · 广告解说 2.0 · 儒雅青年 2.0 · 少年梓辛 2.0 · 深夜播客 2.0 | 小何 2.0 · Vivi 2.0 · 知性灿灿 2.0<br>甜美桃子 2.0 · 邻家女孩 2.0 · 温柔淑女 2.0 |

内置列表不够用时，也可以直接用声音复刻出来的音色 ID。

### 密钥纪律

API Key 只存在 `tts.env` 里，而且**只有技能里的一个接口包读它**。
智能体调的是那个接口包，拿不到、也不需要读密钥的值：

```
你（手工填一次）→ tts.env（已被 .gitignore 忽略）
                      ↓  只有接口包读
               合成调用  ← 智能体只调这个，永远拿不到密钥值
                      ↓
               火山引擎
```

- **不进对话**：问智能体密钥状态，它给你的是脱敏摘要（`ef90******86c8`）。
- **不进仓库**：`.gitignore` 覆盖 `tts.env` / `*.env` / `*.key` / `*.pem` / `secrets/`；
  仓库自检里有专门的**密钥防线**，并且做过反向自证 —— 故意植入一个假密钥会被当场抓到。
- **不进日志**：出错信息过脱敏，只抹「这次实际用到的密钥值」与 `AKLT…` 形态的 token ——
  刻意**不**用「长字符串就算密钥」这种宽规则，否则连排查要用的请求 ID 一起抹掉。
- **不进分发**：技能打包时排除密钥文件。
- **兜底层**（真正的安全边界）：万一还是泄了，损失要可控 —— 建议在火山控制台用
  **子账号只授语音合成权限**、设**用量告警与限额**，密钥**可随时轮换**。

> 说句实话：合成代码是在你本机执行的，运行时进程里密钥对那一段脚本可见，
> **「智能体绝对读不到」做不到**。上面保证的是**不进对话、不进仓库、不进日志、不进分发**，
> 把泄露半径收敛成一次可撤销的事故。
>
> 另外**别用环境变量存密钥** —— 很多宿主会把进程环境原样打进会话记录。

**别把 `tts.env.example` 改名成 `tts.env` 去填**：那是要留在仓库里的模板，
改名会让后来的人没模板可抄（仓库自检会报错并提示）。复制一份再填。

---

## 思想来源

`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()` 节拍锚定、**动效库**（`assets/motion.js`）、
**模板编排器**（`style_director.py`）、**渲染档位与通道体系**（画质/帧率/快门/并行，
`references/render-profiles.md`）、封面多画幅（含几何实测器），以及
`lint_frames.py` / `qc_check.py` 的判据。完整的署名与逐风格对应关系见
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)。

如果你要的是 React 组件动画、或 Studio 可视化协作，直接去用上面两个项目；如果你想改一句
解说词而不用手工重新对位、并且要帧级可复现，用本项目。
