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

19 KiB
Raw Permalink Blame History

变更日志

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。 修法(两层,任一层都能挡住):

    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。