diff --git a/CHANGELOG.md b/CHANGELOG.md index bd76bd5..ddbe26d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.en.md b/README.en.md index daa548f..863dd83 100644 --- a/README.en.md +++ b/README.en.md @@ -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) diff --git a/README.md b/README.md index 660f186..7415095 100644 --- a/README.md +++ b/README.md @@ -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)](#安装) diff --git a/SKILL.md b/SKILL.md index 858133a..aed1ca0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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= # 派子 agent 时必须展开成绝对 "$PY" /scripts/timeline_build.py --project . # 全局时间轴:layout.json + narration-full.mp3 "$PY" /scripts/subs.py --project . # 字幕:subs.json + beats.js + srt/vtt "$PY" /scripts/lint_frames.py --project . # 静态体检:八条契约违规(渲染前一秒出结果,比渲完再发现便宜得多) -node /scripts/render_video.mjs . [--preview 30] [--keep-frames] [--only <场景id>] [--mux-only] # 渲染:out/.mp4 +node /scripts/render_video.mjs . [--preview 30] [--keep-frames] [--only <场景id>] [--mux-only] [--png-fast|--jpeg] [--concurrency N] # 渲染:out/.mp4 "$PY" /scripts/qc_check.py --project . # 体检 + 抽帧速览图 node /scripts/cover_build.mjs . # 封面双方案:out/cover_169.png + cover_34.png ``` @@ -85,6 +85,15 @@ node /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` | diff --git a/references/lessons.md b/references/lessons.md index 44cf50a..3314826 100644 --- a/references/lessons.md +++ b/references/lessons.md @@ -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/.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 个帧,模板里写了 + `` 和 `` —— 两处都叫 `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)}` 这类语义类。 + 但进度条 / 比例条是 `` 空元素,**只有 `background` 才可见**。 + 本片实测:给 `` 挂色 → 条带宽度动画完全正常 + (`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` 清,让渲染器原地覆盖帧号即可(帧号是全片唯一的)。 diff --git a/references/volcano-tts.md b/references/volcano-tts.md index 313a8a8..b59ab69 100644 --- a/references/volcano-tts.md +++ b/references/volcano-tts.md @@ -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:` | | 音频末尾「嘎然而止」 | 结束标记 `code=20000000` 的处理问题(本包已处理;自研代码要留意) | diff --git a/references/workflow-guide.md b/references/workflow-guide.md index 8d65547..69b0ef5 100644 --- a/references/workflow-guide.md +++ b/references/workflow-guide.md @@ -94,7 +94,7 @@ bash /setup_env.sh # 首台机器先跑一次 ## 阶段 5 · 渲染与打样 ```bash -node /scripts/render_video.mjs . --preview 30 # 前 30 秒草稿(JPEG 快渲) +node /scripts/render_video.mjs . --preview 30 --jpeg # 前 30 秒草稿(JPEG 快渲) ``` **确认点 4**:把 preview.mp4 给用户看,风格/字号/语速/节奏在这里一次定稿。 @@ -107,6 +107,12 @@ node /scripts/render_video.mjs . # PNG 精渲 + 音轨 mux "$PY" /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`。 +> 判断口径:**开工前先看一眼「帧里有没有满幅照片」,有就换,别等渲了半小时才发现。** + ### 改一句文案之后 成片已经渲完,用户对某一场的**文案**(旁白/画面文字)有意见时,不必整片重渲: diff --git a/scripts/qc_check.py b/scripts/qc_check.py index c0cf563..798f9e0 100644 --- a/scripts/qc_check.py +++ b/scripts/qc_check.py @@ -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 比音轨多出 diff --git a/scripts/render_video.mjs b/scripts/render_video.mjs index 0b7249c..277220e 100644 --- a/scripts/render_video.mjs +++ b/scripts/render_video.mjs @@ -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 砍掉视频尾部。 diff --git a/scripts/tts_build.py b/scripts/tts_build.py index 98c221e..eab66e9 100644 --- a/scripts/tts_build.py +++ b/scripts/tts_build.py @@ -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 diff --git a/scripts/tts_volcano.py b/scripts/tts_volcano.py index d6f7e59..9dea0d0 100644 --- a/scripts/tts_volcano.py +++ b/scripts/tts_volcano.py @@ -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