Files
html-explainer/references/lessons.md
T
MohZhuang 66b61002f2 chore: prepare html-explainer v1.2.2 for public release
First public release of the html-explainer skill: deterministic HTML-to-MP4 explainer video pipeline for AI coding agents.

Additions since v1.2.1:

- scripts/lint_frames.py: pre-render audit of the eight frame-contract rules
- scripts/peek_frame.mjs: single-scene screenshot preview (seconds, not minutes)
- scripts/check_integrity.py: version/catalog/count consistency + stray-CDN scan
- Bilingual README (README.md Chinese default, README.en.md English)
- LICENSE (MIT), THIRD_PARTY_NOTICES.md, licenses/Apache-2.0.txt
- CHANGELOG.md, CONTRIBUTING.md, .gitignore, .gitattributes
- GitHub CI workflow and issue/PR templates

Fixes and docs:

- document lint_frames.py and peek_frame.mjs, previously absent from the file table
- add the lint stage to the pipeline order
- remove references to private client projects so the skill can ship publicly
- record the full upstream attribution chain for the style catalog
- package_skill.py now excludes repo-hygiene files and keeps licence files
2026-09-21 12:46:23 +08:00

240 lines
19 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 分钟)。
想省时间,用临时项目精确复现该场景的**全局帧号**,渲完贴回主序列:
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` 逐字一致,`-shortest` 会让视频收在音轨长度):
`-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 -shortest`
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` 这种小的百分数。