feat(v1.3.0): 新增火山引擎语音合成 2.0 作为可选配音引擎

流水线开跑前先问用户用哪个 TTS。两引擎产出的 audio-manifest.json
结构完全一致,故 timeline_build / subs / 渲染器零改动。

新增
- scripts/tts_volcano.py  接口包,唯一读 tts.env 的地方。零第三方依赖
                          (只用标准库 urllib),HTTP Chunked 单向流式,
                          与 edge-tts「发一次收一串」形态同构
- scripts/tts_setup.py    向导:选方案 → 配密钥 → 测连接 → 选音色,
                          输出 NEXT_ACTION=… 供智能体识别
- tts.env.example         密钥模板(真实 tts.env 已 gitignore)
- references/volcano-tts.md  密钥纪律 / 配置步骤 / 接口规格 / 音色表 / 排查表

安全
- Key 只存 tts.env,只有 tts_volcano.py 读它;异常过脱敏,只抹本次实际
  加载的密钥值与 AKLT… 形态,刻意不用「长字符串即密钥」的宽规则
- check_integrity.py 新增密钥防线检查(含「疑似把模板改名」提示)
- package_skill.py 排除 .env / .key / .pem,修掉一个真实外泄口

修复(真机联调暴露,均只在实跑时复现)
- enable_subtitle 必须显式开启,否则 words 恒为空数组且完全不报错,
  下游会静默退回按字数插值、字幕开始飘
- usage 挂在结束标记那一行,提前 continue 导致计费恒为 0
- 缓存未存首裁量,命中缓存时 lead_cut_sec=0,重跑字幕比首跑晚一帧
- CLI 把中文音色名原样当 speaker 发出,服务端报 resource ID mismatched

文档
- README 中英双语新增「配音:两个引擎」章节,按用户要求不写命令
  (那是智能体的职责),只讲「会问你什么 / 你唯一要动手的地方」
- 疑难排查补 6 条火山症状;lessons 34 → 56 条;流水线图加 tts_setup 节点
This commit is contained in:
MohZhuang committed 2026-09-23 10:04:13 +08:00
1 parent 09aa089354
commit 0d74a1097d
16 files changed
+1769 -45

No files matched your search

+79 -6
View File
@@ -7,7 +7,7 @@
HTML 写画面 → 确定性逐帧渲染 → 真 MP4。全本地跑,核心链路零 API key、零按次计费。
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.2.3-blue.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.3.0-blue.svg)](CHANGELOG.md)
[![Agent Skill](https://img.shields.io/badge/Agent%20Skill-SKILL.md-8A2BE2.svg)](SKILL.md)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-%E2%9C%93-D97757.svg)](#安装)
[![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#安装)
@@ -102,7 +102,7 @@ Windows 上 `~` 就是 `C:\Users\<你的用户名>`。
| 项 | 最低 | 说明 |
|---|---|---|
| Python | 3.9+ | `edge-tts==7.2.8`(刻意钉死 —— v7 改过边界 API)、`numpy`、`pillow`、`imageio-ffmpeg` |
| Python | 3.9+ | `edge-tts==7.2.8`(刻意钉死 —— v7 改过边界 API)、`numpy`、`pillow`、`imageio-ffmpeg`。**火山引擎引擎零额外依赖**(只用标准库 `urllib`,不需要装 SDK) |
| Node.js | 18+ | 渲染器与封面器用 |
| 浏览器 | Chrome 或 Edge | 自动探测;都没有才下载 playwright chromium(约 115MB,只需一次) |
| ffmpeg | 任意版本 | 先在 `PATH` 找;没有则用 `imageio-ffmpeg` 自带的静态二进制 |
@@ -112,6 +112,71 @@ Windows 上 `~` 就是 `C:\Users\<你的用户名>`。
---
## 配音:两个引擎
流水线开始前,智能体会**先问你用哪个 TTS**,再列候选音色让你挑(也可以直接给它音色 ID):
| | `edge-tts` | 火山引擎语音合成 2.0 |
|---|---|---|
| 你要做什么 | 什么都不用做 | 填一次 API Key |
| 费用 | 免费 | 按字符计费 |
| 音色 | 内置中英文若干 | 豆包 2.0 音色库,含声音复刻 |
| 字级时间戳 | `WordBoundary` | `sentence.words[]` |
| 额外依赖 | `edge-tts==7.2.8`、`imageio-ffmpeg` | **零** —— 只用标准库 `urllib` |
| 什么时候选它 | 默认。开箱可用、够用 | 想要更自然的语气与更好音质 |
两者产出的 `audio-manifest.json` **结构完全一致**,所以时间轴、字幕、渲染**全都不用改** ——
换引擎只是换一个字段的事。
### 选火山时,你唯一要动手的地方
技能第一次跑火山时会**生成一个 `tts.env` 并停下来**,然后告诉你把 API Key 填进去
(火山控制台 → 语音技术 → API Key 管理)。你填好回来说一声,智能体就去测连接、确认音色,
然后接着往下跑。
**这一步刻意不经过对话窗口** —— 密钥不发给智能体,智能体也不需要知道它。
内置常用音色(完整列表见[官方音色文档](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` 去填**:那是要留在仓库里的模板,
改名会让后来的人没模板可抄(仓库自检会报错并提示)。复制一份再填。
---
## 视频项目结构
说一句需求,智能体会在一个**视频项目目录**里走完整个流水线;产物落在 `out/`:
@@ -152,7 +217,9 @@ CSS `transition` 入场)不可能工作,会被 `lint_frames.py` 直接拒绝
| | |
|---|---|
| **节拍锚定解说词** | 画面按**匹配字幕文本**定位动画(`B('块文本')`),绝不硬编码帧号。改解说词,全片自动重排时间,画面代码零改动。 |
| **词边界字幕** | 时序来自 edge-tts 的 `WordBoundary` 事件,不按字数插值 —— 中文里两个同字数的短语时长可以差 3 倍。 |
| **词边界字幕** | 时序来自 TTS 的**字级时间戳**,不按字数插值 —— 中文里两个同字数的短语时长可以差 3 倍。edge-tts 取 `WordBoundary`,火山引擎取 `sentence.words[]`。 |
| **两个配音引擎** | `edge-tts`(免费、免密钥、默认)或**火山引擎语音合成 2.0**(音质更好,需 API Key)。两者产出的 manifest 结构一致,下游零改动。切换用 `--provider`。 |
| **密钥不进 agent、不进仓库** | 火山 API Key 只存 `tts.env`(已 gitignore),**只有接口包读它** —— agent 只调 `tts_volcano.py`,拿不到也不需读密钥。异常信息脱敏;`check_integrity.py` 有专门的密钥防线检查;打包排除密钥文件。 |
| **两级时钟** | 帧长用 MP3 **容器时长**(保音画同步);末块字幕按**语音真实结束**收尾。 |
| **23 种画面风格** | 8 个类别,每种都记录了画布、字阶、时间轴与配色纪律。不用对着空白页从零设计。 |
| **封面双方案** | 每条成片附带 16:9 与**独立重排**的 3:4 封面 —— 不是裁切,裁切会丢掉 57.8% 的画面宽度。 |
@@ -166,8 +233,9 @@ CSS `transition` 入场)不可能工作,会被 `lint_frames.py` 直接拒绝
```mermaid
flowchart LR
A["主题"] --> B["调研<br/><i>每个数字带出处</i>"]
B --> C["narration.json<br/><i>竖线切字幕块</i>"]
C --> D["tts_build.py<br/><i>edge-tts → MP3 + 词边界</i>"]
B --> C["narration.json<br/><i>竖线切字幕块</i>"]
C --> S["tts_setup.py<br/><i>问 TTS 方案 · 配密钥 · 选音色</i>"]
S --> D["tts_build.py<br/><i>edge-tts / 火山引擎 → MP3 + 词边界</i>"]
D --> E["timeline_build.py<br/><i>全局轴 + 拼接音轨</i>"]
E --> F["subs.py<br/><i>subs.json · srt/vtt · beats.js</i>"]
F --> G["frames/*.html<br/><i>一句一场景,风格取自风格库</i>"]
@@ -251,8 +319,13 @@ tl.fromTo('.verdict', { scale: 0.8 }, { scale: 1, duration: 0.6 },
| 封面出成 1 倍图 | 同上,`deviceScaleFactor` 的孪生坑 | 传 `newPage()` + `screenshot({ scale: 'device' })` |
| 数字能渲染但永远不动 | seek 抑制了 `onUpdate` 回调 | 渲染器已修(`pause(t, false)`);帧里改用 transform 数字卷轴 |
| Windows 上 `No such file or directory` | 非 ASCII 路径 —— Windows 的 ffmpeg 把 UTF-8 当 ANSI 读 | 路径保持 ASCII |
| 字幕开始飘 / 位置对不上 | TTS 没返回字级时间戳,`subs.py` 退回**按字数插值**(只有一行 stderr 警告) | 看 `tts_build` 有没有打 `⚠ 未返回字级时间戳`;换个受支持的音色(中英文 2.0) |
| 火山报 `resource ID is mismatched with speaker related resource` | `speaker` 收到了中文显示名而不是音色 ID;或复刻音色配了预置资源 ID | 音色用内置名/ID(接口包会解析);复刻音色配 `VOLC_RESOURCE_ID=seed-icl-2.0` |
| 火山报 `HTTP 401/403` | `tts.env` 里的 key 不对,或该服务未开通 | 核对 `VOLC_API_KEY`;`tts_setup.py --status` 看脱敏状态 |
| 火山报 `网络不可达` | 本包默认**绕过系统代理直连**(境内端点) | 确实需要代理时设 `VOLC_PROXY=http://127.0.0.1:<port>` |
| 重跑后字幕整体晚一帧 | 命中缓存时丢掉了首裁量(历史 bug,已修) | 升级到 v1.3.0+;缓存格式已带 `lead_cut_sec` |
**`references/lessons.md` 是这个仓库里最值钱的文件。** 34 条编号记录,每一条都是一个
**`references/lessons.md` 是这个仓库里最值钱的文件。** 56 条编号记录,每一条都是一个
「成片看着挺正常、其实是错的」的 bug —— 包括它一开始是怎么被误判的。从零开始 debug 之前,
先读它。