修复 - 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 段补齐 + 版本徽章同步。
19 KiB
变更日志
html-explainer 的所有重要变更都记录在这里。
格式遵循 Keep a Changelog, 版本号遵循语义化版本。
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。 修法(两层,任一层都能挡住):tts_volcano.synthesize()入口处补voice = resolve_voice_token(voice, DEFAULT_VOICE)—— 接口包是唯一知道音色表的地方,任何调用方都不该自己解析;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(CDPoptimizeForSpeed)7.36 10.45 3.0MB 无损(PSNR 99dB、最大差 0) --jpeg --jpeg-quality 9516.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计数)、CSStransition入场、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。