修复 - 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 段补齐 + 版本徽章同步。
719 lines
59 KiB
Markdown
719 lines
59 KiB
Markdown
# 踩坑台账(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` 清,让渲染器原地覆盖帧号即可(帧号是全片唯一的)。
|