Files
MohZhuang d158afcd36 release: v1.3.1 —— 火山音色名解析修复 + 渲染器照片帧提速 5–13 倍
修复
- tts_build.py 走火山引擎时,project.json 里的中文音色名没被解析成音色 ID,
  合成恒失败并返回 code=55000000(报错完全看不出是「名字没解析」)。
  双层兜底:tts_volcano.synthesize() 入口解析 + tts_build.py 读配置后解析。

新增
- render_video.mjs:`--png-fast`(CDP optimizeForSpeed,逐像素无损,4.4×)、
  `--jpeg-quality N`、`--crf N`、`--preset <名>`。最后一对把「帧容器」与
  「成片码率」解耦(旧 --jpeg 会连带降 crf23/veryfast,那是草稿语义)。
  默认行为完全不变。
- qc_check.py:新增实测视频流帧数校验。原先只比容器时长,而容器时长会被音轨撑起
  来,帧序列缺口会导致视频流被截断却仍显示完整长度(假通过,见 lessons #57)。

文档
- lessons.md 新增 #62–#69:信息层色值契约与绘制层排查、语速标定绑定引擎与音色、
  以及「PNG 编码才是瓶颈、且对并发完全免疫」的完整实测与判定路径。
- SKILL.md / references/workflow-guide.md:补截图模式选用判据(帧里有满幅照片就换模式)。
- CHANGELOG 1.3.1 段补齐 + 版本徽章同步。
2026-09-23 16:41:48 +08:00

297 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 变更日志
`html-explainer` 的所有重要变更都记录在这里。
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
`1.2.2` 之前的版本都是私有内部发布。**`1.2.2` 是 GitHub 上的首个公开版本。**
---
## [1.3.1] — 2026-09-23
### 修复
- **★ `tts_build.py` 走火山引擎时,`project.json` 里的中文音色名没被解析成音色 ID,
合成恒失败。** 症状是服务端返回
`code=55000000 message='resource ID is mismatched with speaker related resource'`,
报错信息完全看不出是「名字没解析」。
根因:`resolve_voice_token()` 只在 `tts_setup.py` / `tts_volcano.py` 的 CLI 路径被调用,
`tts_build.py` 是把 `project.json` 的 `voice`(如 `解说小明 2.0`)**原样**透传给
`tts_volcano.synthesize()` 的,而 `speaker` 字段只认 ID。
修法(两层,任一层都能挡住):
1. `tts_volcano.synthesize()` 入口处补 `voice = resolve_voice_token(voice, DEFAULT_VOICE)`
—— 接口包是唯一知道音色表的地方,任何调用方都不该自己解析;
2. `tts_build.py` 读 `project.json` 后先解析一次,让日志与 `audio-manifest.json`
里记的是可直接复制的真 ID。
> 教训:**「文档里写了有这一步」不等于「每条代码路径都有这一步」。**
> `references/volcano-tts.md` 当时已写明「音色名解析走 `resolve_voice_token()`」,
> 但那条路径实际没被接上 —— 光看文档会误判成「密钥/账号没开通音色」,
> 于是去逐个测音色(实测 7 个 2.0 音色全都可合成),白绕一圈。
### 新增
- **★ 逐帧截图模式可选,照片类片子提速 5–13 倍。** 起因是一条全片满幅照片的片子
实测只有 **1.93 帧/秒**(9356 帧要 84 分钟)。逐项拆解后发现瓶颈根本不在 seek
(只占 21ms/帧),而在 **PNG 编码(561ms,占 96%)**,且**与画面内容强相关**:
纯色/纯 CSS 图形帧只要 45ms,照片满幅帧要 582ms(13×)—— 这就是「CSS 类项目
几百帧/分钟、照片类项目掉到 1.9 帧/秒」这个量级差的全部原因。
更关键的是**这段编码在浏览器进程内串行**:并发 1/3/6 路的总吞吐实测
1.80 / 1.86 / 1.87 帧/秒 —— `--concurrency` 对帧数毫无帮助。
| 截图方式 | 单页 | 3 页并发 | 体积/帧 | 对无损基准 |
|---|---|---|---|---|
| Playwright PNG(默认,未变) | 1.80 帧/秒 | 1.86 | 2.5MB | 无损 |
| **`--png-fast`**(CDP `optimizeForSpeed`) | 7.36 | 10.45 | 3.0MB | **无损**(PSNR 99dB、最大差 0) |
| **`--jpeg --jpeg-quality 95`** | 16.07 | 29.01(5 路 32.3) | 0.54MB | PSNR 41.65dB |
新增开关(**默认行为完全不变**):`--png-fast`、`--jpeg-quality N`(默认 82)、
`--crf N`、`--preset <名>`。其中 `--crf`/`--preset` 顺带把「帧容器」与「成片码率」解耦 ——
旧行为里 `--jpeg` 会连带降成 `crf23/veryfast`(那是"草稿"语义),
成片现在可以显式给 `--crf 18 --preset medium`。
选用判据(已写进 `SKILL.md` 与 `references/workflow-guide.md`):
**帧里有满幅照片 → 换模式;`--png-fast` 要无损,`--jpeg --jpeg-quality 95` 要速度。**
- **`qc_check.py` 新增「实测视频流帧数」校验。** 原先只比容器时长,而容器时长会被音轨撑起来:
帧序列中间若有缺口,ffmpeg 的 image2 序列会在缺口处**停下且退出码仍是 0**,
视频流只有前半段,Duration 却照常显示完整长度(假通过,见 `lessons.md` #57)。
现在用 `-map 0:v:0 -f null -` 数真实帧数,与 `layout.json` 的 `total_frames` 比对;
不一致直接判 fail,并在报错里附上「找帧序列缺口 → 补齐 → `--mux-only`」的命令。
- `tts_build.py` 的日志与清单现在打印/记录**解析后的音色 ID**,便于直接对照官方文档排错。
---
## [1.3.0] — 2026-09-23
### 新增
- **★ 火山引擎(豆包)语音合成 2.0 作为可选配音引擎。** 原先只有 edge-tts;
现在 `tts_build.py --provider edge|volcano`,`project.json` 记 `provider` 字段。
两个引擎产出的 `audio-manifest.json` **结构完全一致**(`boundaries` 均为「秒 + 原始坐标系」),
因此 `timeline_build.py` / `subs.py` / 渲染器**零改动**。
- **`scripts/tts_volcano.py`** —— 火山 TTS 接口包(SDK 层)。走 **HTTP Chunked 单向流式**
`POST /api/v3/tts/unidirectional`:与 edge-tts 形态同构(发一次、收一串),
故缓存 / 硬超时 / 退避重试 / 裁静音 / 两级时钟全部原样复用。
**零第三方依赖**(只用标准库 `urllib`)。`--check` / `--voices` / `--synth` 三个子命令。
- **`scripts/tts_setup.py`** —— 配音方案向导:选方案 → 缺密钥则生成 `tts.env` 模板并停下 →
测连接 → 选音色 → 写回 `project.json`。输出 `NEXT_ACTION=…` 供 agent 判断下一步。
- **`tts.env.example`** —— 密钥模板(入库;真实 `tts.env` 被忽略)。
### 安全
- **★ 密钥纪律:agent 不读、仓库不记、日志不吐、分发不含。** 四条都由结构保证:
① 密钥只从 `tts.env` 读取,而只有 `tts_volcano.py` 读它 —— agent 只调接口包;
② 异常信息过 `_redact()` 脱敏(只抹本次实际加载的密钥值与 `AKLT…` 形态 token,
保留 reqid/logid 以便排查);
③ `.gitignore` 新增 `tts.env` / `*.env` / `*.key` / `*.pem` / `secrets/`;
④ **`check_integrity.py` 新增「密钥防线」检查**(gitignore 覆盖 + 遍历仓库抓非空密钥赋值与
`AKLT…` token,空模板不算违规),CI 在无密钥环境下跑通。
- **★ 修掉一个真实的密钥外泄口:`package_skill.py` 原先不排除 `tts.env`** ——
技能目录里若放了这个文件,打包会把用户的 API Key 一起塞进可分发的 zip。
已加入 `EXCLUDE_NAMES` / `EXCLUDE_SUFFIX`(`tts.env`、`*.env`、`*.key`、`*.pem`),
模板 `tts.env.example` 保留。已实测:打包后 zip 内无密钥文件、内容级扫描无密钥串。
### 修复
- **`tts_setup.py` 的方案选定后未落盘**:用户选火山 → 生成模板 → 填密钥 → 回来跑 `--check`,
此时 `project.json` 还写着 `edge`,会**静默去测 edge 并报「就绪」**。现在方案一旦确定立刻写盘。
- `--check` 现在先打印 `测试方案:<provider>`,避免「测的其实不是你以为的那个」。
- `ensure_env_file()` 的查找顺序改成与 `tts_volcano.env_candidates()` 一致
(项目目录 → 技能目录 → 新建于项目目录),消除「向导认技能目录的、接口包认项目目录的」错位。
### 修复(首次真机联调后补齐)
> v1.3.0 在合并前用**真实 API Key** 跑了完整链路(向导 → 真合成 → timeline → subs → 打包),
> 又抓出 4 个只看代码发现不了的问题。以下都已修复并实测通过;v1.3.0 此前从未发布,故不另起版本号。
- **★ 字级时间戳必须显式开 `audio_params.enable_subtitle: true`。** 官方文档的响应示例里
`sentence.words[]` 是有值的,容易误以为「默认就给」——**实测不传这个参数 `words` 恒为空数组**
(同文本对照:`false` → `words=0`,`true` → `words=10`)。而且它**完全不报错**:音频正常、
时长正常、`sentence.text` 也正常。下游 `subs.py` 会静默退回**按字数插值**,成片照样出、
字幕开始飘。现已在接口包默认开启,并在拿到音频却没有时间戳时返回 `warning`
让 `tts_build.py` 打到台面上。该能力仅豆包 2.0 的中英文音色支持。
- **计费字数永远是 0**:`usage.text_words` 挂在**结束标记那一行**(`code=20000000`),
而解析循环先判结束标记就 `continue` 了,那一行从没被读完。同时补上
`X-Control-Require-Usage-Tokens-Return: *` 请求头(不传该头服务端根本不返回 `usage`)。
修后实测:`计费 20 字` 与文本逐字对上。
- **★ 命中缓存时丢失首裁量 → 首跑与重跑产出不一致。** 词边界是原始坐标系,下游靠
`lead_cut_sec` 平移;缓存只存了边界数组,于是**重跑 `lead_cut_sec=0`、首跑是 0.037**
——字幕整体晚一帧,且只在第二次跑时出现。缓存格式改为
`{"boundaries": [...], "lead_cut_sec": x}`,并对旧版裸列表格式做兼容读取。
- **CLI 把中文音色名原样当 `speaker` 发出去**:`--voice "云舟 2.0"` 被服务端拒绝,
报的是 `55000000 resource ID is mismatched with speaker related resource`
——完全看不出是「名字没解析」。现把 `resolve_voice_token()`(名称/序号/ID)与
`parse_rate_percent()`(`+8%`/`8`/`0.08`)都收进接口包,`tts_setup.py` 改为委托,
消除两份实现跑偏的可能。
- 火山项目的 `rate` 会沿用 `new_project.py` 写的 edge 格式 `+8%`,现按引擎清洗
(火山存 int 百分比);`--status` 在 `rate=0` 时不再误报「未设置」(`0` 是 falsy)。
- `check_integrity.py` 缺模板时新增提示:若同目录存在 `tts.env`,直接说明
**「疑似把模板改名成了 tts.env」**(实测踩到的真实用法)。
### 备注
- 火山接口的坑已处理并写进文档:**结束标记 `code=20000000`(不是 0,误判会丢最后一段音频)**、
单请求文本上限 ~200 字(超了按标点切分并按实际时长平移词边界)、
**字级时间戳需显式开 `audio_params.enable_subtitle`**(见上)。
- `openspeech.bytedance.com` 是境内端点,接口包**默认绕过系统代理直连**;需要时设 `VOLC_PROXY`。
- edge 独有的「相邻数字补逗号」对火山**不启用** —— 它会改动送进去的文本,
影响词边界与解说词的逐字对齐,而火山不熔读相邻数字。
- 真机联调的验收口径:3 段共 20 块字幕,**每块起点与其首字词边界的误差均为 0.000s**;
首跑与缓存重跑的 manifest 深度 diff(982 个字段)完全一致;
打包 zip 38 个成员,密钥文件名与内容级扫描**双无命中**。
---
## [1.2.3] — 2026-09-22
### 新增
- **`render_video.mjs --only <场景id,场景id>`** —— 改完某一场的文案或动效后,**只重渲这一场的帧**,
其余场景的帧原样保留。实测:14 场 6194 帧整片重渲约 20 分钟,只重渲末场 78 秒。
脚本会检查被指定场景是否为末场并给出警告(非末场一旦时长变化,其后场景帧号会整体前移)。
- **`references/lessons.md` 第 43–45 条**:结尾写法红线(同义反复 / 自指复读 / 鸡汤留白三类废句,
及「勾选框全空的待办清单」这一替代写法)、极简帧的信息重复、`--only` 的两个前提与必做清理。
### 修复
- **★ 合成阶段的 `-shortest` 会砍掉视频尾部。** 音轨通常比画面短(段尾静音 + 片尾留白),
`-shortest` 按较短的那条流收尾,于是**片尾字幕与片尾文字面被截短**。
实测某片:片尾设计 1.5s,成片只剩 1.16s(成片比 layout 短 0.52s)。
已改为 `-af apad -t <renderSec>`:**音轨不足的部分补静音,画面长度由 layout 唯一决定**,
不再受音轨长度左右。
- **`scripts/qc_check.py` 抽帧判据改为「体积 + 内容」双条件**:
体积低于本片中位数 35% 时不再直接判可疑,先看该帧的颜色数与标准差 ——
片头淡入起点、纯文字面这类**合法的极简画面**体积天然小,旧判据会稳定误报。
- **`qc_check.py` 时长差文案**:原先「成片差 0.48s」会被读成缺陷,
实际是段间 gap 与片尾留白的设计值;已改为分别列出「音轨 / layout 期望 / 成片」三个口径。
### 文档勘误
- **`lessons.md` 第 33 条与本次改动自相矛盾,已同步修正**:该条原写「`render_video.mjs`
没有单场景开关」(本次新增的 `--only` 正是此开关),且给出的主序列合成命令带 `-shortest`
并注明「与渲染器逐字一致」——**照抄会把上面刚修掉的片尾截断 bug 原样装回去**。
现改为:末场优先用 `--only`,临时项目法保留给「被改场景不在末位」的情形;合成命令同步换
`-af apad -t <renderSec>` 并加一段「别用 `-shortest`」的说明。
- `--only` 的示例场景名由本项目专有的 `coda` 换成通用占位 `<场景id>`。
---
## [1.2.2] — 2026-09-21 — 首个公开版本
### 新增
- **`scripts/lint_frames.py`** —— 渲染前的画面静态体检。在花时间渲染整片之前,先检查八条
画面契约与已知坑:外部字体 / CDN 引用、硬编码颜色字面量、缺失 `window.__tl` 注册、
CDN 版 GSAP、墙钟逻辑(`setInterval`、`requestAnimationFrame` 计数)、CSS `transition`
入场、`B('…') || 数字` 兜底写法、侵入字幕禁区的内容、缺失中文字体族。
- **`scripts/peek_frame.mjs`** —— 单帧速览。把一个场景 seek 到若干时点截图,耗时是**秒级**,
于是不用渲全片就能判断画面设计。`--at` 收的是百分比,不是帧号。
- **双语项目 README**(`README.md` 中文为默认,`README.en.md` 英文)。
- **开源治理文件**:`LICENSE`(MIT)、`THIRD_PARTY_NOTICES.md`、`CHANGELOG.md`、
`CONTRIBUTING.md`、`licenses/Apache-2.0.txt`、`.gitignore`、`.gitattributes`、
GitHub issue / PR 模板,以及 CI 工作流。
### 修复
- **`speech_end_sec` 在冷跑与缓存重跑之间不一致**(lessons #31)。`_result()` 量的是已经裁过
静音的输出文件,于是头部裁剪量被减了两次:冷跑报 `7.541s`,缓存重跑报 `7.696s` ——
0.155s 的差异破坏了「两次渲染逐帧一致」的保证,并让末块字幕提前消失。现在统一存**裁后
坐标系**下的值。
### 变更
- 文档:`scripts/lint_frames.py` 与 `scripts/peek_frame.mjs` 补进命令序列与文件表
(两者此前都没有被文档记录)。
- 文档:lint 现在是工作流里的显式阶段,位置在生成字幕之后、渲染之前。
- 文档:删除对私有客户项目的引用,使技能可以公开发布。
- 文档:README 精简 —— 移除**面向智能体执行**的七条命令序列与「命令参考」表(这些属于
`SKILL.md` 的职责),改为只向使用者交代**视频项目结构**与产物位置;同时删去「路线图」
与文末作者签名。-150 行,中文 364→295 行、英文 390→319 行。
- 许可:风格目录衍生自 Apache-2.0 内容,现已带上完整的来源署名。
### 备注
- 从实战回填:预览渲染会清空帧目录(`--keep-frames`)、重渲单个场景的临时项目法、
以及「开场 0.5 秒内就必须出现主角」的冷开场规则(lessons #32–#34)。
---
## [1.2.1] — 2026-09-20
四个渲染 / QC 正确性修复,全部是靠给渲染器加探针找出来的,而不是靠肉眼看。
详见 `references/lessons.md` #27–#30。
### 修复
- **`tl.pause(t)` 会静默吞掉本次 seek 的回调。** GSAP 的签名是
`pause(atTime, suppressEvents)`,第二参默认为 `true`,于是 seek 触发的任何
`onUpdate` / `onStart` / `onComplete` 都被丢弃。用 `onUpdate` 写的计数器因此在成片里恒为
初值 —— 不报错、不告警、缩略图尺寸正确,静态检查也查不出来。渲染器与封面器现在调用
`tl.pause(t, false)`。文档同时把数字滚动引导向**纯 transform 的「数字卷轴」**,
完全不依赖任何回调。
- **传给 `chromium.launch()` 的 `deviceScaleFactor` 被静默忽略。** `viewport` 与
`deviceScaleFactor` 都是 context 级选项,只在 `newPage()` 上生效。封面实际写成了 1 倍图,
而日志声称 2 倍。现在构建用 PIL 核验输出尺寸,不再信日志。
- **QC 抽样只覆盖了视频前四分之一。** `qc_check.py` 取字幕块起始时间后写成 `pts[:samples]`,
拿的是**前 N 块**而不是均匀铺开。一条 39 块的视频抽了 12 块,只覆盖 0.25–23.86s,后 6 个
场景一帧没抽,却报告「全部通过」。现在按步长在全片范围均匀抽样。
- **封面排版是在动画中途量的。** 校验紧接页面加载后就跑,读的是 `fromTo` 起始值而不是稳定
终态,于是报出幽灵偏移。现在测量前先置 `__MG_RENDER__` 并 seek 到 `tl.duration()`,
与封面渲染器完全一致。
---
## [1.2.0] — 2026-09-20
### 新增
- **封面双方案。** 每条成片现在产出两张独立排版的封面而不是一张:`cover_169.png`
(1920×1080,输出 3840×2160)用于信息流与播放器,`cover_34.png`(1440×1080,
输出 2880×2160)用于主页栅格。
- `scripts/cover_build.mjs` —— 封面渲染器,复用场景渲染器的确定性 seek 内核,但有三处
刻意不同:不带字幕层、只 seek 到单个「封面时刻」(默认 `--at last`)而不是逐帧步进、
以及默认 2 倍输出。
- `assets/cover-template.html` —— 封面模板,三个必需元素写在文件头注释里。
- `references/cover-guide.md` —— 重排对照表、尺寸规则、上传策略与自检清单。
- `scripts/new_project.py` 现在会把两份封面 HTML 一起生成到新项目里。
### 备注
- 两张封面是**共享同一套视觉基因的姊妹版式,绝不是一对裁切**。从 16:9 裁 3:4 会丢掉
57.8% 的画面宽度(1920 → 810px),必然把大字钩子切掉。重排改的是构型本身:悖论视觉
从「右侧左右并置」变成「上方竖排堆叠」,钩子从两行变成三行。
---
## [1.1.0] — 2026-09-20 — 首个打包发布
### 新增
- 核心流水线:`new_project.py` → `tts_build.py` → `timeline_build.py` → `subs.py` →
`render_video.mjs` → `qc_check.py`。
- **确定性 seek 渲染器**(`render_video.mjs`)。不录活页面的屏,而是每一帧都靠 seek
GSAP 时间轴(`tl.pause(t, false)`)并同步 CSS 动画
(`document.getAnimations().currentTime = t*1000`)后再截图。这消掉了实时录制的一整类
失败:没有起播竞态、没有字体加载超时、没有不稳定的引导期。
- **`B('块文本')` 节拍同步。** 画面动画按**匹配字幕块文本**定位,而不是写死帧号,于是改解说词
会重排全片时序,而一个画面文件都不用动。
- `scripts/subs.py` —— 字幕三出口(运行时 JSON、SRT/VTT 外挂、渲染器注入的画面内层),
外加每场景生成一份 `beats.js`。
- `scripts/make_theme.py` —— 四个预设 + 按主题词推色,写出唯一颜色来源 `theme.css`。
- `scripts/qc_check.py` —— 流 / 时长 / 响度 / 抽帧体检,附抽帧速览图。
- `scripts/import_styles.py` —— 移植期一次性工具,把模板设计规范转写成
`references/style-catalog.md` / `.json`。
- 23 种画面风格目录,8 个类别,按改编成本分类。
- `setup_env.sh` 做首次环境自检,`--install` 装缺失依赖;`package_skill.py` 打可移植 zip。
[1.3.1]: https://github.com/OneMoh/html-explainer/releases/tag/v1.3.1
[1.3.0]: https://github.com/OneMoh/html-explainer/releases/tag/v1.3.0
[1.2.3]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.3
[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.0]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.0
[1.1.0]: https://github.com/OneMoh/html-explainer/releases/tag/v1.1.0