Skip to content

Latest commit

 

History

History
948 lines (763 loc) · 66.7 KB

CHANGELOG.md

File metadata and controls

948 lines (763 loc) · 66.7 KB

变更日志

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 项检查:frontmatter description 超过 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 ff vs 89 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 个模板、封面流程一律未破坏。

新增 —— 动效(assets/motion.js)

  • ★ 动效库:把「镜头语言」变成时间的纯函数。40+ 动作词汇分 5 组:
    • enter(到场):riseWord / dropLetters / springIn / blurAway / riseFromMask / typeChars / checkOff / flyPlane
    • carry(承接):morphBox / irisOpen / diveInto / arcHop / gatherTo / railShift / sealDisc / burstWord
    • contact(接触):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 条断言)。

新增 —— 主题驱动模板编排(scripts/style_director.py)

  • ★ 读解说词,不填表:按位置 + 文本线索推断每场角色(开场/陈述/数据/原理/例证/反差/收束/落版)。
  • ★ 挑主风格:按角色 × 子类别 × 时长区间 × 内容词 × 受众 × 节奏打分。子类别级适配修正了 "类别太粗"(presentation 下有 11 个子类,讲数据用 hero 是错的)。
  • ★ 混用与局部替换:按能量预算给部分场搭次风格做局部元素替换(不是整场换皮)。
  • ★ 动态开头:opener_variant 由主题哈希轮换,不再是写死的那个。
  • ★ 动态转场:transition_out 由相邻两场能量差决定,不是清一色淡入淡出。
  • ★ 多样性约束:风格数上限 / 连续同风格上限 / 开场≠第二场;--pin 是硬约束。 只允许同类别内合并(跨类别会讲歪),合并代价 > 10 分就放弃合并并如实记录。
  • 防雷同:容差带内按主题哈希轮换 —— 同一题材每次换一批画面。--band 0 完全确定(供回归)。
  • 可解释:产物 style-plan.json 的 why 字段逐条说明"这场为什么被挑中"。
  • 自测 tests/test_style_director.py(60 条断言)。

新增 —— 画质/帧率档位与渲染提速(scripts/render_video.mjs)

  • ★ 成套档位 --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。

新增 —— 基准工装(scripts/bench_render.py)

  • 合成复杂度可控的项目(N 场 × M 动元素,--heavy 模拟高熵帧),同机多档位各渲一遍, 拉出「截图耗时 / fps / 体积」对比表 —— 优化前后同表对比,不靠感觉。

性能(本机实测:16 核 / Windows / Chrome / 纯 CSS 图形帧)

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:
    1. 档位覆盖 --fps 时未同步 layout._total.total_frames —— 渲染器采信声明帧数, 于是 4k60 按旧 fps 的帧数渲染、时长少一半,基准不可比。现按档位 fps 同步声明帧数、跑完还原。
    2. 某档无元数据时,报告阶段 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 条:
    • #90 B() 是前缀匹配(《》「」 不参与归一化)且失败时机在渲染期 → 校验器 + 反向验证
    • #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) = 6646 vs gf_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 音轨骗)。附一条排查纪律: 复算场景边界不要用 Python round() —— 银行家舍入 vs JS Math.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 与 解释器目录(Windows Scripts/python.exe 与 POSIX bin/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。 修法(两层,任一层都能挡住):

    1. tts_volcano.synthesize() 入口处补 voice = resolve_voice_token(voice, DEFAULT_VOICE) —— 接口包是唯一知道音色表的地方,任何调用方都不该自己解析;
    2. 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(CDP optimizeForSpeed) 7.36 10.45 3.0MB 无损(PSNR 99dB、最大差 0)
    --jpeg --jpeg-quality 95 16.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 计数)、CSS transition 入场、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)。

1.2.1 — 2026-09-20

四个渲染 / 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(), 与封面渲染器完全一致。

1.2.0 — 2026-09-20

新增

  • 封面双方案。 每条成片现在产出两张独立排版的封面而不是一张: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),必然把大字钩子切掉。重排改的是构型本身:悖论视觉 从「右侧左右并置」变成「上方竖排堆叠」,钩子从两行变成三行。

1.1.0 — 2026-09-20 — 首个打包发布

新增

  • 核心流水线: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。