Files
html-explainer/references/lessons.md
T
MohZhuang 0d74a1097d feat(v1.3.0): 新增火山引擎语音合成 2.0 作为可选配音引擎
流水线开跑前先问用户用哪个 TTS。两引擎产出的 audio-manifest.json
结构完全一致,故 timeline_build / subs / 渲染器零改动。

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

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

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

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

40 KiB
Raw Blame History

踩坑台账(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。

本技能自己的记录(持续追加)

  1. (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 类回调全不触发。)

  2. playwright-core 无浏览器探测:chromium.launch() 不带 executablePath 时不会 自动找系统 Chrome/Edge —— 必须显式传 executablePath(render_video.mjs 已内置探测链)。

  3. ★ 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;或先试「内联箭头函数」 能跑通、换成字符串就挂 —— 立刻锁定此坑。

  4. browser.launch({ viewport }) 不继承给 newPage():viewport 必须逐 page 指定 browser.newPage({ viewport: { width: W, height: H } }),否则成片静默变 1280×720。

  5. QC 体积阈值必须按主题底色自适应:固定「<100KB = 空画面」在黑底稀疏文字主题上 会把全部合规帧判成假阳性(实测本片中位数仅 40KB)。改为取本片抽帧体积中位数的 35% 作可疑线(叠加 12KB 绝对下限兜底),跨亮底/暗底主题都稳。 真·空画面会明显掉到中位数 1/3 以下,仍能被抓住。

  6. (2026-09-20 补)画面风格库来自 html-video 的 23 个模板,不是「一种暗底风格」。 务必先读 references/style-catalog.md 再动手 —— 8 个类别、12 个 rich 模板 (零改动可渲染)与 11 个 gsap 模板(需重写)。挑风格时同时核对时长档 (3–6s 的片花模板硬拉到 20s 会显得空)。

  7. 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。
  8. 改编 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 全过,三种风格视觉完全成型。

  9. 一个项目可以混排多种模板风格,且这是推荐做法 —— 8 个场景全用同一个模板会很单调。建议 2–4 种风格轮换 (如:开场用 bold-signal 强冲击 → 论述用 build-minimal 留白 → 数据用 pentagram-stat 理性)。字幕层与进度条由渲染器统一注入, 跨风格视觉一致,各帧无需自管。

  10. 风格目录是可再生成的,不要手改: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。

  11. (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。

  12. 封面渲染器(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 时点微调。

  13. ★★(2026-09-20 实测确诊)tl.pause(t) 会掐掉本次 seek 的回调 —— 数字滚动/末态回调全失效: GSAP 签名是 pause(atTime, suppressEvents),第二参默认 true,含义是「本次跳转触发的 onStart/onUpdate/onComplete 一律不触发」。于是最自然的写法

    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),否则整片几千帧会累积出灾难。

  14. (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' })。

  15. (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 一次「最后一个样本的时间 ≈ 片尾」

  16. (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。 附:写一个十几行的终态测量脚本即可(行数 / 四边边距 / 元素重叠 / 钩子字号全判)。

  17. (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,不会造成音画漂移。

  18. ★(2026-09-21 实测)--preview 渲完会删帧 —— 想留 PNG 必须显式 --keep-frames: render_video.mjs 尾部有

    if (!args.keepFrames && preview != null) { /* 遍历 framesDir 逐个 unlinkSync */ }
    

    设计意图是「预览模式顺手清草稿帧省盘」,但它对已经合成完、正准备复用的帧一视同仁地删掉。 本次踩法:为「只重渲 outro 一段」而用 --preview 15.1 跑临时项目,成片正常产出、 日志一切正常,帧目录被清掉了 49 张(第 50 张被宿主的安全删除守卫拦下才暴露)。 症状极隐蔽:✓ 场景 outro:453 帧 的日志是真的,帧也确实渲过,只是结尾被删了。 规则:只要帧还要用于「拼接 / QC / 封面 / 复核」,一律加 --keep-frames。 另注:完整渲(不带 --preview)不触发这段清理,所以「全片重渲」反而比「局部重渲」安全。

  19. ★(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 唯一决定。

  20. ★(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 这种小的百分数。

  21. ★(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):

    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 肉眼看
    
  22. (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 条,不要直接全片重渲。

  23. (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,不要图省事。

  24. 绝对定位元素的 top 是相对它自己的定位框**,不是相对页面。** compare 帧要把徽标放到「双卡下方」,直接写 top:384px → 实际落在 容器 top(196) + 384 = 580,跑进了下一区块并压住柱状图。 正确值 = 容器高度 + 留白(本例 176 + 6 = 182px)。 → 给绝对定位子元素写坐标前,先确认它挂在哪个定位祖先上; peek_frame 出图后拿实际像素反查一次最稳。

  25. 数字卷轴(odometer)在 seek 渲染下的可靠写法。 第 9 条说「数字滚动用纯 transform 数字卷轴」,这里给出可直接抄的实现:

    <span class="reel"><b class="rack"><i>0</i><i>1</i>…<i>9</i></b></span>
    
    .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; }
    
    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 三个数都靠它,终态逐位正确。

  26. 暗底上的光晕别写 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 的色,效果一样且换肤自动跟随。

  27. peek_frame --at 是百分比,判「元素缺失」前先换算成秒。 --at 92 ≈ 时长 × 0.92。本片 models 的 20×、perf 的底部结论条、 compare 的 1/20 ~ 1/60 都在 peek 时点之后才入场 —— 看到「应该有的东西没有」时,先 时长 × 百分比 算一下时点,再决定要不要改版。 想复核终态直接给 --at 99。

  28. 亮底「编辑插页」是最好用的节奏工具。 全片 10 场景里只把 2 个纯数据场景(排名柱图、差距图)做成亮底 (--paper + --ink + --accent-on-paper),其余全暗底 —— 比「全亮」或「全暗」都更抓人,且换肤只动 theme.css 一处。 注意:亮底帧必须自己铺 --paper 背景,否则会露出暗色幕底。

  29. ★ 「A 或者不 A」不是选择,是同义反复 —— 结尾最常见的废句。 真实案例:结尾三行写成 结婚或者不结婚 / 买房或者不买房 / 留在城市,或者回到县城, 观众读完的反馈是「说了跟没说一样」。因为它的信息量严格为零: 每条都穷尽了全部可能性,等于什么都没说,只是把句法凑得像选项。 同类废句还有两种,一起拉黑:

    废句类型 例子 为什么废
    同义反复 结婚或者不结婚 穷尽全部可能 = 零信息
    自指复读 这就是一个普通人的一生 把标题当结论念了一遍
    鸡汤留白 没有人知道你会走到哪里,包括你自己 听起来像总结,其实没有指向任何事

    正确写法:不总结、不升华、不判断,只把「还没有结果的具体的事」按它本来的样子列出来。 落点必须是可核查的具体物(一条没回的消息 / 一栏空着的三方协议 / 下周的复查), 而不是处境的名字。判据一句话:把这句话里的名词换成任何其他片子也成立,就是废句。 结构上让它成立的做法是前面埋钩子、结尾不回收—— 全片早段写过「还有一个,在等消息」,结尾不是给答案,而是让这条消息仍然悬着。

    配套的画面写法(比文字更省事):把结尾做成一页勾选框全空的待办清单 (☐ 那条消息 ⋯⋯ 未回),每行随念到它的那一句落下,一条都不打勾。 「未完成」不用靠形容词,靠空框本身。

  30. 极简帧里同一信息只准出现一次。 上一条的同一个镜头里,全片固定的左下角日期戳是 2026 · 你 21 岁, 右栏又放了一个 .min-age 写着同一句 —— peek 出图才发现画面上有两处一模一样的字。 极简帧元素少,重复格外显眼。定稿前通读一遍画面上所有文字(含全局固定元素: 左上系列标、左下日期、右下场次),逐个确认没有同义重复。同类:右上小标题与场次标记撞词。

  31. --only <场景id> 局部重渲的前提,和它的必然后续。 改一句旁白不必重渲整片(本片 14 场 6194 帧,全渲 20 分钟,重渲末场 78 秒):

    node scripts/render_video.mjs . --only <场景id>     # 只重渲这一场
    node scripts/render_video.mjs . --mux-only          # 用现有帧重新合成
    

    两个前提,缺一个就会出错: ① 只有末场安全。脚本会提示被改场景是否为末场;非末场一旦时长变化, 其后所有场景的帧号整体前移,必须整片重渲。 ② 变短之后必须清掉尾部过期帧,否则 ffmpeg 会把旧帧一起编进去,成片比 layout 长。 先算出新总帧数(round((Σduration + gap×(n-1)) × fps))再删:

    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 逐位一致。

  32. ★(2026-09-23)「用户需要中断流程去手工做一件事」时,那个选择必须先落盘。 加火山 TTS 时踩的:向导让用户选方案 → 选火山 → 生成 tts.env 模板让用户去填密钥 → 此时 project.json 还没写 provider(原本要等全部步骤走完才写)→ 用户填完回来跑 --check,脚本从 project.json 读到默认的 edge,于是静默去测 edge 并报「✓ 就绪」。 用户会以为密钥配好了。 规则:只要流程中间存在「需要用户离开去手工完成」的断点,断点之前已经确定的状态就要立刻持久化。 顺带把「我测的是哪个」显式打印出来(测试方案:volcano)—— 加了这一行,同类错位就不可能再静默。 更一般的判据:任何带默认值的回退,都要问一句「这个默认值在什么情况下会冒充用户的真实选择」。

  33. 密钥只能用「单点接触」的接口包封装,且默认值不能替用户做安全决定。 本技能的密钥纪律是「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 都打出来过)。要存密钥就用 系统凭据管理器或仓库外的文件,别用环境变量。
  34. 加了新的敏感文件类型,要同时检查「打包/分发」与「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 仍在包里、内容扫描无命中。

  35. 「非 0 的状态码 = 错误」是错的 —— 先查清楚协议里的成功/结束码各是什么。 火山 V3 单向流式的响应是 NDJSON,音频块 code=0,而结束标记是 code=20000000 且 data=null。 社区记录里有人只判 code===0 收音频,症状是「合并出来的音频末尾嘎然而止」——最后一段丢了, 而 HTTP 200、日志一切正常。本包按 code 显式区分三种:0 收音频 / 20000000 结束 / 其他抛错,并且离线断言过(本地假服务端按官方响应结构回包,断言音频字节数、 时间戳换算、请求头与请求体字段)。这类「协议细节只听文档会错」的地方, 花十分钟搭个假服务端做断言,比拿真 key 反复试便宜得多。

  36. 对外接口的「同构」是省事的关键 —— 适配新引擎时先看能不能让下游零改动。 火山 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 条)。 接新引擎前先确认这一点;拿不到词级时间戳就别接——按字数插值是这个技能明确禁止的退化。

  37. ★(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 能把它打到台面上,而不是安静地飘字幕。

  38. 计费/统计字段常挂在「流结束」那一行上 —— 别在结束标记处提前 continue。 同一个坑的第二层:usage.text_words 就挂在 code=20000000(结束标记)那一行。 解析循环写成「先判结束标记 → continue → 再读其他字段」, 结果计费字数永远是 0,而请求本身完全正常。加上 X-Control-Require-Usage-Tokens-Return: * 这个头之后依然读到 0 —— 因为问题不在服务端给不给,而在我们把那一行跳过了。 症状极易被误判成「是不是免费额度?」。顺序原则:一行的所有字段先全部读完, 再决定这行要不要跳过它的「主语义」。修完实测:计费 20 字 与文本长度逐字对上。

  39. 缓存里必须连「裁剪量」一起存 —— 否则首跑与重跑产出的 manifest 不一致。 词边界是原始坐标系(合成完那一刻的时间),下游 subs.py 靠 lead_cut_sec 平移。 缓存只存了边界数组、命中缓存时把首裁量当 0,于是重跑的 lead_cut_sec=0、首跑是 0.037 ——字幕整体晚一帧。这类 bug 只在「第二次跑」出现,而第二次跑恰恰是最常发生的场景。 修法:缓存改成 {"boundaries": [...], "lead_cut_sec": x},并对旧版裸列表格式做兼容读取。 验收必须是深度 diff(见 55)。

  40. ★ 别把仓库里的 *.example 模板「改名」成真文件 —— 模板会消失,而其他用户再也配不起来。 实证:用户把 tts.env.example 改名成 tts.env 去填 key。功能上没问题(gitignore 也确实 挡住了它入库),但仓库里少了模板,check_integrity.py 的密钥防线当场报错。 这类错之所以常见,是因为「复制」和「改名」在资源管理器里只差一个字。 两道改进:① 模板开头直接写清「请复制,不要改名」; ② 校验脚本在「缺模板」时再看一眼同目录有没有真文件,有就直说「疑似改名」—— 错误信息能指向原因,比只报「缺文件」有用得多。

  41. ★ 验证脚本本身要先 sanity check ——「没发现差异」可能是比较器根本没比到。 验证「首跑 vs 重跑一致」时,第一版 diff 把 items 这个列表当成不透明值 (只记 <list 3>),于是 982 个字段里一个都没深入比较,第二轮打印出 「✓ 两次 manifest 完全一致」。而同一时刻终端上明明显示首裁 0.037s vs 0.000s。 两个互相矛盾的证据同时出现时,先怀疑那个「更省事」的结论。 改成递归进入列表后,立刻暴露 3 处不一致。 ★ 通用规则:验证器要能被证伪 —— 先拿一个已知有差异的输入试一次, 看它会不会报差异;会报,才有资格说「一致」。

  42. 「名称 / 序号 / 原始 ID」的解析必须收在接口包里,不能让调用方各写一份。 火山 CLI 的 --voice "云舟 2.0" 把中文显示名原样当成 speaker 发出去, 服务端拒了,报的是 55000000 resource ID is mismatched with speaker related resource ——完全看不出「其实是名字没解析」。修法是把 resolve_voice_token() 放进接口包, tts_setup.py 改为委托它,语速换算(parse_rate_percent)同理。 判据:凡是「用户会手输、而协议只认机器格式」的字段(音色名、语速、路径), 解析逻辑有且只能有一份,且放在离协议最近的那一层。 两份实现必然慢慢跑偏。