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 段补齐 + 版本徽章同步。
This commit is contained in:
MohZhuang committed 2026-09-23 16:41:48 +08:00
1 parent 0d74a1097d
commit d158afcd36
11 files changed
+393 -11

No files matched your search

+56
View File
@@ -9,6 +9,61 @@
---
## [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
### 新增
@@ -232,6 +287,7 @@
- 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 -1
View File
@@ -8,7 +8,7 @@ Write scenes in HTML → deterministic frame-by-frame rendering → a real MP4.
locally; the core pipeline needs no API key and charges no per-render fee.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.3.0-blue.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.3.1-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)](#install)
[![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#install)
+1 -1
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.3.0-blue.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.3.1-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)](#安装)
+12 -3
View File
@@ -1,6 +1,6 @@
---
name: html-explainer
version: 1.3.0
version: 1.3.1
description: 把任意主题做成「讲解/科普视频」并渲染成 MP4:调研→审查→解说词→字幕→配音(edge-tts,或火山引擎语音合成 2.0)→并行构建 HTML 场景→确定性逐帧渲染→成片后出双方案封面。**画面语言内置 23 个模板风格 / 8 个类别**(大胆信号卡、奢华极简、NYT 数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、Logo 收尾、东方柔和有机、VFX 文字光标…共 23 种风格,含每种的画布/配色/字体/时间轴规范,见 references/style-catalog.md),流程规范与音画同步体系承自 anything2explainer(词边界字幕、两级时钟、语速标定、多 agent 分工与 QC 判据),渲染层为自研 seek 式渲染器。**封面双方案**:抖音主封面 1920×1080 + 兼容 3:4 的 1440×1080(独立重排,防主页栅格切字)。独立可移植:GSAP 内置、playwright-core 随包、ffmpeg 走 imageio-ffmpeg 回退、浏览器自动探测 Chrome/Edge;**不依赖 html-video / anything2explainer 任何代码或目录**。触发场景:要做科普/讲解/教学/知识/产品类视频、"讲一下 X 做成视频"、要用 html-video 那种模板化画面但更稳的音画同步、要挑某种视觉风格(极简/数据/赛博/电影感/品牌)出片、要出抖音封面/竖版封面、anything2explainer 换 HTML 渲染、或提到 html-explainer / HTML 讲解视频 / explainer video / MG 视频。
agent_created: true
---
@@ -74,7 +74,7 @@ PY=<venv python 绝对路径> # 派子 agent 时必须展开成绝对
"$PY" <skill>/scripts/timeline_build.py --project . # 全局时间轴:layout.json + narration-full.mp3
"$PY" <skill>/scripts/subs.py --project . # 字幕:subs.json + beats.js + srt/vtt
"$PY" <skill>/scripts/lint_frames.py --project . # 静态体检:八条契约违规(渲染前一秒出结果,比渲完再发现便宜得多)
node <skill>/scripts/render_video.mjs . [--preview 30] [--keep-frames] [--only <场景id>] [--mux-only] # 渲染:out/<slug>.mp4
node <skill>/scripts/render_video.mjs . [--preview 30] [--keep-frames] [--only <场景id>] [--mux-only] [--png-fast|--jpeg] [--concurrency N] # 渲染:out/<slug>.mp4
"$PY" <skill>/scripts/qc_check.py --project . # 体检 + 抽帧速览图
node <skill>/scripts/cover_build.mjs . # 封面双方案:out/cover_169.png + cover_34.png
```
@@ -85,6 +85,15 @@ node <skill>/scripts/cover_build.mjs . # 封面双方案:out/co
**火山密钥只存 `tts.env`(已 gitignore)—— agent 只调 `tts_volcano.py`,不读该文件。**
详见 `references/volcano-tts.md`。
渲染截图模式(**画面里有满幅照片时务必换掉默认 PNG**,见 `lessons.md` 第 69 条):
默认 PNG 对纯 CSS 图形帧很快(45ms/帧),但**对照片满幅帧是 582ms/帧(13×),
且编码在浏览器进程内串行 —— 加 `--concurrency` 完全无效**(实测并发 1/3/6 路的总吞吐
1.80 / 1.86 / 1.87 帧/秒)。照片类片子选:
- `--png-fast`:CDP `optimizeForSpeed`,**逐像素无损**、4.4× 加速(132ms/帧,体积 +22%)。
- `--jpeg --jpeg-quality 95 --crf 18 --preset medium`:13× 加速、体积 1/5;
q95 = PSNR 41.65dB,已低于 x264 crf18 自身的失真,成片看不出。
辅助工具(随时可用,不进主流水线):
```bash
@@ -209,7 +218,7 @@ GSAP 用 `../assets/gsap.min.js`(本地内置)→ 主体动画压在 speech_
| `scripts/timeline_build.py` | layout.json 全局轴 + narration-full.mp3(gap 显式插入) |
| `scripts/subs.py` | 字幕三出口(subs.json / srt+vtt / 画面内层由渲染器注入)+ beats.js 节拍器 |
| `scripts/lint_frames.py` | **渲染前静态体检**:八条契约违规逐条报(外链字体/色值字面量/墙钟/`B()\|\|N`/字幕带压内容/缺中文字体族…) |
| `scripts/render_video.mjs` | 渲染器:浏览器探测 → 逐场景 seek 截图 → ffmpeg 合成;`--preview N` 快样片,`--jpeg` 草稿模式,`--only <场景id>` 只重渲指定场景(**仅末场安全**,变短后须清尾部过期帧,见 lessons 45),`--mux-only` 用现有帧重新合成 |
| `scripts/render_video.mjs` | 渲染器:浏览器探测 → 逐场景 seek 截图 → ffmpeg 合成;`--preview N` 快样片,`--only <场景id>` 只重渲指定场景(**仅末场安全**,变短后须清尾部过期帧,见 lessons 45),`--mux-only` 用现有帧重新合成;**帧里有满幅照片就必须换截图模式**(默认 PNG 编码占 96% 帧时间且与并发无关):`--png-fast` 无损 4.4×,`--jpeg --jpeg-quality 95` 13×;`--crf N` / `--preset <名>` 单独控制成片码率 |
| `scripts/qc_check.py` | 流/时长/音量/抽帧体检 + contact sheet |
| `scripts/cover_build.mjs` | 封面双方案渲染器:`width=1920/1440` 两份独立排版 → 2 倍图;`--at` / `--only` / `--jpg` |
+222
View File
@@ -494,3 +494,225 @@
`tts_setup.py` 改为委托它,语速换算(`parse_rate_percent`)同理。
**判据:凡是「用户会手输、而协议只认机器格式」的字段(音色名、语速、路径),
解析逻辑有且只能有一份,且放在离协议最近的那一层。** 两份实现必然慢慢跑偏。
57. **★ 成片「时长对、其实被腰斩」——逐场累计取整会在帧序列里留一个洞,ffmpeg 到洞就停。**
本片 26 场 5436 帧,渲染日志逐场打 ✓、`--mux-only` 也报「用现有 5436/5436 帧重新合成」,
**但 `render/frames/` 里只有 5435 个文件**:`f_003143.png` 不存在
(market1 末帧 3142 → market2 首帧 3143 被跳过,全片唯一一处不连续)。
ffmpeg image2 序列读到这里报 `Could not open file : f_003143.png` /
`Error during demuxing: I/O error`,**然后照样退出码 0 并写出一个文件** ——
实测只有 **3142 帧 / 104.66s**,后 76 秒整段丢失。
更坑的是 `qc_check.py` 报「成片时长 181.21s · ✓ 与期望差 0.00s 全部通过」——
它读的是**容器时长**(被音轨 180.27s 撑起来),不是视频流真实长度。
**判据(必须实测,两条都要)**:
```bash
# ① 帧序列必须严格连续、且末帧 == layout 的 total_frames
ls render/frames/ | sed 's/f_0*\([0-9]*\)\.png/\1/' | awk 'NR==1{p=$1-1}
{if($1!=p+1) print "缺口: "p" -> "$1; p=$1} END{print "末帧: "p}'
# ② 成片真实帧数(不是容器时长!)
ffmpeg -i out/<slug>.mp4 -map 0:v:0 -c copy -f null - 2>&1 | grep -E "^frame="
```
② 的 `frame=` 必须等于 layout 的 `total_frames`。**不等就是被截断了,QC 的 ✓ 不算证据。**
根因:`render_video.mjs` 用「累计取整」分配全局帧号
(`gf=round(cursor*fps)`、`gfEnd=round((cursor+dur+gap)*fps)`),
而 `totalFrames=round(totalSec*fps)` 是**另算一次**;
两者在某些时长组合下会差 1 帧,于是总帧数说有 N 帧、实际只写出 N-1 个文件。
**应急修法**(1 帧 ≈33ms,插值不可感知):`cp f_<洞后一帧>.png f_<洞帧>.png`
补齐后 `--mux-only`。**根因修法**:让 `totalFrames` 由各场 `gfEnd` 累加得出,不要独立 round。
★ 另注:`--only <场id>` 不解决这个问题 —— 补渲 market2 依然只写 145 帧(它的 `sc.n` 就是 145),
洞还在原处。**别用「补渲一场」来修帧洞,先量帧序列。**
58. **★(2026-09-23 连续两次返工)满幅照片上叠信息:只要「压暗」就错了 —— 正确解法是给字加描边。**
症状链:AI 生成的照片当满幅底 → 为了浮层可读,铺了一层全屏 scrim(顶 .74 / 底 .93)。
使用者第一句反馈是「**图片太淡了,我希望图片是主体**」。
第二版改成「四边渐隐」(只压信息所在的那条边,中央不动)—— **仍然被否**:
「**不要把图片背景变暗**」。两次都指向同一结论:**照片一个像素都不该压**。
正确架构是三级,缺一级都不成立:
1. **照片先提亮**。`--photo-lift: brightness(1.30) contrast(1.12) saturate(1.18)`。
★ 实测素材亮度均值只有 **47–54/255**、**83% 像素落在暗部(<64)**、亮部不足 **3%**
—— 这类图天生偏暗,不减遮罩也没用,得主动抬。**先量 `ImageStat` 再决定系数**,
别凭眼睛调。
2. **信息层自带可读性**。小字用 **8 向 2px 硬描边 + 4px 柔影**(`--photo-shadow`),
大号字用软影即可(`--photo-shadow-lg`,笔画粗时硬描边会发脏)。
★ **1px 描边压不住白墙,实测 2px 是 19–21px 小字的正确档位。**
这与渲染器给字幕加 4px 黑边是同一招 —— 描边只覆盖字周围几个像素,**不碰画面**。
3. **文字色整体上移两档**。`--text-faint(.34)` / `--text-dim(.56)` 在原亮度照片上直接消失
→ 换 `--on-photo-faint(.76)` / `--on-photo-dim(.92)`;
细线 `--line(.14)` / `--line-soft(.07)` 换成 `--on-photo-rule(.42)` / `--on-photo-track(.30)`。
两个配套陷阱(都只在「不压暗」的前提下才暴露):
- **「降级态」不透明度必须重算**。为压暗背景设计的 `.42 / .48`,在亮照片上等于隐形 ——
要抬到 `.58 / .62`。**改可读性方案时,把所有「非 1 的不透明度」都过一遍。**
- **半透明大字会变成「脏点」**。把焦点父级淡到 `.16` 当「留痕」,
在亮背景上看起来像**没渲染干净**。判据:亮底场景里**要么全淡出,要么别低于 .4**。
验收判据:**拿「最亮的一帧」验收,不是拿最好看的一帧。**
白墙 / 亮窗 / 车厢顶灯是照妖镜 —— 挑帧时专挑这些区域压着字的那些。
59. **★ 信息从「画面中轴的巨型字」移到「四边的小字」——这是「图为主体」的布局前提,不是可选美化。**
#58 只解决了「照片够不够亮」,但真正让图退成背景板的是**巨字压中轴**:
实测一帧里 `.pay .num` 字号 **210px** 居中,配合 92px 地名 + 150px 年龄,
中央 60% 的画面被字占满 —— 照片再亮也只是字后面的花纹。
改法(一套可复用的「三边 + 底边」HUD 布局,中央完全留给照片):
| 槽位 | 放什么 |
|---|---|
| 顶部左 | 章节 HUD(品牌 / 档案号 / 章节角标 / 发丝线) |
| 顶部右 | 幕标 + 位置条(起点→终点) |
| 左下 | **本场焦点**(数字 / 地名;同一槽位按时序轮换,互斥) |
| 右下 | 次焦点(年龄 / 总时长) |
| 底部中央 | 字幕(渲染器管理,帧不管) |
字号全线砍到原来的 **~55–65%**(210→132 / 92→50 / 150→80 / 158→96 / 100→46),
并把 `.foot` 的「序号 + 地点」与 `.chap` 的重复信息**合并删掉**(顺带修掉一处信息重复)。
代价要认:字号小了、又没遮罩,**信息在碎背景上会更难读** —— 这正是 #58 第 2、3 条必须同时做掉的原因。
数字卷轴(#6 的纯 transform 方案)搬位置时,**JS 里的 `IT` 常量必须跟着 CSS 的
`.dig` height / `line-height` 一起改**(210→132),否则终态会错位到别的数字上。
60. **★ 改视觉时先留「旧版同帧位」——`peek_frame.mjs` 会覆盖,A/B 只有一次机会。**
`peek_frame.mjs` 的输出路径是 `render/peek/<场id>@<百分比>.png`,**同名即覆盖**。
所以「改前先看一眼、改后对比一下」这个最自然的动作**做不成** —— 改完旧图已经没了。
正确做法(零成本):
```bash
cp render/peek/grind@48.png out/_ab/old-48.png # ① 动手改之前先拷走
# ...改 CSS / 改帧...
node scripts/peek_frame.mjs . grind --at 48 # ② 用「完全相同的 --at」重抽
ffmpeg -y -i out/_ab/old-48.png -i render/peek/grind@48.png \
-filter_complex "[0:v]scale=1280:720[a];[1:v]scale=1280:720[b];[a][b]vstack=2" \
-map "[o]" -frames:v 1 out/ab-content.png # ③ 上下叠=一张说服力极强的对照图
```
为什么要**同 `--at`**:`--at` 是**百分比**,同一个百分比 = 同一个节拍状态,两版才可比。
实测这张 A/B 图一次性暴露了两个问题(旧版把照片压掉一半的光、新版片头大标题在亮建筑上
读不出来),比任何文字描述都有效。**给使用者看改版效果时优先交付 A/B 图,不是单张新图。**
配套:把「旧版亮度反推」也量化出来 —— 用旧 `--scrim` 的分档插值权重乘原图,
直接得到「压暗后 22.3 → 不压暗 52.9」这种硬数字(本片实测新/旧 = **2.4×**)。
这种数字比「看起来亮了」有说服力得多,而且**不需要保留旧成片**就能算。
61. **描边宽度不能按字号比例放大 —— 96px 大标题上 3px 会变成「贴片字幕」。**
#58 定了「小字用 8 向 2px 硬描边」后,很自然会推出「大字按比例给 3–4px」。
实测**错**:96px 的片头大标题配 3px 描边,出来的观感是廉价的「贴片字幕」,
反而比不改还难看。正确档位是**大字号仍用 2px,把「读得清」交给更深的落影**
(`0 8px 32px rgba(0,0,0,.80)`)。
判据:**描边解决的是「字与背景的边界」,落影解决的是「字与背景的层次」** ——
字越大,边界越不需要靠加宽描边来建立,层次反而更依赖落影。
62. **★ 模板里同一个键名既当「CSS 内容」又当「class 名」→ 所有主题规则静默失效。**
本片(2026-09-23)用生成器批量出 22 个帧,模板里写了
`<body class="t-%(tone)s">` 和 `<style>%(tone)s</style>` —— 两处都叫 `tone`,
渲染时把 `tone=TONE_CSS[...]`(**整段 CSS 文本**)传进去,
于是 body 的 class 变成 `t-cold` 后面跟着 20 行 CSS。后果:
`.t-cold .xxx` 一律匹配不到 → 条带没底色、填充条没颜色、负值不变红……
**而画面看起来「只是有点淡」,不像坏了**,很容易当成设计问题放过去。
判据:**同一个 dict 里,一个键只能有一个含义**;要同时传「名字」和「内容」就拆成两个键
(`tone` / `tonecss`)。
⚠️ **`lint_frames.py` 查不出这类错** —— 它验的是 CSS 契约(色值字面量、禁区、字体栈),
查不到「class 名被污染」。**只有真要一次帧(peek)才能发现。**
排查手法:在 `page.evaluate` 里打印 `document.body.className`,
一眼就能看到 class 属性里塞着 CSS。
63. **`waitForFunction(() => window.__tl)` 会永远超时 —— GSAP timeline 有循环引用,序列化不过去。**
写调试脚本时很自然会用 `waitForFunction(() => window.__tl)` 等帧就绪。
实测**必然超时**(哪怕页面完全正常):Playwright 要把返回值序列化回 Node 侧,
而 timeline 上有 `parent` / `_targets` 等循环引用。
正确写法:**只返回布尔** —— `waitForFunction(() => !!window.__tl)`。
排查线索:连「已知能渲染的帧」也报超时 → 先怀疑等待条件本身,而不是帧。
(对照:技能的 `peek_frame.mjs` 之所以没这个问题,是因为它在 `evaluate` 里直接调
`tl.pause(t, false)`,从不把 timeline 对象返回到 Node 侧。)
64. **要「填充」的条,不能用只改 `color` 的工具类 —— 空元素没有文本可着色。**
信息层为了让颜色集中在 `theme.css`,常用 `.ui-acc{color:var(--accent)}` 这类语义类。
但进度条 / 比例条是 `<i>` 空元素,**只有 `background` 才可见**。
本片实测:给 `<i class="ui-acc">` 挂色 → 条带宽度动画完全正常
(`getBoundingClientRect().width` = 300/530/899/1030px ✓)**但屏幕上什么都看不见**,
因为背景色始终是 `rgba(0,0,0,0)`。
做法:为背景单独建一对类(`.fill-acc` / `.fill-dim`),**并在色源里按幕各写一份**
(冷幕 = `--accent`,账幕 = `--accent-on-paper`,光幕 = `--cinema-warm`)。
判据:**先问「这个元素靠什么可见」** —— 靠文字就先给 `color`,靠块面就先给 `background`。
65. **逐个点亮的时刻,不能按「固定块索引」分配 —— 尾部会全挤在同一时刻。**
本片 `slots` 组件(彩票 7 个号码位逐个亮)原本写 `t = b[min(3+i, n-2)]`。
当块数 `n=8` 时,`i=3..6` 全部落到 `b6` → **7 个格子实际只亮了 3 个**,
最后一个还压在 `speech_end - 1.2s` 的红线上。
正确做法:**按旁白语义在两个节拍之间线性插值** ——
前区 5 个落在「一个数字一个数字地对 → 前区五个全中」这句里,
后区 2 个落在「前区五个全中 → 后区两个全中」这句里:
`tIn + (tFront - tIn) * i/4`、`tFront + (tBack - tFront) * j/2`。
这样每个格子的亮起都**有对应的那句旁白**,而不是机械平均。
判据:**逐个出现的元素,它的第 k 次出现必须能对应到某句旁白** ——
对应不上就说明时间分配是拍的,不是排的。
66. **★ 深色墨字叠上「照片描边」会糊成黑块 —— 描边是给「亮字压花底」用的,不是通用可读性外挂。**
本片(2026-09-23)给账目帧铺纸卡后,22px 的中文标签**糊成一团黑块**,而同一行的
拉丁字 `20%`、以及 50/78px 的数字**完全正常**。这一半正常一半糊的表象极易误判成字体问题。
排查路径(值得记住):先量 DOM —— `getComputedStyle` 给的是 `font-size:22px`、
`font-family: Microsoft YaHei`、盒子 `230×29`、`scrollWidth:230`(**没有溢出**)→
**布局层完全正常**。那问题只能在**绘制层**。
根因:信息层为满足「满幅照片不压暗」的统一挂了
`.ui-fg/.ui-dim/.ui-faint{text-shadow:var(--photo-shadow)}`,
而 `--photo-shadow` 是 **8 向 2px 深色硬描边**。深色墨字 + 深色描边 → 笔画互相吃掉。
判据:**描边解决「亮字 ↔ 花底」的边界**;一旦字变深(或底变浅),它就从帮助变成噪声。
纸卡/浅底容器必须显式脱描边:`.t-paper .sheet,.t-paper .sheet *{text-shadow:none}`。
排查线索:**DOM 尺寸与字体全对、但画面糊 → 先怀疑绘制层(text-shadow / filter / 合成),
不要继续在布局和字体栈里找。**
67. **★ 同一个画面里文字横跨两种亮度 —— 任何单一颜色都只能蒙对一半,铺「统一底色」比调字色更根本。**
承 #66 的同一帧:`10,000,000` 压在深色栏杆上(白字刚好可读)、
`8,000,000` 压在亮地板上,而 1px 的 42% 白横线在亮地板(实测亮度 **205–236**)上
**完全消失**(扫行确认最亮行 236,与地板同亮度)。这不是「颜色选得不好」,
而是**背景本身不统一**。
做法:给这组信息铺一张纸卡(`--paper` 底 + `--ink` 墨字),全帧只剩一个底色,
一套配色通吃;横线用 2px `--ink` 也立刻成立。
★ 卡片要**做窄**并确认覆盖了谁:本片卡片右边界 814px,人物从 x≈515 起,
只压到小腿 —— 保住「照片是主体」这条硬要求(用户已为此返工两次)。
判据:**信息层要统一颜色前,先问「它脚下的底是不是同一个亮度」** ——
不是,就先造一块自己的底。
68. **★ 语速不是「引擎无关的标量」——它绑定「引擎 + 音色 + 语速档」三者,换音色必须重新标定。**
本片分镜按「+12% 语速 = **5.53 字/秒**」做时长预算(口径对齐 002 集的 5.56)。
成片实测 **5.31 字/秒**(1656 字 ÷ 311.88s),**偏低 4.5%** —— 预算口径与实测
之间出现了唯一一处系统性偏差,25 场的时长列全部偏乐观。
根因:002 用 edge 引擎,003 换成火山「解说小明 2.0」,**换音色没有重新标定**。
同一个坑的另一半:**标定样本必须取「该场景的完整原文」**。
先前拿单段样本推得 5.52,与整场实测 5.31 差 4%(单段样本没有段首尾静音与断句停顿)。
判据:**换配音 = 重新标定,且样本 = 整场原文**;把实测值写回分镜头部,
并明确标注预算列与实测的分工(预算排期、实测决定帧数)。
69. **★ 逐帧截图的瓶颈在「PNG 编码」,而且它对照片满幅帧慢 13 倍、对并发完全免疫 ——
照片类片子必须换截图格式,光加 `--concurrency` 是白费。**
本片(2026-09-23,25 场全是满幅 GPT 照片)实测 **1.93 帧/秒**,9356 帧要 84 分钟。
逐项拆解(1920×1080,同一帧,含 seek 的完整逐帧开销):
| 截图方式 | 单页 | 3 页并发 | 体积/帧 | 对无损基准 |
|---|---|---|---|---|
| Playwright PNG(旧默认) | 1.80 帧/秒 | **1.86** | 2.5MB | 无损 |
| CDP PNG `optimizeForSpeed` | 7.36 | 10.45 | 3.0MB | **无损**(PSNR 99dB、最大差 0) |
| JPEG q95 | 16.07 | 29.01(5 路 32.3) | 0.54MB | PSNR 41.65dB |
| JPEG q82 | ~24 | — | 0.27MB | PSNR 38.47dB |
三条实测结论:
1. **一帧 582ms 里,seek 只占 21ms,PNG 编码占 561ms(96%)。** 抄帧不会因
「GSAP 复杂」变慢,别去优化帧代码。
2. **编码耗时与画面内容强相关,不是常数。** 纯色/纯 CSS 图形帧只要 **45ms**,
照片满幅帧要 **582ms(13×)** —— 因为 PNG 是无损 deflate,高熵照片既压不碎也压不快。
**这解释了「CSS 类项目几百帧/分钟、照片类项目掉到 1.9 帧/秒」这个量级差**,
不是机器变慢,也不是代码写坏了。
3. **★ 这段编码在浏览器进程内串行:并发 1/3/6 路的总吞吐是 1.80 / 1.86 / 1.87 帧/秒
—— 加并发等于白挂,`--concurrency` 对帧数一点用没有**(16 核 i7-10700,CPU 根本没跑满)。
判据:**吞吐不随并发变化时,瓶颈是某个进程内串行的环节,不是算力。**
排查手段就是「并发 1 → 3 → 6 各测一遍总吞吐」这一条,一次就能定性。
修法(已进 `render_video.mjs`):
- `--png-fast`:走 CDP `Page.captureScreenshot({format:'png', optimizeForSpeed:true})`,
**无损**、4.4× 加速(132ms/帧)。注意要自己 `fs.writeFileSync(Buffer.from(r.data,'base64'))`
落盘(playwright 的 `path` 选项在这里用不上);`--scale != 1` 时补 `clip.scale` 才等效。
- `--jpeg --jpeg-quality 95`:13× 加速、体积 1/5。q95 的 41.65dB 已**低于 x264 crf18
自身的失真水平**,成片看不出 —— 照片类片子选它最划算。
- 顺带把「帧容器」与「成片码率」解耦:新增 `--crf` / `--preset`(旧行为是 `--jpeg`
连带降成 crf23/veryfast,那是草稿语义;成片要显式给 `--crf 18 --preset medium`)。
> 附带踩到的坑:**判据验证要先 `cat` 全文,别只看 `tail`。**
> 我 `tail -3 render.log` 看到「1.8 帧/秒」,误以为是新任务的日志 ——
> 其实是**上一轮残留的文件内容**(新任务因为 `rm` 被批量删除保护拦下,`&&` 链断了,
> node 根本没启动,日志一个字节都没写)。`head` 一看模式行还是「PNG 精细模式 · 并发 3」
> 就立刻露馅。**同一个文件里「模式行」与「进度行」必须来自同一次运行,用 head+tail 交叉验证。**
另一个附带坑:**同回合内累积删除超过 50 个文件会触发批量删除确认,`rm` 会失败** ——
帧目录这种成千上万的文件不要用 `rm` 清,让渲染器原地覆盖帧号即可(帧号是全片唯一的)。
+1
View File
@@ -188,6 +188,7 @@ Body: {user:{uid}, req_params:{text, speaker, audio_params:{format,sample_rate
|---|---|
| `没有找到 tts.env` | 跑 `tts_setup.py --project .` 生成模板后填写 |
| `HTTP 401` / `Invalid X-Api-Key` | 密钥错、未开通服务,或该用旧版鉴权(填 `VOLC_APP_ID` + `VOLC_ACCESS_KEY`) |
| `55000000 resource ID is mismatched with speaker related resource` | ① **音色名没被解析成 ID**(`speaker` 只认 ID;1.3.1 前 `tts_build.py` 走了未解析的路径,已修)② 音色与资源 ID 不匹配(预置音色要配 `seed-tts-2.0`)③ 该音色未在控制台开通。**先确认日志里打印的是不是 `xxx_uranus_bigtts` 形态的 ID**,再去查 ②③ —— 否则会白测一圈音色 |
| `HTTP 404` | 音色与资源 ID 不匹配 —— 预置音色要配 `seed-tts-2.0` |
| `网络不可达` | 本包默认直连;若确实需要代理,设 `VOLC_PROXY=http://127.0.0.1:<port>` |
| 音频末尾「嘎然而止」 | 结束标记 `code=20000000` 的处理问题(本包已处理;自研代码要留意) |
+7 -1
View File
@@ -94,7 +94,7 @@ bash <skill>/setup_env.sh # 首台机器先跑一次
## 阶段 5 · 渲染与打样
```bash
node <skill>/scripts/render_video.mjs . --preview 30 # 前 30 秒草稿(JPEG 快渲)
node <skill>/scripts/render_video.mjs . --preview 30 --jpeg # 前 30 秒草稿(JPEG 快渲)
```
**确认点 4**:把 preview.mp4 给用户看,风格/字号/语速/节奏在这里一次定稿。
@@ -107,6 +107,12 @@ node <skill>/scripts/render_video.mjs . # PNG 精渲 + 音轨 mux
"$PY" <skill>/scripts/qc_check.py --project . # 体检 + 抽帧速览图
```
> **画面里有满幅照片时,先换截图模式再开渲**(`lessons.md` 第 69 条):
> 默认 PNG 对照片帧是 582ms/帧(纯 CSS 图形帧只要 45ms),且编码在浏览器进程内串行,
> **加 `--concurrency` 毫无作用**(并发 1/3/6 路总吞吐 1.80/1.86/1.87 帧/秒)。
> 修法二选一 —— 无损走 `--png-fast`(4.4×),要 13× 走 `--jpeg --jpeg-quality 95`。
> 判断口径:**开工前先看一眼「帧里有没有满幅照片」,有就换,别等渲了半小时才发现。**
### 改一句文案之后
成片已经渲完,用户对某一场的**文案**(旁白/画面文字)有意见时,不必整片重渲:
+39
View File
@@ -60,6 +60,26 @@ def mean_volume(ff, path):
return None
def count_video_frames(ff, path):
"""实测**视频流真实帧数**。
为什么不能只看 Duration:容器时长会被音轨撑起来。若帧序列中间有缺口,
ffmpeg 的 image2 序列会在缺口处停下、**退出码仍是 0**,于是视频流只有前半段,
而容器 Duration 依旧显示完整长度 —— 这个假通过真的发生过,见 lessons #57。
"""
r = subprocess.run([ff, "-hide_banner", "-i", path,
"-map", "0:v:0", "-c", "copy", "-f", "null", "-"],
capture_output=True, text=True, errors="replace")
n = None
for line in r.stderr.splitlines():
if line.startswith("frame="):
try:
n = int(line.split("frame=")[1].strip().split()[0])
except (IndexError, ValueError):
pass
return n
def extract_frame(ff, video, sec, out_png):
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-y",
"-i", video, "-ss", f"{sec:.3f}", "-frames:v", "1", out_png],
@@ -116,6 +136,25 @@ def main() -> int:
fails.append(f"成片与期望时长差 {d:.2f}s(>0.5s)—— 查帧时长口径是否用了容器时长")
else:
report.append(f"- ✓ 与期望差 {d:.2f}s")
# ★ 帧数实测(Duration 会被音轨撑大,掩盖视频流被截断 —— 见 lessons #57)
exp_frames = expect.get("total_frames")
if exp_frames:
got = count_video_frames(ff, video)
if got is None:
warns.append("无法实测视频流帧数(跳过帧数校验)")
elif int(got) != int(exp_frames):
fails.append(
f"成片实测 {got} 帧 ≠ layout 期望 {exp_frames} 帧 —— 视频流被截断。"
f"注意容器时长仍会显示正常,别被它骗了(见 lessons #57)。"
f"先量帧序列有没有缺口:"
f"`ls render/frames/ | sed 's/f_0*\\([0-9]*\\)\\.png/\\1/' | awk "
f"'NR==1{{p=$1-1}} {{if($1!=p+1) print \"缺口: \"p\" -> \"$1; p=$1}} "
f"END{{print \"末帧: \"p}}'`;"
f"有缺口就 `cp f_<洞后一帧>.png f_<洞帧>.png` 补齐,再 --mux-only")
report.append(f"- ✗ 实测视频流 {got} 帧 / 期望 {exp_frames} 帧 —— 被截断")
else:
report.append(f"- ✓ 实测视频流 {got} 帧 = 期望 {exp_frames} 帧")
if os.path.exists(audio):
adur, _, _ = probe(ff, audio)
# 有符号差:正数 = 成片比音轨长。layout 的 video_duration 比音轨多出
+43 -4
View File
@@ -62,6 +62,10 @@ function parseArgs(argv) {
else if (t === '--fps') a.fps = parseFloat(argv[++i]);
else if (t === '--concurrency') a.concurrency = parseInt(argv[++i], 10);
else if (t === '--jpeg') a.jpeg = true;
else if (t === '--jpeg-quality') a.jpegQuality = parseInt(argv[++i], 10);
else if (t === '--png-fast') a.pngFast = true;
else if (t === '--crf') a.crf = parseInt(argv[++i], 10);
else if (t === '--preset') a.preset = argv[++i];
else if (t === '--scale') a.scale = parseFloat(argv[++i]);
else if (t === '--keep-frames') a.keepFrames = true;
else if (t === '--mux-only') a.muxOnly = true;
@@ -214,6 +218,32 @@ const frameSeek = async (o) => {
return tl.time();
};
// ---------- 截图:三种模式(这一层是本渲染器最容易踩的性能坑) ----------
// PNG(playwright 默认)对**照片满幅帧**极慢:实测 1920×1080 照片帧 582ms/张,
// 而纯色帧只要 45ms —— 差 13 倍。因为 PNG 是无损 deflate,高熵照片内容既压不小
// 也压不快,且这段编码在**浏览器进程内串行**:并发 1/3/6 路实测总吞吐
// 1.80 / 1.86 / 1.87 帧/秒 —— 挂并发等于白挂,`--concurrency` 对帧数毫无帮助。
// ★ --png-fast:走 CDP 的 optimizeForSpeed(zlib 最快档、不做自适应滤波),
// 实测 132ms/张,仍是**逐像素无损**(对 playwright PNG:PSNR 99dB、最大差 0),
// 代价是体积 +22%。照片多的片子选它。
// ★ --jpeg:q82 41ms/张(13×);q95 52ms/张,PSNR 41.7dB(已低于 x264 crf18
// 自身的失真水平,成片看不出),体积仅 1/5。要极致速度就它。
async function captureFrame({ page, cdp, outPath, args, W, H }) {
if (cdp) {
const opts = { format: 'png', optimizeForSpeed: true, captureBeyondViewport: false };
const sc = args.scale || 1;
if (sc !== 1) opts.clip = { x: 0, y: 0, width: W, height: H, scale: sc };
const r = await cdp.send('Page.captureScreenshot', opts);
fs.writeFileSync(outPath, Buffer.from(r.data, 'base64'));
return;
}
await page.screenshot({
path: outPath,
type: args.jpeg ? 'jpeg' : 'png',
quality: args.jpeg ? (args.jpegQuality || 82) : undefined,
});
}
// ---------- 主流程 ----------
async function main() {
const args = parseArgs(process.argv.slice(2));
@@ -283,7 +313,9 @@ async function main() {
log(`项目:${projectDir}`);
log(`场景 ${scenes.length} 个 · 总时长 ${renderSec.toFixed(2)}s · ${totalFrames} 帧 @${fps}fps`);
log(`画布 ${W}x${H} · ${args.jpeg ? 'JPEG 草稿模式' : 'PNG 精细模式'} · 并发 ${args.concurrency || 3}`);
const shotMode = args.pngFast ? 'PNG 速度优先(CDP,无损)'
: args.jpeg ? `JPEG q${args.jpegQuality || 82}` : 'PNG 精细模式';
log(`画布 ${W}x${H} · ${shotMode} · 并发 ${args.concurrency || 3}`);
let browser = null;
if (!args.muxOnly) {
@@ -309,6 +341,8 @@ async function main() {
async function renderScene(sc) {
// ★ viewport 必须逐 page 指定(launch 的 viewport 不继承到 newPage)
const page = await browser.newPage({ viewport: { width: W, height: H } });
// --png-fast:走 CDP 截图(见 captureFrame 注释)
const cdp = args.pngFast ? await page.context().newCDPSession(page) : null;
const htmlPath = path.join(projectDir, 'frames', `${sc.id}.html`);
try {
// __MG_RENDER__ 必须在任何页面脚本之前置位(帧据此跳过自动起播)
@@ -350,7 +384,7 @@ async function main() {
const globalT = sc.globalStart + t;
await page.evaluate(frameSeek, { t, globalT, total: renderSec });
const framePath = path.join(framesDir, `f_${String(sc.gf + i + 1).padStart(6, '0')}.${args.jpeg ? 'jpg' : 'png'}`);
await page.screenshot({ path: framePath, type: args.jpeg ? 'jpeg' : 'png', quality: args.jpeg ? 82 : undefined });
await captureFrame({ page, cdp, outPath: framePath, args, W, H });
doneFrames++;
if (doneFrames % 300 === 0)
log(`进度 ${doneFrames}/${totalFrames} 帧 · ${(doneFrames / ((Date.now() - t0) / 1000)).toFixed(1)} 帧/秒`);
@@ -398,11 +432,16 @@ async function main() {
'-framerate', String(fps), '-i', path.join(framesDir, `f_%06d.${ext}`),
];
if (hasAudio) cmd.push('-i', audioPath);
// 中间帧格式与最终编码质量**解耦**:--jpeg 只换帧容器,不代表要降码率。
// (旧行为里 --jpeg 连带降成 crf23/veryfast,是"草稿"语义;成片用 --crf 18 覆盖。)
const crf = args.crf != null ? args.crf : (args.jpeg ? 23 : 18);
const preset = args.preset || (args.jpeg ? 'veryfast' : 'medium');
cmd.push(
'-c:v', 'libx264', '-preset', args.jpeg ? 'veryfast' : 'medium',
'-crf', args.jpeg ? '23' : '18', '-pix_fmt', 'yuv420p',
'-c:v', 'libx264', '-preset', preset,
'-crf', String(crf), '-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
);
log(`编码:libx264 · preset ${preset} · crf ${crf}`);
if (hasAudio) {
cmd.push('-c:a', 'aac', '-b:a', '160k');
// 音轨短于视频时补静音,而不是用 -shortest 砍掉视频尾部。
+4 -1
View File
@@ -338,7 +338,10 @@ async def run(args) -> int:
return 1
if provider == "volcano":
voice = args.voice or pj.get("voice") or tts_volcano.DEFAULT_VOICE
# project.json 里存的是显示名(「解说小明 2.0」),但 speaker 字段只认 ID。
# 在入口就解析成 ID,后面日志/清单里也就是可直接复制的真 ID。
voice = tts_volcano.resolve_voice_token(
args.voice or pj.get("voice") or "", tts_volcano.DEFAULT_VOICE)
rate = args.rate or pj.get("rate") or 0
else:
voice = args.voice or pj.get("voice") or DEFAULT_VOICE_ZH
+7
View File
@@ -433,6 +433,13 @@ def synthesize(text: str, voice: str = DEFAULT_VOICE, rate: int = 0,
text = (text or "").strip()
if not text:
raise VolcanoTTSError("文本为空")
# ★ 音色解析必须在这里再兜一次:`speaker` 字段只认 ID,
# 而 project.json 里存的是显示名(如「解说小明 2.0」)。
# CLI 路径调过 resolve_voice_token,但 tts_build.py 是直接把 project.json 的
# voice 传进来的 —— 少这一步就会拿到服务端的
# `55000000 resource ID is mismatched with speaker related resource`,
# 报错信息完全看不出是「名字没解析」(2026-09-23 实测踩到)。
voice = resolve_voice_token(voice, DEFAULT_VOICE)
cred = load_credentials(project_dir)
last: Exception | None = None