html-explainer 的所有重要变更都记录在这里。
格式遵循 Keep a Changelog, 版本号遵循语义化版本。
1.2.2 之前的版本(1.1.0 / 1.2.0 / 1.2.1)都是私有内部发布,
在这个公开仓库里没有对应提交,所以不给链接。
1.2.2 是 GitHub 上的首个公开版本,1.2.2 起每个版本都有对应的 git tag。
2.0.7 — 2026-10-14
SKILL.md的description超出加载器的 1024 字符上限 —— 症状是「整个技能凭空消失」。 由 issue #3(@rowancy66)报告:description原为 1278 字符(2457 字节), 在按 1024 字符规则扫描的加载器(Claude Code、ZCode 等,规则相近)里,html-explainer会被静默丢弃:不报错、技能列表里没有、也永远无法被触发。 而本地怎么测都正常 —— 脚本能跑、回归测试全绿、CI 全绿。 这类缺陷本地不可观测,只能在构建期把它钉住。
SKILL.md的description从 1278 字符(2457 字节)压缩到 395 字符(829 字节), 并把**「触发场景」的措辞提到最前**(加载器就是靠这段文字判断该不该唤起技能, 把它埋在功能清单后面等于自废触发)。压缩掉的只是「功能清单」性质的细节: 风格目录、v2.0 能力表、渲染通道口径、封面三规格 —— 这些正文原本就有, 不需要新增落点。name保持不变(它是加载器的键,改名等于让老用户的引用全部失效)。scripts/check_integrity.py新增第 7 项检查:frontmatterdescription超过 1024 字符即 FAIL(同时打印字节数,便于判断字节口径的加载器), 顺带拦下两个 YAML 平面标量的坑 —— 值里出现 ASCII「冒号+空格」会被解析成新字段 (全角「:」不受影响)、以引号等特殊字符开头会变成另一种标量。 超过建议值 700 字符时只提示、不失败。- 新增
tests/test_skill_frontmatter.py:A 段守真实SKILL.md的不变式 (长度上下界 /name未变 / 触发词仍在 / 正文仍覆盖被移出 description 的细节); B 段用合成夹具证明这道闸门本身能被触发(1024 字符 → 绿、1025 字符 → 红、 缺description→ 红)。只守 A 段的话,「闸门从来没生效过」也会显示全绿 —— 验证脚本必须能被证伪。 - CI 增加对应步骤(
python tests/test_skill_frontmatter.py)。
- 这是元数据 / 打包层面的修复:渲染行为、脚本接口、项目目录结构零变化, 不需要改动任何已有视频项目。
- 已装在别的技能目录里的用户不需要重跑流水线:更新技能目录(重新拉取或覆盖)
后重新加载即可;触发能力只和
description文本有关。 - 感谢 @rowancy66 定位到具体字符数上限并给出可复现的验证方式。
2.0.6 — 2026-10-06
渲染器「精细 PNG 单浏览器」路径(路径 A)上的两处静默缺陷。 由一条 7195 帧 / 1080p / 24 场的真实讲解片驱动:全片跑到第 7 场时被看门狗判成「停滞」并
exit(3),而那一场只差 24 帧(约 7 秒)就渲完了。两处缺陷都不报错, 代价都是白跑一轮 —— 而且第二处正是第一处提示你去踩的。
-
★ 路径 A 的看门狗把「单场耗时」当成「进程挂死」。
pokeWatchdog()原先只在并行路径(renderParallel():快门积分前后、等屏障时)被调用, 而本路径的doneFrames只在整场渲完才累加 → 看门狗量到的其实是「这一场渲了多久」。 默认WATCHDOG_MS = max(60s, SHOT_TIMEOUT_MS + 45s) = 105s,于是等价于 「任何单场超过 105 秒即判挂死」。本机 1080p 精细 PNG 约 3.5 帧/秒 ⇒ 超过 ~12 秒的场必然误杀;本片s2013_gold(12.82 s / 396 帧 / 需 ~114 s) 正是这样死在第 372 帧。修法:renderLegacy()的逐帧循环里补上对称的pokeWatchdog()。 判据:修复后同一场在默认 105 s 窗口下顺利渲完 —— 不靠放大HX_SHOT_TIMEOUT_MS"通过",那只是把阈值挪走,判据本身还是错的。 -
★
--resume在路径 A 是静默 no-op。 续渲逻辑只写在renderParallel()的pendingFrames过滤里,本路径加了--resume既不报错、也不跳帧 (与--workers在useLegacy下无效同族)。更糟的是看门狗挂死时的提示恰恰写着 「加--resume可直接接着跑」——把人骗进第二次白跑;而精细 PNG 只能走路径 A, 它偏偏是最需要续渲的那条。修法:路径 A 按全局帧号判同名帧是否在盘上 (shotOne()写的本来就是sc.gf + i + 1),整场都在盘上时连页面都不加载。
- 看门狗挂死提示按路径分叉:并行路径给「调小
HX_SHOT_TIMEOUT_MS/ 降--workers」, 路径 A 给「调大HX_SHOT_TIMEOUT_MS/ 加--resume/ 只差尾部几场用--only」三条。 references/render-profiles.md:§断点续渲补「两条路径都支持」与 v2.0.6 前的静默行为; §健壮性补「两条路径都必须按帧打点」与误杀阈值推算。- 顺带修正一处文档与代码不符:
--resume的判据是fs.existsSync(存在性), 不是 mtime —— 改过帧内容但文件名没变时它不会重渲。原文口径已作废。 SKILL.md工具表与references/lessons.md#132 同步。
- 路径 A 的续跑手段不止
--resume:只差尾部几场时用--only <剩余场次,逗号列表>。 它逐帧写的也是全局帧号,前面已渲的场次完全保住。前提是必须一路覆盖到末场, 否则中间会留下过期帧 —— 合成前的帧完整性闸门会拦下,并把这条命令直接打给你。 - 路径 A 的精细 PNG 只在单并发下稳定:同一片子
--concurrency 1正常(3.36 帧/秒),--concurrency 2实测在启动阶段停滞(0 帧产出)。
2.0.5 — 2026-10-05
渲染通道的默认推荐由
jpeg q95改回png-fast。 原口径的依据是「JPEG q95 快 13×」, 但那个倍率测的是满幅照片帧;本技能绝大多数片子是纯 CSS/MG 图形帧,PNG 的 deflate 对平色块极其高效 —— 实测两者同速,且 PNG-fast 体积更小、还逐像素无损。 由一条 5982 帧横屏讲解片的通道基准推动(见references/lessons.md#130)。
- ★ 默认推荐通道:
--jpeg --jpeg-quality 95→--png-fast。 本机 6 进程并行实测(1920×1080 · 纯 CSS/MG 图形帧): JPEG q95 27.14 帧/秒 · PNG-fast 26.65 帧/秒(×1.02) · PNG 精细 23.41 帧/秒(×1.16); 体积 PNG 精细 0.08 < PNG-fast 0.10 < JPEG q95 0.12 MB/帧。 即纯图形帧上 PNG-fast 全面占优(同速 + 更小 + 无损)。 - 端到端代价可忽略:同一条 5982 帧片子,JPEG q95 实测 460.7 s, 换 PNG-fast 预计 468–493 s(+2%~+5%,仅多 10–32 秒),且中间帧体积更小。
- 保留例外口径:含满幅照片 / 重合成帧、或 4K 终稿时,
--jpeg --jpeg-quality 95仍是首选 (照片帧上 PNG 是 582ms/帧的黑洞;4K 精细 PNG 单帧 2–6 MB)。「JPEG q95 不是降质」这句依旧成立。 - 档位表的
shot字段不动(draft/balanced/final本就 =png-fast): 它现在与推荐口径是同一个值 —— 不传通道参数就走 PNG-fast,两者不再冲突。
SKILL.md确认点 5:通道表默认推荐换位、询问话术四条重排(① png-fast,仍要求四条全列)、 自动推荐口径重写;能力表与命令示例同步。references/render-profiles.md§0:通道表、体积口径提醒、实测段、询问话术、决策表全部对齐;§5 要点段同步。references/workflow-guide.md阶段 5:默认命令注释与「照片帧修法」改为 png-fast 优先。scripts/gate_check.py:CHANNEL_HINT与确认项描述改为「默认推荐 png-fast」列在第 1 条。
- 「×13 / ×4.4 / ×1.0」与「慢 3×、占盘 8×」都是满幅照片口径,不要套到纯图形帧上; 体积数字同理(纯图形帧 0.08–0.12 MB/帧 vs 照片帧 1.6–2.0 MB/帧,差一个数量级)。
2.0.4 — 2026-10-05
一个真实会毁掉整条成片的渲染器 bug:
--jpeg通道下,「hold 帧」被写成了 「后缀.jpg、内容 PNG」的脏帧,ffmpeg 按后缀选 mjpeg 解复用器、读第一帧即崩。 由一条 5982 帧的横屏讲解片实测暴露(详见references/lessons.md#126 / #127)。
- ★
render_video.mjs:--jpeg通道没贯穿到 CDP。captureFrame()的 CDP 分支写死format:'png',captureBytes()则恒为 PNG(这是刻意的:快门样本要无损 PNG,first.equals(last)也要无损字节比对)。但 hold 帧落盘走的是fs.writeFileSync(finalPath, first)—— 把 captureBytes 的 PNG 字节写进了.jpg名字。 PIL 按内容嗅探能读,ffmpeg 按后缀解码则当场报unsupported coding type。 修法:①captureFrame的 CDP 分支补format:'jpeg'+quality; ② hold 落盘改走captureFrame({ …, outPath: finalPath, args, W: pjW, H: pjH }); ③ 给captureBytes加注释锁死「恒为 PNG」,防止后人加--jpeg分支破坏快门积分。 实测该轮 1254 个 hold 帧全中,与渲染日志的still_frames计数完全相等。
references/lessons.md#126:脏帧的识别(head -c4看魔数ff d8 ffvs89 50 4e 47) 与补救(PIL 就地重存为真 JPEG 后--mux-only补编码,无需重渲)。references/lessons.md#127:收尾积分/清理阶段非零退出会让编码被整体跳过, 但帧其实全好 —— 用blur_integrate.py --report+ 帧序列(1 基编号)核对后, 直接--mux-only补编码。
2.0.3 — 2026-10-05
一个能力补齐 + 一处认知纠正。起于用户的一句提问:「快门能不能只在用到动效的地方开, 没用到就别开,这样单条视频的总时长不会被拉太长?」—— 答案能,但当时的实现做不到。
--shutter-only <id,id>:场景级快门白名单。只有点名的场景走快门采样,其余场景走 零成本单张(不截图、不落样本、不占磁盘、不进积分)。让「只有几处运镜要拖影」的片子 不必为全片买单。id 打错会立刻报错退出 —— 否则就是「静默全片不开快门」: 拍出来没有拖影,但没有任何提示。--motion-hold <px>(默认 1):帧级位移闸门。页面若导出window.__motion(t0,t1)(这段快门窗口里屏幕最远走了几 px),位移不到 N 个设备像素的帧直接单张落盘, 一次多余采样都不截。assets/motion.js的screenTravel(c0, c1)可直接用作它的实现。- 截图结束新增一行
快门覆盖面:X/Y 帧走快门采样(Z%);still_frames的日志拆成位移闸门 N + 逐字节 M;manifest 新增motion_hold_px/shutter_scenes/shutter_frames/motion_holds。
- ★ 旧文档「静帧只截 2 张就跳过,静止段几乎不额外耗时」是误导性的乐观陈述。
判 hold 的实际判据是
first.equals(last)—— 对 16.7 ms 快门窗口内的两张 PNG 做 逐字节比对,阈值等于 0:任何亚像素级抗锯齿差异都让两张图不等。 实测一部 31 场 / 8525 帧的片子,只有 ~20% 的帧被判成 hold,剩下 80% 全按 8 样本付费, 截图吞吐 18.4 → 2.9 帧/秒(约 6.4×)。文档已把判 hold 改写成三级闸门表, 并显式标注 ③(逐字节)不可靠、②(__motion)才可靠。 - 补记一个反直觉坑:为「防静止」给整场加的全时长缓慢推镜每帧只走零点几像素、
肉眼毫无拖影,却让
__motion全非零 → ② 变成无操作、③ 必然不等 → 整场全额付快门成本。
- 不给
--shutter-only= 全片开(与 v2.0.2 行为一致);不给--motion-hold= 1 px。 唯一语义变化:帧里导出了__motion时,位移 < 1 px 的帧现在会真的被摘掉 —— 这正是该参数存在的意义,且只会让渲染更快,不改变「有位移的帧」的结果。
2.0.2 — 2026-10-05
三个 bug,全是跑一部真实长片才暴露出来的(1080p / 284 s / 8525 帧 / 31 场)。 第一个会让渲染在中途写满磁盘、第二个会让它静默漏帧、第三个会把正常工作误判成挂死。 起点是一样的:开默认快门的长片——也就是 v2.0 一直推荐的那条路。
发布说明:2.0.2 与 2.0.3 的改动在同一批文件里不可分割(
render_video.mjs同时承载 两者的改动),且都由同一部片子驱动、均未独立发布 → 合并为一次提交,只打v2.0.3的 tag。 本节记录完整保留,供追溯。(若强行按 hunk 拆成两个提交,第一个提交会出现「CHANGELOG 已写 2.0.3 但代码还没有对应能力」的不一致中间态。)
- ★★ 快门样本「全程堆积」:峰值磁盘 = 全片之和,长片必然写满盘。
积分与清理原本只发生在全部截图完成之后(
render_video.mjs的积分段), 于是render/shutter/在截图阶段单调增长。实测:1080p 图形帧单样本 1.33 MB, 默认 8 样本/动帧 → 一部 8525 帧的片子要 ~90 GB(加最终帧 ~104 GB), 而本机只有 57 GB 可用。两个后果:① 跑到一半ENOSPC;② 采样涨到 ~20 GB 后 速率从 110 帧/分掉到 12 帧/分 —— 进程活着、日志不报错、chrome 进程一个不少, 看起来像"渲染卡住",其实是磁盘 I/O 在打架。 → 新增--shutter-flush <GB>磁盘护栏:样本攒到阈值就全体停手做一次积分 + 清理, 把峰值从「全片之和」压到「阈值 × 1」。默认按可用磁盘自动取(可用空间的 25%,夹在 1–8 GB),--shutter-flush 0可退回旧行为。实况写进out/<slug>.render.json的shutter_flush_bytes/shutter_flushes/shutter_peak_bytes。 同一部片子改为--shutter 0 --jpeg q95实测 18.4 帧/秒,9 分 7 秒渲完、中间产物 1.9 GB。 - ★ 屏障必须是「真屏障」,不能是「碰巧大家都空闲」。 第一版护栏只在
active === 0(所有 worker 都不在帧内)时积分,结果整片只触发了一次 —— 8 个 worker 的帧间空隙只有微秒级,"全部同时空闲"在长片里几乎不会自然发生。 改成:攒够阈值 → 举flushReq→ 各 worker 在帧边界停手 → 等active === 0且全员到齐 → 由最后一个到齐的人执行积分 → 统一放行。带FLUSH_WAIT_MS超时兜底, 凑不齐就放行退回堆积,宁可慢、不可死锁。 - ★ 看门狗会把「一次正常的积分」当成挂死杀掉。 默认
WATCHDOG_MS = 105 s, 而实测一次 276 帧的积分要 109 s → 第一次积分就被process.exit(3)。 → 看门狗的"最后活动时刻"提到外层,新增pokeWatchdog();积分前后各喂一次 (execFileSync会阻塞事件循环,返回后必须补喂)。任何"长时间不产新帧但确实在干活" 的阶段都要喂它,否则最后一道防线会变成第一个误报源。 - ★★ 积分器把成品帧扩展名写死成
.png——--jpeg通道下整片动帧被静默丢弃。blur_integrate.py的pending()里是name + '.png'。而--jpeg通道下 Node 侧把 静帧写成.jpg、积分器把动帧写成.png→render/frames/里两种扩展名混住, ffmpeg 的输入是f_%06d.jpg,全部动帧不见;完整性闸门也只按一种扩展名点号, 两边永远对不上(实测直接报「缺 8495 帧,拒绝合成」)。 讽刺的是--jpeg q95正是 §0 推荐的通道、快门又是balanced的默认 —— 两个推荐项撞在一起就是坏的。 →blur_integrate.py新增--ext png|jpg与--jpeg-quality N,render_video.mjs在调用时把渲染通道的扩展名传下去。单样本快路径也从「直接 copyfile」改为「ext 不同就真转一次」, 避免出现「.jpg文件名装着 PNG 内容」的脏帧。
- 渲染通道的推荐口径改为
--jpeg --jpeg-quality 95优先(原为png-fast)。 理由:中间帧还要再被 x264 压一次才成为成片,只要中间帧失真低于成片自身编码失真 就不可观测 —— q95 实测 PSNR 41.65 dB,正好低于 crf18 的自身失真,等于零代价 换来 13× 编码速度与 1/5 磁盘。PNG/PNG-fast 的"逐像素无损"在这一环没有可观测收益。 档位表里的shot字段保持不动(balanced/final仍是png-fast), 以免破坏「不传新参数时行为等价于旧 1080p30 默认」的兼容承诺 —— 推荐口径与档位默认值是两回事。
2.0.1 — 2026-10-05
check_cover.mjs的四处修复。根因都是脚本自身:一个是潜伏了两个版本、不报错不崩溃的 静默错误,一个让整条钩子判据链失效,一个把柱状图数值标签误报成「左右两栏」, 一个是从来没检查过的「文字压字」。 起因是某期封面首版三张全错,而脚本当时报了 8 个 FAIL 却没一个是真问题 —— 一个会误报的闸门比没有闸门更危险,它会训练人「跳过这一步」。
-
★★ 探针内的
norm正则少了一层反斜杠 —— 空格从未被剥掉(静默)。PAGE_PROBE里写的是/+ 单反斜杠 +s+/g,模板字符串把「反斜杠 + s」吃成s, 正则退化成/s+/g:剥掉的是字母 s,空格一个没动(实测norm('a b c')原样返回)。 同一文件里split(/+ 双层反斜杠 +s+/)却写对了 —— 同一转义在同文件内不一致。 后果全部静默:①「句子级」字位权重把空格算成 0.55 字位,容器/碎片容易被误判成钩子; ②pureNumber永不成立 → 悖论数字被当成「文字」,「钩子是最大的文字」会对合法封面误报 FAIL。 必须先修它:下面那条改用el.textContent后文本必然带缩进换行,会把它的代价放大。 -
★★ 钩子识别改用完整文本,不再只看直接子文本节点。 钩子常写成
家长<em>不骂</em>游戏了,只取直接子文本就剩「家长」+「游戏了」= 5 字位,过不了「≥6 字位 才算句子级」的门槛 → 真正的 112px 钩子被跳过,脚本退而把 28px 的数值标签认成钩子 → 「钩子字号不足 / 钩子不是最大文字 / 末行是孤字」整条链全部误判(三张封面一起中招)。 改用el.textContent判句子级;父容器也会因此通过门槛,但它font-size小,最终按 fs 取最大者,赢不了。 -
9:16「禁止左右两栏」加字号门槛(≥40px)。 柱状图左右两个数值标签(28–38px)天然就左右 分布,原判据把它们报成「两栏文字」。真正的两栏正文/大字都 ≥60px,不受影响。
- ★「文字无重叠」检查(FAIL)。 封面规范一直写着「先跑终态测量,再逐条肉眼过」,
但「长标签折行后压住上面的数字」这类问题在缩略图里极难发现,首版三张全部命中。
判据量的是字墨而不是元素框(元素框在文字溢出容器时还是容器那么大,看不出压到了谁),
也不是 Range 的行框:
Range.getClientRects()返回的行框含 leading,直接比会把 「相邻两行」「line-height:1的字形溢出」全报成重叠(实测一次 15 个假阳性); 把高度收窄到fontSize后用阈值「面积 > 4px²」才准。判「出画」同理。 与既有的「钩子不与其它文字重叠」(块框级)互补,两条都保留。 反向验证:注入首版的长标签 → 准确报出 2942px² / 1727px² 两处重叠,退出码 1。
SKILL.md版本号2.0.0→2.0.1,封面实测的命令注释与脚本表补入「文字重叠」。references/cover-guide.md自检清单补入「文字无重叠」。references/lessons.md新增 #116–#120(清点既有脚本 / 钩子识别的 inline 陷阱 / 量字墨而非行框 / 探针里不能写反引号 / 模板字符串里的反斜杠会静默退化)。
2.0.0 — 2026-10-03
一次「表现力 + 生产力」的升级:v1.x 解决「能不能稳定出片」,v2.0 解决「出得快不快、好不好看、 每期像不像同一部片子」。
完全向后兼容:所有新能力都是可选开关。不带任何新参数跑,行为与 v1.4.3 一致 (
--profile legacy逐位复现旧成片)。原有接口、23 个模板、封面流程一律未破坏。
- ★ 动效库:把「镜头语言」变成时间的纯函数。40+ 动作词汇分 5 组:
enter(到场):riseWord/dropLetters/springIn/blurAway/riseFromMask/typeChars/checkOff/flyPlanecarry(承接):morphBox/irisOpen/diveInto/arcHop/gatherTo/railShift/sealDisc/burstWordcontact(接触):landHit/splitOnHit/tapPress/pointer/stretch2/sim.*(磁吸/跟随/软体/绳索)camera(运镜):camTrack/layerMatrix/depthBlur/whipPan/camShake/slowPush/gridDots/ 坐标互转ambience(环境):swiftSpring/glowField/floodRings/noiseField(5 套色带)/beltLoop
- 确定性:所有随机走
seededRng,渲染路径上不出现Math.random()—— 逐帧渲染与实时预览一致。 - 自测
tests/test_motion.mjs(148 条断言)。
- ★ 读解说词,不填表:按位置 + 文本线索推断每场角色(开场/陈述/数据/原理/例证/反差/收束/落版)。
- ★ 挑主风格:按角色 × 子类别 × 时长区间 × 内容词 × 受众 × 节奏打分。子类别级适配修正了
"类别太粗"(
presentation下有 11 个子类,讲数据用hero是错的)。 - ★ 混用与局部替换:按能量预算给部分场搭次风格做局部元素替换(不是整场换皮)。
- ★ 动态开头:
opener_variant由主题哈希轮换,不再是写死的那个。 - ★ 动态转场:
transition_out由相邻两场能量差决定,不是清一色淡入淡出。 - ★ 多样性约束:风格数上限 / 连续同风格上限 / 开场≠第二场;
--pin是硬约束。 只允许同类别内合并(跨类别会讲歪),合并代价 > 10 分就放弃合并并如实记录。 - 防雷同:容差带内按主题哈希轮换 —— 同一题材每次换一批画面。
--band 0完全确定(供回归)。 - 可解释:产物
style-plan.json的why字段逐条说明"这场为什么被挑中"。 - 自测
tests/test_style_director.py(60 条断言)。
- ★ 成套档位
--profile draft|balanced|final|master|legacy,或--quality 1080p|2k|4k×--fps 30|60自由组合。显式开关永远覆盖档位默认值。 - ★ 快门运动模糊
--shutter 180:线性光下的多样本积分(不是 blur 滤镜)。hold静帧只截 2 张就跳过 → 静止段几乎不额外耗时;样本数按window.__motion()自适应。⚠ 本行是 v2.0.0 发布时的原话,已被 2.0.3 实测推翻(判 hold 的阈值等于 0, 8525 帧里只有 ~20% 被判成静帧)。正确说法见 2.0.3 与
references/render-profiles.md§3。 - ★ 多浏览器进程级并行
--workers N:各自一个 chromium 进程,截图是 CPU 活,可拉满多核。 - ★ 断点续渲
--resume(已积分的帧直接跳过)、定期重启浏览器--recycle N(4K 长片防 OOM)。 - 画质只改
deviceScaleFactor(1× / 1.333× / 2×),布局逐像素不变,只是采样更密。 - 中间帧格式(
--png-fast/--jpeg)与最终编码质量(--crf/--preset)解耦。
- ★ 渲染前必须先问用户选哪条中间帧通道:
png/png-fast/jpeg q95/jpeg q82, 附逐帧耗时、相对速度、画质、中间帧体积对照,并给出自动推荐口径 (有满幅照片一律不默认 PNG;4K 终稿必须换通道)。 - 规格落进
consent.json的render_channel字段,由gate_check.py挡在渲染之前 (--phase render只查渲染前必须拍板的项)—— 把「问一句」从纸面规则变成硬闸门, agent 不能凭「上次用的 png-fast」静默开工。 - 对照表与询问话术见
SKILL.md确认点 5 与references/render-profiles.md§0。
- 合成复杂度可控的项目(N 场 × M 动元素,
--heavy模拟高熵帧),同机多档位各渲一遍, 拉出「截图耗时 / fps / 体积」对比表 —— 优化前后同表对比,不靠感觉。
2 场 × 1.0s 合成项目的同机对比(scripts/bench_render.py):
| 档位 | 输出 | fps | 帧数 | 截图 s | 积分 s | 总 s | 帧/秒 | 加速比 | 体积 MB |
|---|---|---|---|---|---|---|---|---|---|
legacy |
1920×1080 | 30 | 60 | 21.4 | 0.0 | 23.6 | 2.8 | ×1.00 | 0.38 |
draft |
1920×1080 | 30 | 60 | 6.2 | 0.0 | 8.0 | 9.7 | ×3.47 | 0.37 |
balanced |
1920×1080 | 30 | 60 | 16.6 | 7.1 | 27.5 | 3.6 | ×1.29 | 0.49 |
4k30 |
3840×2160 | 30 | 60 | 16.9 | 5.9 | 26.4 | 3.5 | ×1.26 | 0.49 |
4k60 |
3840×2160 | 60 | 120 | 25.4 | 8.8 | 37.5 | 4.7 | ×1.69 | 0.57 |
- 并行比分辨率更划算:
4k30(4× 像素)的截图吞吐几乎追平 1080p 的balanced(都 ~3.5 帧/秒)—— 真正拖慢legacy的是单浏览器串行,不是分辨率。 - 优化前后:
balanced总耗时 70.4 s → 33.0 s(其中积分 + 清理 53.6 s → 8.5 s)。 - 完整数据与读法见
references/render-profiles.md§5。
scripts/render_video.mjs:--out相对路径的解析口径。原先按调用方 cwd 解析, 与--audio(按项目目录解析)不一致,导致文档里的--out out/<slug>.mp4落到错误目录、.render.json在预期位置找不到。现统一为按项目目录解析(绝对路径原样),并自动建父目录。scripts/bench_render.py:- 档位覆盖
--fps时未同步layout._total.total_frames—— 渲染器采信声明帧数, 于是4k60按旧 fps 的帧数渲染、时长少一半,基准不可比。现按档位 fps 同步声明帧数、跑完还原。 - 某档无元数据时,报告阶段
NoneType.__format__崩溃。现对缺失字段降级显示。
- 档位覆盖
SKILL.md版本号1.4.3→2.0.0;description 与命令流水线补入新能力(动效 / 编排 / 档位)。.gitignore:补.scratch/(基准测试现场,不该入库)。
- 新增
references/motion-library.md、references/style-director.md、references/render-profiles.md。 THIRD_PARTY_NOTICES.md:新增"v2.0 新增能力与依赖"一节(v2.0 未引入新依赖) (说明新增能力只用已声明的库,不引入额外运行时组件)。
1.4.3 — 2026-09-29
两个「只在运行时才暴露」的静默错误,各补一道构建期闸门。 一个在渲染器里(帧数少 1 帧 → QC 假警报),一个在帧作者手里(
B()前缀匹配失败 → 前几百帧白渲)。
- ★
scripts/render_video.mjs:总帧数有两个出口,落在.5帧边界时 QC 必然误报「视频流被截断」。timeline_build.py拿未舍入的 manifest 时长求和得 5129,渲染器只能拿到layout.json里 已舍入到 3 位小数的duration_sec、重算得 5128 ——170.95 × 30 = 5128.5恰在边界, 两边差 1,而 QC 的帧数是精确相等判据 → 必然 FAIL。而帧目录连续无缺口、音轨被完整覆盖、 渲染日志自己报「5128/5128 齐备」,三条证据都指向假警报。 两个出口改一处等于没改:① 全片渲染改为采信layout._total.total_frames; ② 编码的-t改为按totalFrames/fps反算 —— 采信 5129 后若仍写renderSec(170.950 < 170.967)照样把最后一帧切掉,表现为「补帧补了个寂寞,QC 依旧报截断」。 scripts/subs.py:beats.js 生成头部的注释是坑源。 原文「text 匹配『块的原文片段』即可 (块内前几个字也行)」读起来像「任意几个字」,而实际是只能从块文本开头取。 已改为显式写前缀匹配语义 + 正反例,并指向构建期校验器。
- ★★
scripts/check_beats_refs.py——B()/Be()节拍引用的构建期校验。B()抛错只在渲染到那一帧时才发生(前面几百帧白渲,长片要等十几分钟才炸), 属于「只在运行时才炸的静态错误」——这类错误应当有一个构建期出口。 本脚本把每帧的B()/Be()全抓出来和自己的 beats 表对一遍,失配直接打印该帧可用块列表, 退出码 1,秒级出结果。已挂进主流水线(位置在lint_frames.py之前)。 做过反向验证:把B('2018年')改成B('被网游毁掉的孩子')→ ERR + 退出码 1 ✓。 references/lessons.md补#90–#97,本轮的 8 条:#90B()是前缀匹配(《》「」不参与归一化)且失败时机在渲染期 → 校验器 + 反向验证#91交付给子 agent 的并行构建组会静默卡死 → 按 mtime 巡检、>15 分钟自己补位、 需要「家族一致」的帧(章节卡/封面对)主 agent 亲自写#92测渲染速度不能按文件名取最后一帧(并发写盘 → 编号有缺口,实测差 3 倍)→ 按getmtime排序 + 两个独立测法互验;附「本机 Git Bash 没有bc」#93渲染器要求全部帧就位 → stub 占位帧把风格定稿提前到参考帧阶段(错了只浪费 30 秒)#94亮底段的字幕换肤:把<body class="paper">写进分发 prompt + 交付后grep自查 (v1.3.2 那个「白底白字」坑的主动预防版,本轮一次做对、零返工)#95上画面的数字要回查一手口径名:「网络视听 201 分钟」被媒体写成「短视频 201 分钟」 是偷换统计主体;「黑神话销量 3000 万份」无一手来源 → 宁缺勿用#96「时效刷新」是流水线的正式步骤,位置在tts_build之前(改解说词 → beats 全位移)#97风格家族一致性只有两种保证手段:同一个人写,或给成品骨架要求逐条照抄 —— 这种不一致任何自动体检都抓不到
references/frame-contract.md第 5 条补前缀匹配对照表(B('一位父亲写下')✓ /B('被网游毁掉的孩子')✗)与「拿不准就抄块文本前 6–10 个字」,并加上校验命令。references/workflow-guide.md:- 阶段 1 新增 「★ 时效刷新(必须在解说词定稿之前)」 小节 —— 含口径名纪律与 「不要替换清单」(历史事实本来就不该更新),并解释为什么顺序不能颠倒;
- 阶段 4 重写:先写 4–5 个参考帧 → stub 占位帧把
--preview提前 → 再批量派单, 新增「按 mtime 看护并行组」与「亮底(paper)组的额外硬规矩」两节; - 修掉一处陈旧引用
tools/check_beats.py(该脚本早已不存在)。
SKILL.md:命令流水线加check_beats_refs.py;工具表加一行; 「静态体检」从两条命令扩成三条(节拍引用 / 文本规则 / 几何);质量标尺补三条 (节拍引用全绿、亮底body.paper、口径名逐字一致 + 解说词一次定稿)。
1.4.2 — 2026-09-27
一个「念得对、写得错」的静默失败:口播念 2.4%,画面字幕却是 24%。 去句读标点的规则把小数点也当成标点了。
- ★ 小数点不再被当标点删掉。 屏幕文本规则原来用字符集一刀切(
, . :全删), 于是2.4%上屏幕变成24%—— 差一个数量级,不报错、不告警,只有人眼能发现。 定稿口径:,与:继续删,数字内部的小数点保留(判据:左右各有一个数字;1.2.3这类版本号两个点都留,句末结束.、前导.5照删)。 - 同一概念的五个出口全部对齐(只改一处等于没改):
① 屏幕文本
clean_sub_text② SRT/VTT ③B()节拍归一化(subs.py与new_project.py两份模板)④ 火山split_long_text的长文本切句(2.4%曾被劈成两个请求, 语音中间凭空多一个硬停顿)⑤ 对齐用的_PUNCT_RE。
tests/test_subs_text.py(零依赖,守 Python 侧五个出口)+tests/test_beats_norm.mjs(守 JS 侧B()匹配,直接从subs.py取模板本体跑,不复制一份)。 两者都已挂进 CI 四矩阵,且都做过反向验证(拿旧代码跑必须变红)。references/lessons.md补#88,含写正则时踩的\.(?<!\d)断言位置坑 (断言按当前位置求值,写在原子之后语义就反了),以及「测试第一版没被证伪、 样本必须造出跨块撞车才有鉴别力」这条元教训。
SKILL.md字幕规范、references/frame-contract.md(B() 参数)、references/volcano-tts.md(切句)同步说明小数点例外。
1.4.1 — 2026-09-26
出片实战(小米 / 华为那期,11,146 帧 / 22 场)踩到的一个静默失败: 成片标称 6:11、实际只有 3:41 有画面,而 ffmpeg 退出码为 0、每场日志都报齐。
- ★★
scripts/render_video.mjs:场景帧号边界漏 1 帧,后果是成片中段断流**。** 旧写法在算gfEnd时用Math.round((cursorSec + dur + gap) * fps)现场重算, 而下一轮的gf用的是累计后的cursorSec—— 同一时间点在 float64 上可能差 1 个 ULP, 舍入后正好差 1(实例:gfEnd_ai_hw = round(221.5499… × 30) = 6646vsgf_layout = round(221.5500… × 30) = 6647→ 6647 谁都不写)。21 个边界里中了 1 个。 连锁反应才是要命的地方:截图阶段doneFrames少 1,但每场日志都报齐看不出异常;image2解复用器遇缺号即停止解码;而命令行里的-t <renderSec>仍把容器时长写成全长,ffmpeg退出码还是 0 → 产出「Duration: 00:06:11.54看起来完全正常、实际视频流只有 6,646 帧 = 3:41.5、后 150 秒无画面」的坏片,肉眼抽查几帧也发现不了。 改为构造性无缝:先一次算好每场起点,令第 i 场的终点帧直接等于第 i+1 场的起点帧 (末场封到totalFrames)。修复后实测 11,146 / 11,146 ✓。
- 合成前的「帧完整性闸门」。 逐号核对帧文件,缺帧直接
die并点名缺在哪个场景、 附上可复制的--only <id>修补命令 —— 拦在 mux 之前,省掉一次 26 分钟的无效合成。qc_check.py的帧数实测能抓到同类问题(第 148 行,lessons#57),但那已经到成片之后, 而且不点名场景。 ffmpeg合成命令行加-xerror。 让解复用 / 解码错误变成非零退出,而不是静默出片。
references/lessons.md补#86/#87:#86帧号边界必须构造性无缝:完整连锁反应(含「Duration正常但帧数少一半」这个 假象)+ 修复式 + 判别式(Σ n === totalFrames且帧号无缺无重)+ 怎么验成片有没有断流 (ffmpeg -i out/x.mp4 -map 0:v:0 -f null -看末尾frame=,必须等于layout.json的total_frames;只看Duration会被 apad 音轨骗)。附一条排查纪律: 复算场景边界不要用 Pythonround()—— 银行家舍入 vs JSMath.round = floor(x+0.5), 在.5处差一格,会把缺帧定位到错误的场景(本次因此白跑一次--only layout重渲, 真凶是ai_hw)。#87--only <单场>时并发会退化成 1(队列里只有 1 场,conc = Math.max(1, …)), 没有多 worker 摊薄固定开销 → 补帧这类小修默认就该带--png-fast。实测同机 0.9–1.2 帧/秒 → 9.4 帧/秒(636 帧:9 分钟 → 70 秒);帧体积 +22% 但 PNG 依旧 逐像素无损,可与默认模式渲出的帧安全混在同一套序列里。
1.4.0 — 2026-09-26
- ★
scripts/check_cover.mjs:封面终态几何实测器。 封面规范一直写着「先跑终态测量,再逐条 肉眼过」,但没给工具 —— 于是「差 20px 的贴边」和「多出一个字的孤行」这两类只能靠机器抓的 问题,全落在肉眼里,而肉眼恰恰看不出它们。新工具把自检清单变成机器判据:四边边距 / 钩子字号 / 钩子是否仍是画面最大文字 / 钩子行宽 / 孤字断行 / 元素越界;916另判无左右两栏。 先tl.pause(tl.duration(), false)seek 到轴末再量(量中间态等于量错,见下「修复」), 退出码 0/1 可挂流水线;--only/--json/--shot。 - 封面第三个画幅
916(1080×1920,竖版投放)。cover_build.mjs的SPECS加一条即生效 (渲染与自检都是数据驱动的),输出 2 倍图 2160×3840;--only支持逗号分隔。缺哪张封面帧 就跳哪张 —— 只投横版的项目不必建 9:16。 references/cover-guide.md新增「C. 1080×1920」整节:三段式构图(角标 0–10% / 视觉 10–45% / 钩子 50–88%,重心偏上);上下边距是「不可裁区」而不是「安全边距」 —— 上 ≥180px 让开账号与时长层、下 ≥160px 让开标题栏与互动按钮;钩子放大一档到 130–170px、悖论数字缩到 96–130px;左右并置必须改上下堆叠(1080px 宽里放不下两栏)。
references/cover-guide.md从「双方案」重写为「多画幅」:三画幅重排对照表(含钩子容器宽 1060 / 1220 / 900px)、自检清单分档(边距 / 字号 / 字数)。每行字数上限改为由容器宽反算:字数 ≤ 容器宽 ÷ 字号 ÷ 0.97;硬判据是行宽 ≤ 容器宽,字数只是查孤字断行的参考 (旧值 7 / 5 字是更早一版小字号设计留下的,与当前 130px 字号不匹配)。scripts/render_video.mjs:ffmpeg 回退链补上托管 Python 探测。此前回退只问PY/python3/python,而 WorkBuddy 这类环境下 PATH 上根本没有 python(只有~/.workbuddy/binaries/python/...),于是「PATH 无 ffmpeg → 问 python → python 也不在 PATH」 一路静默落空,最后只报一句「找不到 ffmpeg」,看不出是回退链断了。现在主动扫托管 venv 与 解释器目录(WindowsScripts/python.exe与 POSIXbin/python都覆盖)再问。SKILL.md/scripts/new_project.py/assets/cover-template.html/README:封面口径从 「两张」统一为「多画幅」,确认点 1 增加「投放画幅」一问。README.md/README.en.md的版本徽章此前停在1.3.1(落后SKILL.md两个版本,自洽性检查 只看 SKILL 与 CHANGELOG 所以没报)—— 本次一并对齐。
- 封面验收的两个假阳性(这一版的由来)。
① 等固定毫秒数会量到中间态:第一版脚本
goto后等 650ms 就量,而cover_34.html的时间轴 长 1.54s —— 650ms 时钩子还停在opacity:0,既没进边距统计,又让脚本把悖论数字「6.02」 (112px)当成「画面最大文字」并误判为钩子,报出「上边距 84px / 下边距 82px / 钩子字号只有 112px」三条不存在的排版事故。改为 seek 到轴末。 ② 钩子识别与「钩子是否最大」是同一条判据:第一版「钩子」= 字号最大的带文字元素, 而检查项是「钩子字号是画面最大」—— 自己验自己,永远 PASS,等于没查。改为把「句子级」 元素(自身文本 ≥6 字位、且不是纯数字串)作为候选取最大者,再另外与全场最大字号比; 数字压过钩子只提醒(上限 钩子×1.35),文字压过才 FAIL。 references/lessons.md补#82–#85:9:16 独立排版三条纪律 /SPECS数据驱动与--only逗号分隔 / 验收脚本必须 seek 轴末(任何「等 N 毫秒再量」都是定时炸弹)/ 钩子识别与 「是否最大」要拆成两条独立判据。
1.3.3 — 2026-09-25
- ★
scripts/check_layout.mjs:渲染前的几何体检器。lint_frames.py是静态文本检查 (禁色值字面量、禁 transition、中文字体族…),它看不见几何;而画面里最常见的两类 「静默出错」恰恰是几何问题 —— 遮挡(图表轴标签被底部证据条压住、两段文字打架)和 越界(内容溢出画布、压进字幕带)。两者都 lint 全绿、渲完几千帧才被人眼发现。 新工具把「谁和谁重叠、谁越界」变成可枚举清单(out/layout_report.md): 侵入字幕禁区 / 出画 / 文字被遮挡 / 文字重叠 = ERROR(退出码 1,可挂流水线); 越安全边 / 文字压色块 / 色块重叠 = WARN;「疑似该跟图形共心却差了 N 像素」= INFO。 度量基准是被三个版本的假阳性逼出来的:量字墨(Range逐行,不是元素框)+ 纵向收成 em 盒(行盒含字体留白,紧排大字号会擦边),带底色的元素仍用元素框。 - ★
scripts/frame_at.py:排障入口 —— 报「几分几秒」直接落到那一帧。--at 1:23给出场景 id / 帧号 /frames/<id>.html/ 当刻字幕块(反查代码里的哪一句B('…'))/ 整帧图 / 底部 260px 禁区带裁图 / 场景终态帧;另有--list(全片场景时间表)、--box x0,y0,x1,y1(再裁可疑区)、--final、--no-images。 配合带视觉的模型看图 → 改一个 HTML →--only <id>局部重渲 → 回到同一时间点复核。 peek_frame.mjs新增--guides:叠十字中线 + 字幕禁区线 + 左右安全边。 判「元素有没有对齐」必须有参照物 —— 没有基准线时,肉眼判不出「圆点在不在图形中心」。.github/ISSUE_TEMPLATE/bug_report.yml新增「★ 出错的位置在几分几秒?」字段。tests/geometry-fixture/:几何体检的证伪样本 —— 一个文件里塞进 issue #1 的三类症状 (越界 / 遮挡 / 错位),期望 ERROR 2 / WARN 0 / INFO 1。检查类工具必须能被证伪: 拿合格项目报 0 ERROR 是待证实而不是通过(见references/lessons.md#76)。references/frame-contract.md新增「几何:两类不会报错的错,和它们的确定性判据」一节, 含一个几何体只准有一个坐标系(SVG 图元与 HTML 部件不得混用两套基准); 第 6 条把安全线口径写清(170px 硬底线 / 176px 设计基准)。- SKILL.md 新增「★ 画面出错怎么定位」一节(报时间点 → 定位 → 看图 → 改 → 局部重渲 四步),
质量标尺补「几何 ERROR 清零」;
references/lessons.md新增 #76–#78。
- ★
qc_check.py的帧数校验在部分 ffmpeg 构建上被静默跳过。 旧实现只从 ffmpeg 的 stderr 里找frame= …stats 行,而那条 stats 行打不打印、字段前缀长什么样,随 ffmpeg 构建而变 —— 有人用 gyan.dev 7.0 full build 时 stderr 里一条都没有, QC 报告里只剩一句⚠ 无法实测视频流帧数(跳过帧数校验),帧数校验形同不存在。 现在同时读-progress pipe:1的机器可读流(跨版本稳定),两条通道任一命中即可。 报这条的用户是自己发现的,不是我们发现的。
1.3.2 — 2026-09-25
- ★ 渲染器的字幕层/进度条是「白字 + 黑描边」写死的,做亮底(浅色背景)片时字幕直接看不见。
症状:帧 HTML 全对、
lint_frames.py全绿,渲完 4950 帧才发现宣纸底的场景字幕读不出来 —— 坏的是render_video.mjs注入的OVERLAY_CSS,与帧主题无关。 现在字幕层改为读主题变量:--mg-sub-fg/--mg-sub-stroke/--mg-track/--mg-tick(各自带原来的写死值作 fallback)。主题文件里给亮底作用域补一份色即可,旧项目零影响。 详见references/lessons.md#75。
- ★ 确认点 0:配色 / 风格一律先问用户,禁止凭记忆拍板。 流程从「四个确认点」改为
「五个」。开工(乃至挑风格)之前必须先给用户 2–4 个候选,每条写清
① 色号 hex ② 一句气质描述 ③ 适合什么内容;风格模板同样要列候选,
不许静默从「上次挺好用」的记忆里定。用户自带色号时照他的落地,但要回报
「哪个色做哪个语义」请他确认。同步更新
references/workflow-guide.md。 references/lessons.md新增 #72–#75:双底混用(墨黑暗底 / 宣纸亮底)的正确做法 (把亮底做成一整套变量的作用域翻面 + 全片唯一一份明暗名单,组件 CSS 一个字不用改)、 两条由「抽变量」引发的 lint 假失败(var(--font-sans)会击穿中文字体族检查; 非纯黑白的rgba()一律算色值字面量,要变成主题变量)、亮底片的字幕层换肤、 以及确认点 0 的由来。
scripts/除render_video.mjs的OVERLAY_CSS(纯增量、带 fallback)外未动;assets/未动。已跑通的项目无需重跑,除非它用了亮底。
1.3.1 — 2026-09-23
-
★
tts_build.py走火山引擎时,project.json里的中文音色名没被解析成音色 ID, 合成恒失败。 症状是服务端返回code=55000000 message='resource ID is mismatched with speaker related resource', 报错信息完全看不出是「名字没解析」。 根因:resolve_voice_token()只在tts_setup.py/tts_volcano.py的 CLI 路径被调用,tts_build.py是把project.json的voice(如解说小明 2.0)原样透传给tts_volcano.synthesize()的,而speaker字段只认 ID。 修法(两层,任一层都能挡住):tts_volcano.synthesize()入口处补voice = resolve_voice_token(voice, DEFAULT_VOICE)—— 接口包是唯一知道音色表的地方,任何调用方都不该自己解析;tts_build.py读project.json后先解析一次,让日志与audio-manifest.json里记的是可直接复制的真 ID。
教训:「文档里写了有这一步」不等于「每条代码路径都有这一步」。
references/volcano-tts.md当时已写明「音色名解析走resolve_voice_token()」, 但那条路径实际没被接上 —— 光看文档会误判成「密钥/账号没开通音色」, 于是去逐个测音色(实测 7 个 2.0 音色全都可合成),白绕一圈。
-
★ 逐帧截图模式可选,照片类片子提速 5–13 倍。 起因是一条全片满幅照片的片子 实测只有 1.93 帧/秒(9356 帧要 84 分钟)。逐项拆解后发现瓶颈根本不在 seek (只占 21ms/帧),而在 PNG 编码(561ms,占 96%),且与画面内容强相关: 纯色/纯 CSS 图形帧只要 45ms,照片满幅帧要 582ms(13×)—— 这就是「CSS 类项目 几百帧/分钟、照片类项目掉到 1.9 帧/秒」这个量级差的全部原因。 更关键的是这段编码在浏览器进程内串行:并发 1/3/6 路的总吞吐实测 1.80 / 1.86 / 1.87 帧/秒 ——
--concurrency对帧数毫无帮助。截图方式 单页 3 页并发 体积/帧 对无损基准 Playwright PNG(默认,未变) 1.80 帧/秒 1.86 2.5MB 无损 --png-fast(CDPoptimizeForSpeed)7.36 10.45 3.0MB 无损(PSNR 99dB、最大差 0) --jpeg --jpeg-quality 9516.07 29.01(5 路 32.3) 0.54MB PSNR 41.65dB 新增开关(默认行为完全不变):
--png-fast、--jpeg-quality N(默认 82)、--crf N、--preset <名>。其中--crf/--preset顺带把「帧容器」与「成片码率」解耦 —— 旧行为里--jpeg会连带降成crf23/veryfast(那是"草稿"语义), 成片现在可以显式给--crf 18 --preset medium。选用判据(已写进
SKILL.md与references/workflow-guide.md): 帧里有满幅照片 → 换模式;--png-fast要无损,--jpeg --jpeg-quality 95要速度。 -
qc_check.py新增「实测视频流帧数」校验。 原先只比容器时长,而容器时长会被音轨撑起来: 帧序列中间若有缺口,ffmpeg 的 image2 序列会在缺口处停下且退出码仍是 0, 视频流只有前半段,Duration 却照常显示完整长度(假通过,见lessons.md#57)。 现在用-map 0:v:0 -f null -数真实帧数,与layout.json的total_frames比对; 不一致直接判 fail,并在报错里附上「找帧序列缺口 → 补齐 →--mux-only」的命令。 -
tts_build.py的日志与清单现在打印/记录解析后的音色 ID,便于直接对照官方文档排错。
1.3.0 — 2026-09-23
- ★ 火山引擎(豆包)语音合成 2.0 作为可选配音引擎。 原先只有 edge-tts;
现在
tts_build.py --provider edge|volcano,project.json记provider字段。 两个引擎产出的audio-manifest.json结构完全一致(boundaries均为「秒 + 原始坐标系」), 因此timeline_build.py/subs.py/ 渲染器零改动。 scripts/tts_volcano.py—— 火山 TTS 接口包(SDK 层)。走 HTTP Chunked 单向流式POST /api/v3/tts/unidirectional:与 edge-tts 形态同构(发一次、收一串), 故缓存 / 硬超时 / 退避重试 / 裁静音 / 两级时钟全部原样复用。 零第三方依赖(只用标准库urllib)。--check/--voices/--synth三个子命令。scripts/tts_setup.py—— 配音方案向导:选方案 → 缺密钥则生成tts.env模板并停下 → 测连接 → 选音色 → 写回project.json。输出NEXT_ACTION=…供 agent 判断下一步。tts.env.example—— 密钥模板(入库;真实tts.env被忽略)。
- ★ 密钥纪律:agent 不读、仓库不记、日志不吐、分发不含。 四条都由结构保证:
① 密钥只从
tts.env读取,而只有tts_volcano.py读它 —— agent 只调接口包; ② 异常信息过_redact()脱敏(只抹本次实际加载的密钥值与AKLT…形态 token, 保留 reqid/logid 以便排查); ③.gitignore新增tts.env/*.env/*.key/*.pem/secrets/; ④check_integrity.py新增「密钥防线」检查(gitignore 覆盖 + 遍历仓库抓非空密钥赋值与AKLT…token,空模板不算违规),CI 在无密钥环境下跑通。 - ★ 修掉一个真实的密钥外泄口:
package_skill.py原先不排除tts.env—— 技能目录里若放了这个文件,打包会把用户的 API Key 一起塞进可分发的 zip。 已加入EXCLUDE_NAMES/EXCLUDE_SUFFIX(tts.env、*.env、*.key、*.pem), 模板tts.env.example保留。已实测:打包后 zip 内无密钥文件、内容级扫描无密钥串。
tts_setup.py的方案选定后未落盘:用户选火山 → 生成模板 → 填密钥 → 回来跑--check, 此时project.json还写着edge,会静默去测 edge 并报「就绪」。现在方案一旦确定立刻写盘。--check现在先打印测试方案:<provider>,避免「测的其实不是你以为的那个」。ensure_env_file()的查找顺序改成与tts_volcano.env_candidates()一致 (项目目录 → 技能目录 → 新建于项目目录),消除「向导认技能目录的、接口包认项目目录的」错位。
v1.3.0 在合并前用真实 API Key 跑了完整链路(向导 → 真合成 → timeline → subs → 打包), 又抓出 4 个只看代码发现不了的问题。以下都已修复并实测通过;v1.3.0 此前从未发布,故不另起版本号。
- ★ 字级时间戳必须显式开
audio_params.enable_subtitle: true。 官方文档的响应示例里sentence.words[]是有值的,容易误以为「默认就给」——实测不传这个参数words恒为空数组 (同文本对照:false→words=0,true→words=10)。而且它完全不报错:音频正常、 时长正常、sentence.text也正常。下游subs.py会静默退回按字数插值,成片照样出、 字幕开始飘。现已在接口包默认开启,并在拿到音频却没有时间戳时返回warning让tts_build.py打到台面上。该能力仅豆包 2.0 的中英文音色支持。 - 计费字数永远是 0:
usage.text_words挂在结束标记那一行(code=20000000), 而解析循环先判结束标记就continue了,那一行从没被读完。同时补上X-Control-Require-Usage-Tokens-Return: *请求头(不传该头服务端根本不返回usage)。 修后实测:计费 20 字与文本逐字对上。 - ★ 命中缓存时丢失首裁量 → 首跑与重跑产出不一致。 词边界是原始坐标系,下游靠
lead_cut_sec平移;缓存只存了边界数组,于是重跑lead_cut_sec=0、首跑是 0.037 ——字幕整体晚一帧,且只在第二次跑时出现。缓存格式改为{"boundaries": [...], "lead_cut_sec": x},并对旧版裸列表格式做兼容读取。 - CLI 把中文音色名原样当
speaker发出去:--voice "云舟 2.0"被服务端拒绝, 报的是55000000 resource ID is mismatched with speaker related resource——完全看不出是「名字没解析」。现把resolve_voice_token()(名称/序号/ID)与parse_rate_percent()(+8%/8/0.08)都收进接口包,tts_setup.py改为委托, 消除两份实现跑偏的可能。 - 火山项目的
rate会沿用new_project.py写的 edge 格式+8%,现按引擎清洗 (火山存 int 百分比);--status在rate=0时不再误报「未设置」(0是 falsy)。 check_integrity.py缺模板时新增提示:若同目录存在tts.env,直接说明 「疑似把模板改名成了 tts.env」(实测踩到的真实用法)。
- 火山接口的坑已处理并写进文档:结束标记
code=20000000(不是 0,误判会丢最后一段音频)、 单请求文本上限 ~200 字(超了按标点切分并按实际时长平移词边界)、 字级时间戳需显式开audio_params.enable_subtitle(见上)。 openspeech.bytedance.com是境内端点,接口包默认绕过系统代理直连;需要时设VOLC_PROXY。- edge 独有的「相邻数字补逗号」对火山不启用 —— 它会改动送进去的文本, 影响词边界与解说词的逐字对齐,而火山不熔读相邻数字。
- 真机联调的验收口径:3 段共 20 块字幕,每块起点与其首字词边界的误差均为 0.000s; 首跑与缓存重跑的 manifest 深度 diff(982 个字段)完全一致; 打包 zip 38 个成员,密钥文件名与内容级扫描双无命中。
1.2.3 — 2026-09-22
render_video.mjs --only <场景id,场景id>—— 改完某一场的文案或动效后,只重渲这一场的帧, 其余场景的帧原样保留。实测:14 场 6194 帧整片重渲约 20 分钟,只重渲末场 78 秒。 脚本会检查被指定场景是否为末场并给出警告(非末场一旦时长变化,其后场景帧号会整体前移)。references/lessons.md第 43–45 条:结尾写法红线(同义反复 / 自指复读 / 鸡汤留白三类废句, 及「勾选框全空的待办清单」这一替代写法)、极简帧的信息重复、--only的两个前提与必做清理。
- ★ 合成阶段的
-shortest会砍掉视频尾部。 音轨通常比画面短(段尾静音 + 片尾留白),-shortest按较短的那条流收尾,于是片尾字幕与片尾文字面被截短。 实测某片:片尾设计 1.5s,成片只剩 1.16s(成片比 layout 短 0.52s)。 已改为-af apad -t <renderSec>:音轨不足的部分补静音,画面长度由 layout 唯一决定, 不再受音轨长度左右。 scripts/qc_check.py抽帧判据改为「体积 + 内容」双条件: 体积低于本片中位数 35% 时不再直接判可疑,先看该帧的颜色数与标准差 —— 片头淡入起点、纯文字面这类合法的极简画面体积天然小,旧判据会稳定误报。qc_check.py时长差文案:原先「成片差 0.48s」会被读成缺陷, 实际是段间 gap 与片尾留白的设计值;已改为分别列出「音轨 / layout 期望 / 成片」三个口径。
lessons.md第 33 条与本次改动自相矛盾,已同步修正:该条原写「render_video.mjs没有单场景开关」(本次新增的--only正是此开关),且给出的主序列合成命令带-shortest并注明「与渲染器逐字一致」——照抄会把上面刚修掉的片尾截断 bug 原样装回去。 现改为:末场优先用--only,临时项目法保留给「被改场景不在末位」的情形;合成命令同步换-af apad -t <renderSec>并加一段「别用-shortest」的说明。--only的示例场景名由本项目专有的coda换成通用占位<场景id>。
1.2.2 — 2026-09-21 — 首个公开版本
scripts/lint_frames.py—— 渲染前的画面静态体检。在花时间渲染整片之前,先检查八条 画面契约与已知坑:外部字体 / CDN 引用、硬编码颜色字面量、缺失window.__tl注册、 CDN 版 GSAP、墙钟逻辑(setInterval、requestAnimationFrame计数)、CSStransition入场、B('…') || 数字兜底写法、侵入字幕禁区的内容、缺失中文字体族。scripts/peek_frame.mjs—— 单帧速览。把一个场景 seek 到若干时点截图,耗时是秒级, 于是不用渲全片就能判断画面设计。--at收的是百分比,不是帧号。- 双语项目 README(
README.md中文为默认,README.en.md英文)。 - 开源治理文件:
LICENSE(MIT)、THIRD_PARTY_NOTICES.md、CHANGELOG.md、CONTRIBUTING.md、licenses/Apache-2.0.txt、.gitignore、.gitattributes、 GitHub issue / PR 模板,以及 CI 工作流。
speech_end_sec在冷跑与缓存重跑之间不一致(lessons #31)。_result()量的是已经裁过 静音的输出文件,于是头部裁剪量被减了两次:冷跑报7.541s,缓存重跑报7.696s—— 0.155s 的差异破坏了「两次渲染逐帧一致」的保证,并让末块字幕提前消失。现在统一存裁后 坐标系下的值。
- 文档:
scripts/lint_frames.py与scripts/peek_frame.mjs补进命令序列与文件表 (两者此前都没有被文档记录)。 - 文档:lint 现在是工作流里的显式阶段,位置在生成字幕之后、渲染之前。
- 文档:删除对私有客户项目的引用,使技能可以公开发布。
- 文档:README 精简 —— 移除面向智能体执行的七条命令序列与「命令参考」表(这些属于
SKILL.md的职责),改为只向使用者交代视频项目结构与产物位置;同时删去「路线图」 与文末作者签名。-150 行,中文 364→295 行、英文 390→319 行。 - 许可:风格目录衍生自 Apache-2.0 内容,现已带上完整的来源署名。
- 从实战回填:预览渲染会清空帧目录(
--keep-frames)、重渲单个场景的临时项目法、 以及「开场 0.5 秒内就必须出现主角」的冷开场规则(lessons #32–#34)。
四个渲染 / QC 正确性修复,全部是靠给渲染器加探针找出来的,而不是靠肉眼看。
详见 references/lessons.md #27–#30。
tl.pause(t)会静默吞掉本次 seek 的回调。 GSAP 的签名是pause(atTime, suppressEvents),第二参默认为true,于是 seek 触发的任何onUpdate/onStart/onComplete都被丢弃。用onUpdate写的计数器因此在成片里恒为 初值 —— 不报错、不告警、缩略图尺寸正确,静态检查也查不出来。渲染器与封面器现在调用tl.pause(t, false)。文档同时把数字滚动引导向纯 transform 的「数字卷轴」, 完全不依赖任何回调。- 传给
chromium.launch()的deviceScaleFactor被静默忽略。viewport与deviceScaleFactor都是 context 级选项,只在newPage()上生效。封面实际写成了 1 倍图, 而日志声称 2 倍。现在构建用 PIL 核验输出尺寸,不再信日志。 - QC 抽样只覆盖了视频前四分之一。
qc_check.py取字幕块起始时间后写成pts[:samples], 拿的是前 N 块而不是均匀铺开。一条 39 块的视频抽了 12 块,只覆盖 0.25–23.86s,后 6 个 场景一帧没抽,却报告「全部通过」。现在按步长在全片范围均匀抽样。 - 封面排版是在动画中途量的。 校验紧接页面加载后就跑,读的是
fromTo起始值而不是稳定 终态,于是报出幽灵偏移。现在测量前先置__MG_RENDER__并 seek 到tl.duration(), 与封面渲染器完全一致。
- 封面双方案。 每条成片现在产出两张独立排版的封面而不是一张:
cover_169.png(1920×1080,输出 3840×2160)用于信息流与播放器,cover_34.png(1440×1080, 输出 2880×2160)用于主页栅格。scripts/cover_build.mjs—— 封面渲染器,复用场景渲染器的确定性 seek 内核,但有三处 刻意不同:不带字幕层、只 seek 到单个「封面时刻」(默认--at last)而不是逐帧步进、 以及默认 2 倍输出。assets/cover-template.html—— 封面模板,三个必需元素写在文件头注释里。references/cover-guide.md—— 重排对照表、尺寸规则、上传策略与自检清单。
scripts/new_project.py现在会把两份封面 HTML 一起生成到新项目里。
- 两张封面是共享同一套视觉基因的姊妹版式,绝不是一对裁切。从 16:9 裁 3:4 会丢掉 57.8% 的画面宽度(1920 → 810px),必然把大字钩子切掉。重排改的是构型本身:悖论视觉 从「右侧左右并置」变成「上方竖排堆叠」,钩子从两行变成三行。
- 核心流水线:
new_project.py→tts_build.py→timeline_build.py→subs.py→render_video.mjs→qc_check.py。 - 确定性 seek 渲染器(
render_video.mjs)。不录活页面的屏,而是每一帧都靠 seek GSAP 时间轴(tl.pause(t, false))并同步 CSS 动画 (document.getAnimations().currentTime = t*1000)后再截图。这消掉了实时录制的一整类 失败:没有起播竞态、没有字体加载超时、没有不稳定的引导期。 B('块文本')节拍同步。 画面动画按匹配字幕块文本定位,而不是写死帧号,于是改解说词 会重排全片时序,而一个画面文件都不用动。scripts/subs.py—— 字幕三出口(运行时 JSON、SRT/VTT 外挂、渲染器注入的画面内层), 外加每场景生成一份beats.js。scripts/make_theme.py—— 四个预设 + 按主题词推色,写出唯一颜色来源theme.css。scripts/qc_check.py—— 流 / 时长 / 响度 / 抽帧体检,附抽帧速览图。scripts/import_styles.py—— 移植期一次性工具,把模板设计规范转写成references/style-catalog.md/.json。- 23 种画面风格目录,8 个类别,按改编成本分类。
setup_env.sh做首次环境自检,--install装缺失依赖;package_skill.py打可移植 zip。