Files
html-explainer/references/lessons.md
T
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

719 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 踩坑台账(lessons)
血统:上半部分继承自 anything2explainer 与 html-video-workbuddy-driver 的实战,
下半部分是本技能(确定性 seek 渲染)自己的记录。新经验 **持续追加**。
## 继承来的坑(已在技能设计里规避,但要知道原理)
1. **帧时长必须用 MP3 容器时长,不是 TTS 词边界时长**。词边界每段少算
~0.81–0.89s(LAME padding),5 段累积 4.3s 音画错位。本技能:tts_build
裁首尾后回填 `container_duration_sec`,timeline_build 按它排全局轴。
2. **两级时钟**:段长用容器时长(音画同步),末块字幕收尾用语音真实结束
(`speech_end_sec`)。混用 → 字幕比语音多停 0.8s/段。
3. **词边界字幕,绝不按字数插值**:中文同字数时长差 3 倍("AI" 0.71s vs "让" 0.17s)。
4. **edge-tts 7.x 必须显式 `boundary='WordBoundary'`**,默认 SentenceBoundary
整句一个事件;**相邻数字熔读**(沪深300+4479.55 → 三百万…),数字间补中文逗号。
硬超时 45s(websocket 会永久挂起零日志)+ 4 次指数退避(端点限流)+ 缓存 sig 含 boundary 与 trim。
5. **edge-tts 语速标定**:同一句话跨 RATE 对比,`dur×(1+rate)` 是常数 → 线性。
长片默认 +8%;1 分钟内高密度短片可到 +20%~+37%(听感优先,别只对齐字/秒数字)。
6. **字幕是屏幕文本**:去句读标点、子句连写;要断开用 `|` 再切一块,不是把标点加回来。
7. **每块字幕 ≥0.6s**(water-filling 按真实语速比例重分配,起点不动)。
8. **HTML 帧绝不引 Google Fonts**(国内不可达;实时录制系 6s/link 超时 → 成片被裁成
静止尾帧)。本技能用确定性 seek,风险从「毁片」降级为「5s 硬顶后用兜底字体」,
但字体不一致仍会让前后帧字重跳变 —— 契约照旧禁止。
9. **实时录制的 `__hvUnfreeze` 起播坑**(html-video):单文件帧没有 `__hvPlayAll`,
时间轴无人 play → 成片全空且零报错。本技能的 seek 渲染器**整类消除**此问题
(渲染器主动 seek,帧根本不需要起播逻辑)。
10. **字幕带禁区**:底部 80–170px 只给字幕;内容元素 `bottom < 170px` 一律抬到
176–210px。入场轨迹也不许穿带。
11. **中文文件名 + Windows ffmpeg**:UTF-8 按 ANSI 解析 → No such file。
项目目录、输出文件、抽帧全部 ASCII;中文名最后在资源管理器里改。
12. **Git Bash 的 `/tmp` ≠ Node 的 `/tmp`**(Windows 上 Node 解析成 `C:\tmp`);
给 Node/Python 传路径用 `C:/...` 正斜杠绝对路径。
13. **给 Python 传含正则/反斜杠的代码不要用 heredoc/-c**(反斜杠被静默吞),
写成 `.py` 文件再执行。
14. **QC 抽帧先看体积**:同片内「明显偏小」的帧 = 疑似空画面,比肉眼快得多。
⚠️ 这条继承自亮底主题的项目,**阈值不可当常数用** —— 见第 19 条(改自适应)。
附参考量级:亮底主题纯背景 ≈90KB / 有内容 220KB+;
暗底主题(本技能默认风格)纯背景 ≈20KB / 有内容 ≈35–60KB。
## 本技能自己的记录(持续追加)
15. **(2026-09-20 首版验证)seek 渲染确认**:`tl.pause(t, false)` 同步渲染 inline style,
`document.getAnimations().currentTime = t*1000` 同步 CSS keyframes;双 rAF 后
screenshot 能拿到正确的中间态(opacity 0→1 的半透明帧逐帧递增)。
(第二参 `false` 的必要性见 #27 —— 少了它,`onUpdate` 类回调全不触发。)
16. **playwright-core 无浏览器探测**:`chromium.launch()` 不带 executablePath 时不会
自动找系统 Chrome/Edge —— 必须显式传 `executablePath`(render_video.mjs 已内置探测链)。
17. **★ `page.evaluate` 的字符串陷阱(最隐蔽的静默失败,2026-09-20 定位)**:
`page.evaluate` 传**字符串**时,Playwright 只把它当**表达式求值一次**。
所以传 `"async (o) => { ... }"` 得到的是「一个箭头函数对象」而不是执行结果 ——
函数体**根本不执行**,返回值因不可序列化变 `undefined`,**零报错**。
症状:成片每帧都是静止首帧(只有纯 CSS 动画在动,GSAP 控制的元素停在 opacity:0)。
正确写法:把回调写成**真正的 JS 函数字面量**(不是模板字符串),
`await page.evaluate(frameSeek, { t, globalT, total })`。
排查手法:在 evaluate 里 `return` 一个探针值,看是否 undefined;或先试「内联箭头函数」
能跑通、换成字符串就挂 —— 立刻锁定此坑。
18. **`browser.launch({ viewport })` 不继承给 `newPage()`**:viewport 必须逐 page 指定
`browser.newPage({ viewport: { width: W, height: H } })`,否则成片静默变 1280×720。
19. **QC 体积阈值必须按主题底色自适应**:固定「<100KB = 空画面」在黑底稀疏文字主题上
会把全部合规帧判成假阳性(实测本片中位数仅 40KB)。改为**取本片抽帧体积中位数的
35%** 作可疑线(叠加 12KB 绝对下限兜底),跨亮底/暗底主题都稳。
真·空画面会明显掉到中位数 1/3 以下,仍能被抓住。
20. **(2026-09-20 补)画面风格库来自 html-video 的 23 个模板,不是「一种暗底风格」**。
务必先读 `references/style-catalog.md` 再动手 —— 8 个类别、12 个 rich 模板
(零改动可渲染)与 11 个 gsap 模板(需重写)。挑风格时同时核对**时长档**
(3–6s 的片花模板硬拉到 20s 会显得空)。
21. **html-video 模板分两种结构,改编成本差别很大**(2026-09-20 实测):
- **rich**:单文件 + 纯 CSS `@keyframes` 时间轴 + 带 `SKILL.md` 风格规范
(画布/字体/时间轴/配色纪律)。**seek 渲染器零改动即可驱动** ——
因为时间轴由 CSS `animation-delay` 声明,`document.getAnimations().currentTime`
可直接定位。实测 bold-signal 的 9 条动画全部可 seek(0.3s 只有编号、0.8s 卡在滑入中间态、
1.5s 全部到位 —— 逐帧确定性正确)。
- **gsap**:`index.html` + `compositions/*.html` 多组合 + CDN 加载 GSAP。
**不要搬代码** —— 单帧里没有起播钩子,搬进来会得到静止首帧且零报错
(就是 #9 的 `__hvUnfreeze` 坑)。只取视觉 DNA(配色/版式/动效意图),
用 CSS keyframes 重新表达。
判定:`grep -c '@keyframes'` + 是否有 `data-composition-src` / `compositions/*.html`。
22. **改编 rich 模板必做三步**(漏一步就出问题):
① **删 Google Fonts 换系统字体栈** —— 模板原文一律外链 Google Fonts,
离线环境下每 link 5s 超时且字体静默回退,前后帧字重会跳变(详见 #8)。
② **底部元素抬出字幕带** —— 模板不知道自己会被叠字幕,`bottom:74~104px` 的元素
会跟硬字幕压在一起(#10)。一律抬到 `bottom:176px` 以上。
③ **填真实内容** —— 照模板 `example.md` 的字段填,严禁 lorem ipsum。
实测:改编 bold-signal / build-minimal / pentagram-stat 三帧混排成片,
509 帧 / 16.97s、音画差 0.04s、音量 −24.7dB 与源一致、QC 全过,三种风格视觉完全成型。
23. **一个项目可以混排多种模板风格,且这是推荐做法** ——
8 个场景全用同一个模板会很单调。建议 2–4 种风格轮换
(如:开场用 bold-signal 强冲击 → 论述用 build-minimal 留白 →
数据用 pentagram-stat 理性)。字幕层与进度条由渲染器统一注入,
跨风格视觉一致,各帧无需自管。
24. **风格目录是可再生成的,不要手改**:`scripts/import_styles.py --from <html-video>/templates`
会重写 `references/style-catalog.md` + `.json`。该脚本能处理两种模板布局
(`source/index.html` 与 `index.html`),且 YAML 解析支持 `>`/`|` 块标量与任意层级缩进
(`output.duration.min_sec`)—— 一开始写的浅层解析器漏了 duration 和 description,注意别退回。
**它是「抄设计规范」,不是代码依赖**:跑完一次,技能就永久拥有风格知识,
之后做视频不需要 html-video 存在(类比:把菜谱抄回家,做菜不需要原餐厅营业)。
零依赖验证手法:`grep -rn "html-video" scripts/ assets/ references/` ——
只应命中注释署名,**不应命中 `import` / `readFile` / `subprocess`**。
25. **(2026-09-20)封面必须两套独立排版,绝不能用裁切**:
成片 16:9,平台主页栅格按 3:4 裁 → 从 1920×1080 居中裁 3:4 只剩 **810px 宽,
丢掉 57.8% 的画面宽度**,横跨全宽的大字钩子必然被切。
正确做法:`frames/cover_169.html`(1920×1080)+ `frames/cover_34.html`(1440×1080)
两份 HTML 各自排版,共享同一套视觉基因(配色/幕底/主视觉/钩子文案)。
重排的核心动作是**改变构型**,不只是改间距:
悖论视觉从「右侧左右并置」→「上方竖排堆叠」;钩子从「左下两行」→「下方三行」。
实测踩到的两个坑(都已修):
· 钩子每行字数是硬约束 —— 16:9 版 ≤7 字/行、3:4 版 ≤5 字/行。
超了会出现孤字断行(「错在哪」的「哪」单独成行),极难看。
**可反算**:行宽 ≈ 字数 × 字号 × (1 + letter-spacing),必须 ≤ 容器宽,
否则浏览器在任意字间断行。所以字号上限 = 容器宽 ÷ (最长行字数 × (1+ls))。
例:16:9 钩子第 2 行 7 字、ls=-0.022、容器 920px → 字号 ≤ 920/(7×0.978) ≈ 134,
取 126 留余量(实测:134px/880px 时末行出孤字,改 126/920 后通过)。
· 两份的底部元素位置**必须各自算**。3:4 版钩子占位更满,
直接沿用 16:9 的 `bottom:104px` 会跟末行大字重叠(实测撞了)。
3:4 版钩子 118px 三行时,角标必须压到 `bottom:52px`。
26. **封面渲染器(`cover_build.mjs`)复用场景渲染器底座,三处差异**:
① 不注入字幕层/进度条(封面不要字幕)→ 底部**无禁区**,但仍留 ≥96px 平台安全边距
② 不逐帧,只 seek 一个「封面时刻」:默认 `--at last`(`tl.duration()`,取完整态);
`--at 0.8` 可挑入场动画中间态(更有张力,适合动感题材)
③ 输出单张 PNG,默认 `--scale 2` 出 2 倍图(3840×2160 / 2880×2160)应付平台压缩
封面帧**允许纯静态**(没有 `window.__tl` 也不报错)——比场景帧宽松,
因为有的封面就是一张静图。但仍建议挂一条 1.2s 的短时间轴,好在 `--at` 时点微调。
27. **★★(2026-09-20 实测确诊)`tl.pause(t)` 会掐掉本次 seek 的回调 —— 数字滚动/末态回调全失效**:
GSAP 签名是 `pause(atTime, suppressEvents)`,**第二参默认 `true`**,含义是「本次跳转触发的
`onStart`/`onUpdate`/`onComplete` 一律不触发」。于是最自然的写法
```js
var p = {v:0}; tl.to(p, {v:100, duration:2, ease:'none',
onUpdate:function(){ el.textContent = Math.round(p.v); }});
```
在渲染器下 **`el.textContent` 永远是 `0`** —— 补间本身算到了 100,但写 DOM 的那行从不执行。
症状极隐蔽:不报错、截图照写、体积正常、成片只有「数字不动」,静态合规检查也查不出来。
**判定证据**(用一个探针脚本 `.mjs` 复现与渲染器逐字一致的 seek):
| 写法 | seek 后 `#a` 的值 | 结论 |
|---|---|---|
| `onUpdate` + `pause(t)` | `"0"` ❌ | 回调被抑制 |
| `onUpdate` + `pause(t, false)` | `"100"` ✅ | 确诊 = suppressEvents |
| `transform` + `pause(t)` | 位移到位 ✅ | 不依赖回调,稳 |
**两处修法**(本技能采用①+②双保险):
① 渲染器/封面器改 `tl.pause(t, false)`(根因修复,一次性生效于所有帧)
② 帧里做数字滚动改用**纯 transform 的「数字卷轴」**(不依赖任何回调,任何 seek 方式都成立):
外层 `.strip{overflow:hidden}`,内层 `.val{display:block}`(inline 元素不吃 transform),
`tl.fromTo('.strip .val', {y:0}, {y:-steps*ITEM, duration:1.4, ease:'power2.out'})`。
**顺带结论**:`tl.pause()` 无参时不影响;但凡是「seek 到某时刻并期望伴随副作用」的写法,
都要显式传 `false`。**注意**:放行回调意味着 `onUpdate` 每帧都会跑一次,帧里写 `onUpdate`
必须保证幂等(只赋值、不 append DOM),否则整片几千帧会累积出灾难。
28. **(2026-09-20 补)#18 的孪生坑:`deviceScaleFactor` 传给 `chromium.launch()` 会被静默忽略**:
`viewport` / `deviceScaleFactor` 都是 **context 级**选项,只能给 `newPage()`(或 `newContext()`)。
传给 `launch()` 不报错、不生效 → 封面出成 1 倍图(1920×1080 而非承诺的 3840×2160),
日志还自欺写着「输出倍率 2x」。**自查手法:渲完立刻 `PIL.Image.open(...).size` 核尺寸,
别信日志。** 修复:`newPage({ viewport, deviceScaleFactor })` + `screenshot({ scale: 'device' })`。
29. **(2026-09-20 补)QC 抽帧曾只覆盖前 ~1/4 片**:`qc_check.py` 按「字幕块起点」抽样,
但写成 `pts[:samples]` = **取前 N 块**。一条 39 块的实测片取 12 → 只抽 0.25–23.86s,
后半段 6 个场景一帧没抽,「全部通过」是假的放心。
修法:块数多于样本数时**按步长均匀挑**(`pts[int(i*len/N)]`),既覆盖全片又仍落在字幕边界。
**教训:抽样逻辑写完要 sanity check 一次「最后一个样本的时间 ≈ 片尾」**
30. **(2026-09-20 补)封面排版验收要量终态、不量中间态**:页内脚本若写了
`if (!__MG_RENDER__) tl.play(0)` 自动起播,则**直接 load 完就量**得到的是 `fromTo` 的
起始值(`.foot` 会多出 +16px 之类的假偏移,`.paradox` 会 -17px)。
验收脚本必须先 `addInitScript('window.__MG_RENDER__=true')` 再 `tl.pause(tl.duration(), false)`,
与 cover_build 的 seek 完全一致 —— 否则你会去「修」一个根本不存在的布局 bug。
附:写一个十几行的终态测量脚本即可(行数 / 四边边距 / 元素重叠 / 钩子字号全判)。
31. **(2026-09-20 补,v1.2.2)`speech_end_sec` 在「首跑(现合成)」与「重跑(走缓存)」下不一致**:
`_result()` 里量的是**已经裁过首尾**的 `out_path`(`trim_edges` 原地替换过),
所以 `speech_window()` 返回的 `de` **已经在裁后坐标系里**,原代码又写了
`"speech_end_sec": de - head_cut` → **重复扣一次首裁量**。
症状:同一段解说,首跑报 `语音止 7.541s`、重跑(缓存命中走 `head_cut=0.0` 分支)报 `7.696s`,
差 0.155s = 首裁量;末块字幕提前消失,且**「跑两遍逐帧一致」的承诺被打破**。
修法:`"speech_end_sec": de if trimmed else round(dur, 3)`(`de` 已是裁后坐标,不再减)。
**自查手法**:连跑两次 `tts_build.py`,逐段比对 `speech_end_sec` —— 两次必须完全相同。
另注:`container_duration_sec`(帧长口径)与 `boundaries`(词边界,原始坐标)都不受影响,
所以这个 bug **只影响末块字幕收尾与 `__SEG__.speech_end`**,不会造成音画漂移。
32. **★(2026-09-21 实测)`--preview` 渲完会删帧 —— 想留 PNG 必须显式 `--keep-frames`**:
`render_video.mjs` 尾部有
```js
if (!args.keepFrames && preview != null) { /* 遍历 framesDir 逐个 unlinkSync */ }
```
设计意图是「预览模式顺手清草稿帧省盘」,但**它对已经合成完、正准备复用的帧一视同仁地删掉**。
本次踩法:为「只重渲 outro 一段」而用 `--preview 15.1` 跑临时项目,成片正常产出、
日志一切正常,**帧目录被清掉了 49 张**(第 50 张被宿主的安全删除守卫拦下才暴露)。
症状极隐蔽:`✓ 场景 outro:453 帧` 的日志是真的,帧也确实渲过,只是结尾被删了。
**规则:只要帧还要用于「拼接 / QC / 封面 / 复核」,一律加 `--keep-frames`。**
另注:完整渲(不带 `--preview`)不触发这段清理,所以「全片重渲」反而比「局部重渲」安全。
33. **★(2026-09-21 新增工作流)改单场画面后「只补渲这一场」的标准做法 —— 临时项目法**:
`render_video.mjs` 的 `order` 就是全部场景,改一帧就要全片重渲(一条实测片 3802 帧 ≈ 15 分钟)。
**改的是末场时优先用 `--only <场景id>`(见第 45 条),比本法省事**;本法适用于
**被改场景不在末位**、或需要精确控制全局帧号的情形。
用法是用临时项目精确复现该场景的**全局帧号**,渲完贴回主序列:
1. 建 `<项目>/render/_fix/`,拷进 `theme.css`、`assets/gsap.min.js`、
`frames/<id>.html`、`frames/<id>.beats.js`(帧里的 `../theme.css` / `../assets/` 相对路径靠目录深度对齐);
2. 写 `project.json`(`order` 只放目标场景,其余字段照抄主项目,**`gap` 必须一致**)
+ `layout.json`(只放目标场景的 `start_sec/duration_sec`);
3. **帧号对齐靠两个数**:主序列里该场景的 `gf = round(start_sec × fps)`,
场景帧数 `n = min(总帧数, round((start_sec+duration+gap)×fps)) − gf`。
`n` 比 `round(duration×fps)` 大(末尾那段 gap 被并入前一场景),
所以临时项目里要么加一个 **`duration_sec: 0` 的占位场景**凑出同样的 `totalFrames`,
要么用 `--preview <n/fps>` 直接把帧数卡到 `n`;
4. 渲完(**带 `--keep-frames`**)把 `f_000001..f_0000nn` 贴成主序列的 `f_<gf+1>..f_<gf+n>`;
5. 用主项目的 `ffmpeg` 口径重合成(见下),再跑 `qc_check.py`。
本片实测:hook 291 帧 + outro 453 帧≈ 90s+130s,相比全片重渲 924s 省掉约 12 分钟。
**主序列的合成命令**(与 `render_video.mjs` 逐字一致):
`-framerate 30 -i f_%06d.png -i audio/narration-full.mp3 -c:v libx264 -preset medium -crf 18
-pix_fmt yuv420p -movflags +faststart -c:a aac -b:a 160k -af apad -t <renderSec>`
> **别用 `-shortest`。** 它按音轨长度收尾,而 layout 的 `video_duration_sec` 比音轨多出
> 一段片尾留白(末块字幕的收尾余韵),`-shortest` 会把这段留白连同最后一块字幕一起切掉。
> 正解是 `-af apad -t <renderSec>`:音轨不足处补静音,成片长度由 layout 唯一决定。
34. **★(2026-09-21)冷开场:短视频前 0.5 秒必须有主体,`opacity` 淡入是最慢的入场**:
本片 hook 初版把三行巨型标题放在 `t1(1.154s,第一句解说词起播)`,配 `power3.out` 0.72s 淡入。
抽帧一看 **0.1s / 0.5s / 1.0s 三帧几乎全黑**(只有角标 + 日期戳),1.4s 才有字 —— 信息流里这是硬伤。
两个改法叠加即可:① 入场起点从「第一句解说词」提前到 **`t0 + 0/0.14/0.28`**(`t0` = 首字起播),
别再等解说词;② 缓动换 **`power4.out` + 0.5s**(起手极快,0.15s 就走完七成),
`opacity` 淡入退居次要 —— **遮罩上推(`overflow:hidden` + `y:104%→0`)本身就提供遮挡,
不需要再叠一层从 0 开始的透明度**,叠了就会让首帧必然全黑。
修后实测:0.10s 已能看到字头、0.35s 两行到位、0.70s 三行齐 —— 首帧体积也从 28KB 升到 37KB,
`qc_check.py` 的抽帧体积列可以直接当「冷开场是否空屏」的回归指标用。
附:`peek_frame.mjs` 的 `--at` 是**百分比**不是帧号(`--at 2,5,10` = 2%/5%/10% 轴长),
查冷开场要传 `--at 1,3,6` 这种小的百分数。
35. **★(2026-09-21)入场动画有「截止时刻」,不是「起始时刻」——挂在最后一句上的收尾元素等于没出现**:
介绍片的 outro 结尾有 3 个 chip(`MIT © 2026 Moh` / `v1.2.2` / `23 种风格`),
挂在了最后一拍 `B('去试试吧') = 5.763s`,而该场景帧长 6.53s、成片实际收在 107.65s
—— 留给它们的只有 **0.35s**(还要叠 0.07s×2 的 stagger)。抽帧看:整片结束前它们只露出半个头。
病根是把「这句解说词在说什么」当成了动画时点,而**帧尾还有 `tail`(约 0.15s)+ 音轨与视频
的 0.4s 差**,最后一拍的落点几乎贴着片尾。
**规则:任何入场动画的最晚触发点应 ≥1.2s 早于 `speech_end`**;越靠后的元素越要挂到
前一句(本片改挂 `B('仓库地址在简介里') + 0.45`,获得 1.45s 展示时间)。
想在最后一句「再给一次响应」而不占用时间,就用**已经在场元素**的微动作(如 `.repo` 的
`scale 1 → 1.03 → 1`),不要引入新元素。
**★ 配套的验收方法(比 `qc_check` 的默认抽帧更狠)**:`qc_check.py` 只均匀抽 12 帧,
本片最后一个样本落在 **92%** 处(99.55s / 107.65s)——片尾三秒根本没被看到。
补一招:**按 `layout.json` 算出每个场景 `speech_end` 前 0.15s 的全局时刻,
逐点抽帧拼成一张 contact sheet,一次过 11 个场景的「终态」**。本片 11 帧一次过,
只有 outro 露出问题。命令形状(ffmpeg 走 `imageio_ffmpeg`):
```bash
FF=$(python -c "import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())")
for t in 6.9 17.8 31.2 44.5 51.9 60.7 71.8 79.6 89.6 100.6 107.35; do
"$FF" -y -loglevel error -ss $t -i out/<slug>.mp4 -frames:v 1 "render/end_check/e$t.png"
done # 再用 PIL 拼成一张 4 列 contact sheet 肉眼看
```
36. **(2026-09-21)多 agent 并行建帧前,先落一份 `frames/kit.css` 共享视觉基座**:
本片 11 个场景、3 个并行 agent,**`lint_frames.py` 一次过、零违规、跨帧风格统一**,
与前几片动辄返工形成对比。做法:把幕底(`.bg`/`.grid`)、章节角标(`.sec`)、
卡片(`.card` / `.card--focus` / `.card--dim`)、文字层级(`.h1/.h2/.h3/.body/.tiny/.mono`)、
配件(`.chip/.tag/.rule`)、禁区之上的信息条(`.foot`,`bottom:186px`)、
媒体卡(`.shot`)一次写死,帧文件只引用 class。要点:
- `kit.css` **不是**第四份契约文件,它和 `theme.css` 一样是共用层 —— 帧里
`<link ../theme.css>` 之后紧跟 `<link kit.css>`;**`lint_frames.py` 只扫 `frames/*.html`,
不扫 `kit.css`,所以共享层里更要自律**(本片 `kit.css` 同样只用 theme 变量)。
- 派 agent 的 prompt 里把「底对齐用 `top` 定位、内容 `bottom ≥ 180px`、禁色值字面量、
`B()` 取 `frames/<id>.beats.js` 原文」写成硬要求 + 违反即返工,比事后 lint 返工便宜得多。
- agent 会自行补「居中章节标题」等版式细节 —— 只要风格统一就放行,别逐帧纠偏。
**配额提醒**:本片一次全片重渲 3242 帧花了 780s;而上面第 33 条已经写好了
「临时项目法按全局帧号补渲再贴回」的完整流程(约省 12 分钟)。
**发现只有一两个场景要改时,先翻第 33 条,不要直接全片重渲。**
37. **(2026-09-22)帧内的「统一生长」辅助函数必须用 `querySelectorAll`。**
常见写法 `function grow(sel, t){ var el = document.querySelector(sel + ' .bar'); ... }`
传单个 id(`'#s1'`)没问题,但传**多匹配选择器**(如 `'.row:not([id])'`)时
只会动到第一个元素 —— 其余柱子永远停在 `width:0`。
**症状极具迷惑性**:零报错、零 lint 违规、帧也不静止,只是「有几根柱子丢了」。
正解:`querySelectorAll(sel)` + 逐项 `offset`,`tl.fromTo()` 在循环内各自注册。
→ 凡是「一组元素一起动」的辅助函数,一律 `All` + `for`,不要图省事。
38. **绝对定位元素的 `top` 是相对**它自己的定位框**,不是相对页面。**
compare 帧要把徽标放到「双卡下方」,直接写 `top:384px` → 实际落在
`容器 top(196) + 384 = 580`,跑进了下一区块并压住柱状图。
正确值 = `容器高度 + 留白`(本例 `176 + 6 = 182px`)。
→ 给绝对定位子元素写坐标前,**先确认它挂在哪个定位祖先上**;
`peek_frame` 出图后拿实际像素反查一次最稳。
39. **数字卷轴(odometer)在 seek 渲染下的可靠写法。**
第 9 条说「数字滚动用纯 transform 数字卷轴」,这里给出可直接抄的实现:
```html
<span class="reel"><b class="rack"><i>0</i><i>1</i>…<i>9</i></b></span>
```
```css
.reel { display:inline-block; height:1em; overflow:hidden; }
.reel .rack { display:block; }
.reel .rack i { display:block; height:1em; line-height:1em; font-style:normal; }
```
```js
tl.fromTo(rack, { y:'0em' }, { y:'-3em', duration:1.5, ease:'power2.out' }, t);
```
要点:① 每位数字一个 `.reel`,`y` 走到 `-N em` 即显示数字 N;
② **完全不用 `onUpdate` 回调**,因此不受 `tl.pause(t, true)` 抑制影响(对比第 27 条);
③ 外层数字用 `display:flex; align-items:flex-start` 保证多位等齐,
货币符号等窄字用 `align-self:center` + 小字号,别混进 rack。
本片 liverl 的 30 / 347 / 75 三个数都靠它,终态逐位正确。
40. **暗底上的光晕别写 `rgba()` 字面量。**
`box-shadow: 0 0 40px rgba(214,137,81,.45)` 会被 `lint_frames.py` 按契约 3 拦下。
直接 `box-shadow: 0 0 40px var(--signal)` / `var(--accent-glow)` 即可 ——
theme 变量本身就是带 alpha 的色,效果一样且换肤自动跟随。
41. **`peek_frame --at` 是百分比,判「元素缺失」前先换算成秒。**
`--at 92` ≈ `时长 × 0.92`。本片 models 的 `20×`、perf 的底部结论条、
compare 的 `1/20 ~ 1/60` 都在 peek 时点之后才入场 ——
看到「应该有的东西没有」时,先 `时长 × 百分比` 算一下时点,再决定要不要改版。
想复核终态直接给 `--at 99`。
42. **亮底「编辑插页」是最好用的节奏工具。**
全片 10 场景里只把 2 个纯数据场景(排名柱图、差距图)做成亮底
(`--paper` + `--ink` + `--accent-on-paper`),其余全暗底 ——
比「全亮」或「全暗」都更抓人,且换肤只动 `theme.css` 一处。
注意:亮底帧**必须自己铺 `--paper` 背景**,否则会露出暗色幕底。
43. **★ 「A 或者不 A」不是选择,是同义反复 —— 结尾最常见的废句。**
真实案例:结尾三行写成 `结婚或者不结婚` / `买房或者不买房` / `留在城市,或者回到县城`,
观众读完的反馈是「说了跟没说一样」。**因为它的信息量严格为零**:
每条都穷尽了全部可能性,等于什么都没说,只是把句法凑得像选项。
同类废句还有两种,一起拉黑:
| 废句类型 | 例子 | 为什么废 |
|---|---|---|
| 同义反复 | `结婚或者不结婚` | 穷尽全部可能 = 零信息 |
| 自指复读 | `这就是一个普通人的一生` | 把标题当结论念了一遍 |
| 鸡汤留白 | `没有人知道你会走到哪里,包括你自己` | 听起来像总结,其实没有指向任何事 |
**正确写法:不总结、不升华、不判断,只把「还没有结果的具体的事」按它本来的样子列出来。**
落点必须是**可核查的具体物**(一条没回的消息 / 一栏空着的三方协议 / 下周的复查),
而不是处境的名字。判据一句话:**把这句话里的名词换成任何其他片子也成立,就是废句。**
结构上让它成立的做法是**前面埋钩子、结尾不回收**——
全片早段写过「还有一个,在等消息」,结尾不是给答案,而是让这条消息**仍然悬着**。
配套的画面写法(比文字更省事):把结尾做成一页**勾选框全空的待办清单**
(`☐ 那条消息 ⋯⋯ 未回`),每行随念到它的那一句落下,一条都不打勾。
「未完成」不用靠形容词,靠空框本身。
44. **极简帧里同一信息只准出现一次。**
上一条的同一个镜头里,全片固定的左下角日期戳是 `2026 · 你 21 岁`,
右栏又放了一个 `.min-age` 写着同一句 —— peek 出图才发现画面上有两处一模一样的字。
极简帧元素少,重复格外显眼。定稿前**通读一遍画面上所有文字**(含全局固定元素:
左上系列标、左下日期、右下场次),逐个确认没有同义重复。同类:右上小标题与场次标记撞词。
45. **`--only <场景id>` 局部重渲的前提,和它的必然后续。**
改一句旁白不必重渲整片(本片 14 场 6194 帧,全渲 20 分钟,重渲末场 78 秒):
```bash
node scripts/render_video.mjs . --only <场景id> # 只重渲这一场
node scripts/render_video.mjs . --mux-only # 用现有帧重新合成
```
两个前提,缺一个就会出错:
① **只有末场安全**。脚本会提示被改场景是否为末场;非末场一旦时长变化,
其后所有场景的帧号整体前移,必须整片重渲。
② **变短之后必须清掉尾部过期帧**,否则 ffmpeg 会把旧帧一起编进去,成片比 layout 长。
先算出新总帧数(`round((Σduration + gap×(n-1)) × fps)`)再删:
```python
for f in (FRAMES := pathlib.Path('render/frames')).iterdir():
if f.name.startswith('f_') and int(f.name[2:8]) > TOTAL: f.unlink()
```
验收口径(别只看渲染日志的「✓ 成片」):**帧号连续无缺口 + 成片时长 == layout 总长**。
本片收尾实测 6194 帧、0 缺帧、`Duration 00:03:26.47` 与 layout 的 206.47s 逐位一致。
46. **★(2026-09-23)「用户需要中断流程去手工做一件事」时,那个选择必须先落盘。**
加火山 TTS 时踩的:向导让用户选方案 → 选火山 → 生成 `tts.env` 模板让用户去填密钥 →
**此时 `project.json` 还没写 `provider`**(原本要等全部步骤走完才写)→ 用户填完回来跑
`--check`,脚本从 `project.json` 读到默认的 `edge`,于是**静默去测 edge 并报「✓ 就绪」**。
用户会以为密钥配好了。
规则:**只要流程中间存在「需要用户离开去手工完成」的断点,断点之前已经确定的状态就要立刻持久化**。
顺带把「我测的是哪个」显式打印出来(`测试方案:volcano`)—— 加了这一行,同类错位就不可能再静默。
更一般的判据:**任何带默认值的回退,都要问一句「这个默认值在什么情况下会冒充用户的真实选择」**。
47. **密钥只能用「单点接触」的接口包封装,且默认值不能替用户做安全决定。**
本技能的密钥纪律是「agent 不读、仓库不记、日志不吐、分发不含」。让它成立的**不是自觉,
是结构**:密钥只存在 `tts.env`,**只有 `tts_volcano.py` 读它**,agent 只调这个包。
四条各有对应机制:
- 不读 → 接口包自己读;agent 想知道状态就跑 `--status`(输出脱敏摘要)
- 不记 → `.gitignore` + `check_integrity.py` 的密钥防线检查(遍历仓库抓非空赋值与 `AKLT…`)
- 不吐 → 异常过 `_redact()`。**脱敏规则别写成「长字符串就当密钥」**:那会把 reqid/logid
一起抹掉,排查时没有定位线索。只抹「本次实际加载的密钥值」+ 明确形态(`AKLT…`)即可,
因为密钥只可能从这两个来源进入输出。
- 不含 → `package_skill.py` 排除密钥文件(见 #48)
**必须对用户说清的实话**:合成代码是 agent 在本机执行的,运行时进程里密钥对脚本可见,
「agent 绝对读不到」做不到。能保证的是**不进对话、不进仓库、不进日志、不进分发**,
再加**最小权限子账号 + 用量告警 + 可随时轮换**把泄露半径收敛成一次可撤销的事故。
★ 附实证:**环境变量比凭据文件更不安全** —— 本机 `env` 的输出会被工具结果带进会话记录
(早前 `env | grep proxy` 连 MCP 的 bearer token 都打出来过)。要存密钥就用
系统凭据管理器或仓库外的文件,**别用环境变量**。
48. **加了新的敏感文件类型,要同时检查「打包/分发」与「gitignore」两条路 —— 它们不共享规则。**
`package_skill.py` 的排除表原先只有仓库卫生文件,**没有 `.env`**。
也就是说:技能目录里一旦放了 `tts.env`,`package_skill.py` 会把它打进可分发的 zip,
用户把包转给别人 = 把 API Key 一起发出去。gitignore 挡不住这条(打包不看 gitignore)。
修完必须**实测**而不是只看代码:往技能目录放一个假 `tts.env`,打包,
再开 zip 列表 + 对每个成员做**内容级**扫描(`b'AKLT...' in z.read(n)`)。
验收三条:密钥文件不在包里、模板 `tts.env.example` 仍在包里、内容扫描无命中。
49. **「非 0 的状态码 = 错误」是错的 —— 先查清楚协议里的成功/结束码各是什么。**
火山 V3 单向流式的响应是 NDJSON,音频块 `code=0`,而**结束标记是 `code=20000000` 且 `data=null`**。
社区记录里有人只判 `code===0` 收音频,症状是「合并出来的音频末尾嘎然而止」——**最后一段丢了**,
而 HTTP 200、日志一切正常。本包按 `code` 显式区分三种:`0` 收音频 / `20000000` 结束 /
其他抛错,并且**离线断言过**(本地假服务端按官方响应结构回包,断言音频字节数、
时间戳换算、请求头与请求体字段)。这类「协议细节只听文档会错」的地方,
花十分钟搭个假服务端做断言,比拿真 key 反复试便宜得多。
50. **对外接口的「同构」是省事的关键 —— 适配新引擎时先看能不能让下游零改动。**
火山 TTS 有五种接入方式(WS 双向流式 / WS 单向流式 / HTTP Chunked / SSE / 已弃用的 v1),
选的是 **HTTP Chunked 单向流式**,唯一理由是它和 edge-tts **形态同构**:发一次文本、收一串音频。
于是缓存 / 硬超时 / 退避重试 / 裁静音 / 两级时钟**一行没改**,
两引擎产出的 manifest 结构完全一致,`timeline_build.py` / `subs.py` / 渲染器零改动。
双向流式是为 LLM 实时对话设计的(可打断、多轮 session 状态机),离线出片要它纯属负担。
★ 判断依据要落在**数据契约**上而不只是传输方式:这里真正的关键不是「怎么发」,
而是「词级时间戳拿不拿得到」——火山 `sentence.words[]` 给字级 `startTime`/`endTime`(秒),
正好等价于 edge 的 `WordBoundary`,**不需要「强制对齐」那一步**
(注意:**前提是显式开 `audio_params.enable_subtitle`**,见第 51 条)。
接新引擎前先确认这一点;拿不到词级时间戳就**别接**——按字数插值是这个技能明确禁止的退化。
51. **★(2026-09-23)官方文档示例里「有值」的字段,不等于你不传参数它就会返回。**
火山文档的响应示例里 `sentence.words[]` 带着完整的字级时间戳,很容易让人以为「默认就给」。
实测:**不传 `audio_params.enable_subtitle: true`,`words` 恒为空数组**(同文本对比
false→`words=0`,true→`words=10`)。这个坑的可怕之处在于**它完全不报错**:
音频正常、时长正常、`sentence.text` 也正常,只有时间戳是空的。
下游会静默退化——`subs.py` 发现没有词边界就**按字数插值**(只打一行 stderr 警告),
成片照样能出,字幕却开始飘。**判据:凡是「文档示例里看着有值」的字段,
都要用一个显式关掉参数的对照组实测一次**,别接受单边证据。
顺带:该开关仅豆包 2.0 的中英文音色支持,所以本包在拿到音频却没有时间戳时
会往结果里塞 `warning`,让 `tts_build.py` 能把它打到台面上,而不是安静地飘字幕。
52. **计费/统计字段常挂在「流结束」那一行上 —— 别在结束标记处提前 `continue`。**
同一个坑的第二层:`usage.text_words` 就挂在 `code=20000000`(结束标记)那一行。
解析循环写成「先判结束标记 → `continue` → 再读其他字段」,
结果**计费字数永远是 0**,而请求本身完全正常。加上 `X-Control-Require-Usage-Tokens-Return: *`
这个头之后依然读到 0 —— 因为问题不在服务端给不给,而在**我们把那一行跳过了**。
症状极易被误判成「是不是免费额度?」。**顺序原则:一行的所有字段先全部读完,
再决定这行要不要跳过它的「主语义」**。修完实测:`计费 20 字` 与文本长度逐字对上。
53. **缓存里必须连「裁剪量」一起存 —— 否则首跑与重跑产出的 manifest 不一致。**
词边界是**原始坐标系**(合成完那一刻的时间),下游 `subs.py` 靠 `lead_cut_sec` 平移。
缓存只存了边界数组、命中缓存时把首裁量当 0,于是**重跑的 `lead_cut_sec=0`、首跑是 0.037**
——字幕整体晚一帧。这类 bug 只在「第二次跑」出现,而第二次跑恰恰是最常发生的场景。
修法:缓存改成 `{"boundaries": [...], "lead_cut_sec": x}`,并对旧版**裸列表**格式做兼容读取。
验收必须是**深度 diff**(见 55)。
54. **★ 别把仓库里的 `*.example` 模板「改名」成真文件 —— 模板会消失,而其他用户再也配不起来。**
实证:用户把 `tts.env.example` 改名成 `tts.env` 去填 key。功能上没问题(gitignore 也确实
挡住了它入库),但**仓库里少了模板**,`check_integrity.py` 的密钥防线当场报错。
这类错之所以常见,是因为「复制」和「改名」在资源管理器里只差一个字。
**两道改进**:① 模板开头直接写清「请复制,不要改名」;
② 校验脚本在「缺模板」时**再看一眼同目录有没有真文件**,有就直说「疑似改名」——
错误信息能指向原因,比只报「缺文件」有用得多。
55. **★ 验证脚本本身要先 sanity check ——「没发现差异」可能是比较器根本没比到。**
验证「首跑 vs 重跑一致」时,第一版 diff 把 `items` 这个**列表**当成不透明值
(只记 `<list 3>`),于是 982 个字段里一个都没深入比较,第二轮打印出
「✓ 两次 manifest 完全一致」。而同一时刻终端上明明显示首裁 `0.037s` vs `0.000s`。
**两个互相矛盾的证据同时出现时,先怀疑那个「更省事」的结论**。
改成递归进入列表后,立刻暴露 3 处不一致。
★ 通用规则:**验证器要能被证伪** —— 先拿一个已知有差异的输入试一次,
看它会不会报差异;会报,才有资格说「一致」。
56. **「名称 / 序号 / 原始 ID」的解析必须收在接口包里,不能让调用方各写一份。**
火山 CLI 的 `--voice "云舟 2.0"` 把中文显示名**原样当成 speaker 发出去**,
服务端拒了,报的是 `55000000 resource ID is mismatched with speaker related resource`
——完全看不出「其实是名字没解析」。修法是把 `resolve_voice_token()` 放进接口包,
`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` 清,让渲染器原地覆盖帧号即可(帧号是全片唯一的)。