feat(v1.2.3): 局部重渲开关、修片尾被截、QC 抽帧双判据

渲染器
- 新增 --only <场景id>:改一句文案只重渲这一场(14 场 6194 帧全渲 20 分钟 → 末场 78 秒)
- 新增 --mux-only:跳过截图,用现有帧重新合成
- 合成弃用 -shortest,改用 -af apad -t <renderSec>:音轨不足处补静音,
  成片长度由 layout 唯一决定(此前片尾留白连同末块字幕被按音轨长度切掉)

体检
- qc_check.py 抽帧判据改「体积 + 内容」双条件:体积低于中位数 35% 时再看
  色数与标准差,避免极简画面(片头淡入起点、纯文字面)稳定误报
- 时长差改列「音轨 / layout 期望 / 成片」三个口径,并把「成片比音轨短」定为故障

文档
- lessons.md 新增 35–45 条:入场动画的截止时刻、共享 kit.css、数字卷轴写法、
  结尾三类废句与「勾选框全空的待办清单」、极简帧信息重复、--only 的两个前提
- lessons.md 第 33 条同步修正:单场重渲已有开关、合成命令不再用 -shortest
- workflow-guide.md 新增「改一句文案之后」子流程

版本号同步至 1.2.3(SKILL.md / 双语 README / CHANGELOG / issue 模板)
This commit is contained in:
MohZhuang committed 2026-09-22 18:14:11 +08:00
1 parent ae9612a185
commit 3aec9f6701
9 files changed
+304 -36

No files matched your search

+1 -1
View File
@@ -69,7 +69,7 @@ body:
attributes:
label: 版本
description: SKILL.md 里的 `version:` 字段,或发布 tag。
placeholder: "1.2.2"
placeholder: "1.2.3"
validations:
required: true
+26
View File
@@ -9,6 +9,31 @@
---
## [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 期望 / 成片」三个口径。
---
## [1.2.2] — 2026-09-21 — 首个公开版本
### 新增
@@ -117,6 +142,7 @@
- 23 种画面风格目录,8 个类别,按改编成本分类。
- `setup_env.sh` 做首次环境自检,`--install` 装缺失依赖;`package_skill.py` 打可移植 zip。
[1.2.3]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.3
[1.2.2]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.2
[1.2.1]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.1
[1.2.0]: https://github.com/OneMoh/html-explainer/releases/tag/v1.2.0
+1 -1
View File
@@ -8,7 +8,7 @@ Write scenes in HTML → deterministic frame-by-frame rendering → a real MP4.
locally; the core pipeline needs no API key and charges no per-render fee.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.2.2-blue.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.2.3-blue.svg)](CHANGELOG.md)
[![Agent Skill](https://img.shields.io/badge/Agent%20Skill-SKILL.md-8A2BE2.svg)](SKILL.md)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-%E2%9C%93-D97757.svg)](#install)
[![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#install)
+1 -1
View File
@@ -7,7 +7,7 @@
HTML 写画面 → 确定性逐帧渲染 → 真 MP4。全本地跑,核心链路零 API key、零按次计费。
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.2.2-blue.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.2.3-blue.svg)](CHANGELOG.md)
[![Agent Skill](https://img.shields.io/badge/Agent%20Skill-SKILL.md-8A2BE2.svg)](SKILL.md)
[![Claude Code](https://img.shields.io/badge/Claude%20Code-%E2%9C%93-D97757.svg)](#安装)
[![Codex](https://img.shields.io/badge/Codex-%E2%9C%93-000000.svg)](#安装)
+3 -3
View File
@@ -1,6 +1,6 @@
---
name: html-explainer
version: 1.2.2
version: 1.2.3
description: 把任意主题做成「讲解/科普视频」并渲染成 MP4:调研→审查→解说词→字幕→edge-tts 配音→并行构建 HTML 场景→确定性逐帧渲染→成片后出双方案封面。**画面语言内置 23 个模板风格 / 8 个类别**(大胆信号卡、奢华极简、NYT 数据图表、瑞士网格、故障艺术、胶片漏光、流体 Hero、Logo 收尾、东方柔和有机、VFX 文字光标…共 23 种风格,含每种的画布/配色/字体/时间轴规范,见 references/style-catalog.md),流程规范与音画同步体系承自 anything2explainer(词边界字幕、两级时钟、语速标定、多 agent 分工与 QC 判据),渲染层为自研 seek 式渲染器。**封面双方案**:抖音主封面 1920×1080 + 兼容 3:4 的 1440×1080(独立重排,防主页栅格切字)。独立可移植:GSAP 内置、playwright-core 随包、ffmpeg 走 imageio-ffmpeg 回退、浏览器自动探测 Chrome/Edge;**不依赖 html-video / anything2explainer 任何代码或目录**。触发场景:要做科普/讲解/教学/知识/产品类视频、"讲一下 X 做成视频"、要用 html-video 那种模板化画面但更稳的音画同步、要挑某种视觉风格(极简/数据/赛博/电影感/品牌)出片、要出抖音封面/竖版封面、anything2explainer 换 HTML 渲染、或提到 html-explainer / HTML 讲解视频 / explainer video / MG 视频。
agent_created: true
---
@@ -72,7 +72,7 @@ PY=<venv python 绝对路径> # 派子 agent 时必须展开成绝对
"$PY" <skill>/scripts/timeline_build.py --project . # 全局时间轴:layout.json + narration-full.mp3
"$PY" <skill>/scripts/subs.py --project . # 字幕:subs.json + beats.js + srt/vtt
"$PY" <skill>/scripts/lint_frames.py --project . # 静态体检:八条契约违规(渲染前一秒出结果,比渲完再发现便宜得多)
node <skill>/scripts/render_video.mjs . [--preview 30] [--keep-frames] # 渲染:out/<slug>.mp4
node <skill>/scripts/render_video.mjs . [--preview 30] [--keep-frames] [--only <场景id>] [--mux-only] # 渲染:out/<slug>.mp4
"$PY" <skill>/scripts/qc_check.py --project . # 体检 + 抽帧速览图
node <skill>/scripts/cover_build.mjs . # 封面双方案:out/cover_169.png + cover_34.png
```
@@ -189,7 +189,7 @@ GSAP 用 `../assets/gsap.min.js`(本地内置)→ 主体动画压在 speech_
| `scripts/timeline_build.py` | layout.json 全局轴 + narration-full.mp3(gap 显式插入) |
| `scripts/subs.py` | 字幕三出口(subs.json / srt+vtt / 画面内层由渲染器注入)+ beats.js 节拍器 |
| `scripts/lint_frames.py` | **渲染前静态体检**:八条契约违规逐条报(外链字体/色值字面量/墙钟/`B()\|\|N`/字幕带压内容/缺中文字体族…) |
| `scripts/render_video.mjs` | 渲染器:浏览器探测 → 逐场景 seek 截图 → ffmpeg 合成;`--preview N` 快样片,`--jpeg` 草稿模式 |
| `scripts/render_video.mjs` | 渲染器:浏览器探测 → 逐场景 seek 截图 → ffmpeg 合成;`--preview N` 快样片,`--jpeg` 草稿模式,`--only <场景id>` 只重渲指定场景(**仅末场安全**,变短后须清尾部过期帧,见 lessons 45),`--mux-only` 用现有帧重新合成 |
| `scripts/qc_check.py` | 流/时长/音量/抽帧体检 + contact sheet |
| `scripts/cover_build.mjs` | 封面双方案渲染器:`width=1920/1440` 两份独立排版 → 2 倍图;`--at` / `--only` / `--jpg` |
+156 -4
View File
@@ -206,8 +206,10 @@
另注:完整渲(不带 `--preview`)不触发这段清理,所以「全片重渲」反而比「局部重渲」安全。
33. **★(2026-09-21 新增工作流)改单场画面后「只补渲这一场」的标准做法 —— 临时项目法**:
`render_video.mjs` **没有单场景开关**,`order` 就是全部场景,改一帧就要全片重渲(一条实测片 3802 帧 ≈ 15 分钟)。
想省时间,用临时项目精确复现该场景的**全局帧号**,渲完贴回主序列:
`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/` 相对路径靠目录深度对齐);
@@ -222,9 +224,13 @@
5. 用主项目的 `ffmpeg` 口径重合成(见下),再跑 `qc_check.py`。
本片实测:hook 291 帧 + outro 453 帧≈ 90s+130s,相比全片重渲 924s 省掉约 12 分钟。
**主序列的合成命令**(与 `render_video.mjs` 逐字一致,`-shortest` 会让视频收在音轨长度):
**主序列的合成命令**(与 `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 -shortest`
-pix_fmt yuv420p -movflags +faststart -c:a aac -b:a 160k -af apad -t <renderSec>`
> **别用 `-shortest`。** 它按音轨长度收尾,而 layout 的 `video_duration_sec` 比音轨多出
> 一段片尾留白(末块字幕的收尾余韵),`-shortest` 会把这段留白连同最后一块字幕一起切掉。
> 正解是 `-af apad -t <renderSec>`:音轨不足处补静音,成片长度由 layout 唯一决定。
34. **★(2026-09-21)冷开场:短视频前 0.5 秒必须有主体,`opacity` 淡入是最慢的入场**:
本片 hook 初版把三行巨型标题放在 `t1(1.154s,第一句解说词起播)`,配 `power3.out` 0.72s 淡入。
@@ -237,3 +243,149 @@
`qc_check.py` 的抽帧体积列可以直接当「冷开场是否空屏」的回归指标用。
附:`peek_frame.mjs` 的 `--at` 是**百分比**不是帧号(`--at 2,5,10` = 2%/5%/10% 轴长),
查冷开场要传 `--at 1,3,6` 这种小的百分数。
35. **★(2026-09-21)入场动画有「截止时刻」,不是「起始时刻」——挂在最后一句上的收尾元素等于没出现**:
介绍片的 outro 结尾有 3 个 chip(`MIT © 2026 Moh` / `v1.2.2` / `23 种风格`),
挂在了最后一拍 `B('去试试吧') = 5.763s`,而该场景帧长 6.53s、成片实际收在 107.65s
—— 留给它们的只有 **0.35s**(还要叠 0.07s×2 的 stagger)。抽帧看:整片结束前它们只露出半个头。
病根是把「这句解说词在说什么」当成了动画时点,而**帧尾还有 `tail`(约 0.15s)+ 音轨与视频
的 0.4s 差**,最后一拍的落点几乎贴着片尾。
**规则:任何入场动画的最晚触发点应 ≥1.2s 早于 `speech_end`**;越靠后的元素越要挂到
前一句(本片改挂 `B('仓库地址在简介里') + 0.45`,获得 1.45s 展示时间)。
想在最后一句「再给一次响应」而不占用时间,就用**已经在场元素**的微动作(如 `.repo` 的
`scale 1 → 1.03 → 1`),不要引入新元素。
**★ 配套的验收方法(比 `qc_check` 的默认抽帧更狠)**:`qc_check.py` 只均匀抽 12 帧,
本片最后一个样本落在 **92%** 处(99.55s / 107.65s)——片尾三秒根本没被看到。
补一招:**按 `layout.json` 算出每个场景 `speech_end` 前 0.15s 的全局时刻,
逐点抽帧拼成一张 contact sheet,一次过 11 个场景的「终态」**。本片 11 帧一次过,
只有 outro 露出问题。命令形状(ffmpeg 走 `imageio_ffmpeg`):
```bash
FF=$(python -c "import imageio_ffmpeg;print(imageio_ffmpeg.get_ffmpeg_exe())")
for t in 6.9 17.8 31.2 44.5 51.9 60.7 71.8 79.6 89.6 100.6 107.35; do
"$FF" -y -loglevel error -ss $t -i out/<slug>.mp4 -frames:v 1 "render/end_check/e$t.png"
done # 再用 PIL 拼成一张 4 列 contact sheet 肉眼看
```
36. **(2026-09-21)多 agent 并行建帧前,先落一份 `frames/kit.css` 共享视觉基座**:
本片 11 个场景、3 个并行 agent,**`lint_frames.py` 一次过、零违规、跨帧风格统一**,
与前几片动辄返工形成对比。做法:把幕底(`.bg`/`.grid`)、章节角标(`.sec`)、
卡片(`.card` / `.card--focus` / `.card--dim`)、文字层级(`.h1/.h2/.h3/.body/.tiny/.mono`)、
配件(`.chip/.tag/.rule`)、禁区之上的信息条(`.foot`,`bottom:186px`)、
媒体卡(`.shot`)一次写死,帧文件只引用 class。要点:
- `kit.css` **不是**第四份契约文件,它和 `theme.css` 一样是共用层 —— 帧里
`<link ../theme.css>` 之后紧跟 `<link kit.css>`;**`lint_frames.py` 只扫 `frames/*.html`,
不扫 `kit.css`,所以共享层里更要自律**(本片 `kit.css` 同样只用 theme 变量)。
- 派 agent 的 prompt 里把「底对齐用 `top` 定位、内容 `bottom ≥ 180px`、禁色值字面量、
`B()` 取 `frames/<id>.beats.js` 原文」写成硬要求 + 违反即返工,比事后 lint 返工便宜得多。
- agent 会自行补「居中章节标题」等版式细节 —— 只要风格统一就放行,别逐帧纠偏。
**配额提醒**:本片一次全片重渲 3242 帧花了 780s;而上面第 33 条已经写好了
「临时项目法按全局帧号补渲再贴回」的完整流程(约省 12 分钟)。
**发现只有一两个场景要改时,先翻第 33 条,不要直接全片重渲。**
37. **(2026-09-22)帧内的「统一生长」辅助函数必须用 `querySelectorAll`。**
常见写法 `function grow(sel, t){ var el = document.querySelector(sel + ' .bar'); ... }`
传单个 id(`'#s1'`)没问题,但传**多匹配选择器**(如 `'.row:not([id])'`)时
只会动到第一个元素 —— 其余柱子永远停在 `width:0`。
**症状极具迷惑性**:零报错、零 lint 违规、帧也不静止,只是「有几根柱子丢了」。
正解:`querySelectorAll(sel)` + 逐项 `offset`,`tl.fromTo()` 在循环内各自注册。
→ 凡是「一组元素一起动」的辅助函数,一律 `All` + `for`,不要图省事。
38. **绝对定位元素的 `top` 是相对**它自己的定位框**,不是相对页面。**
compare 帧要把徽标放到「双卡下方」,直接写 `top:384px` → 实际落在
`容器 top(196) + 384 = 580`,跑进了下一区块并压住柱状图。
正确值 = `容器高度 + 留白`(本例 `176 + 6 = 182px`)。
→ 给绝对定位子元素写坐标前,**先确认它挂在哪个定位祖先上**;
`peek_frame` 出图后拿实际像素反查一次最稳。
39. **数字卷轴(odometer)在 seek 渲染下的可靠写法。**
第 9 条说「数字滚动用纯 transform 数字卷轴」,这里给出可直接抄的实现:
```html
<span class="reel"><b class="rack"><i>0</i><i>1</i>…<i>9</i></b></span>
```
```css
.reel { display:inline-block; height:1em; overflow:hidden; }
.reel .rack { display:block; }
.reel .rack i { display:block; height:1em; line-height:1em; font-style:normal; }
```
```js
tl.fromTo(rack, { y:'0em' }, { y:'-3em', duration:1.5, ease:'power2.out' }, t);
```
要点:① 每位数字一个 `.reel`,`y` 走到 `-N em` 即显示数字 N;
② **完全不用 `onUpdate` 回调**,因此不受 `tl.pause(t, true)` 抑制影响(对比第 27 条);
③ 外层数字用 `display:flex; align-items:flex-start` 保证多位等齐,
货币符号等窄字用 `align-self:center` + 小字号,别混进 rack。
本片 liverl 的 30 / 347 / 75 三个数都靠它,终态逐位正确。
40. **暗底上的光晕别写 `rgba()` 字面量。**
`box-shadow: 0 0 40px rgba(214,137,81,.45)` 会被 `lint_frames.py` 按契约 3 拦下。
直接 `box-shadow: 0 0 40px var(--signal)` / `var(--accent-glow)` 即可 ——
theme 变量本身就是带 alpha 的色,效果一样且换肤自动跟随。
41. **`peek_frame --at` 是百分比,判「元素缺失」前先换算成秒。**
`--at 92` ≈ `时长 × 0.92`。本片 models 的 `20×`、perf 的底部结论条、
compare 的 `1/20 ~ 1/60` 都在 peek 时点之后才入场 ——
看到「应该有的东西没有」时,先 `时长 × 百分比` 算一下时点,再决定要不要改版。
想复核终态直接给 `--at 99`。
42. **亮底「编辑插页」是最好用的节奏工具。**
全片 10 场景里只把 2 个纯数据场景(排名柱图、差距图)做成亮底
(`--paper` + `--ink` + `--accent-on-paper`),其余全暗底 ——
比「全亮」或「全暗」都更抓人,且换肤只动 `theme.css` 一处。
注意:亮底帧**必须自己铺 `--paper` 背景**,否则会露出暗色幕底。
43. **★ 「A 或者不 A」不是选择,是同义反复 —— 结尾最常见的废句。**
真实案例:结尾三行写成 `结婚或者不结婚` / `买房或者不买房` / `留在城市,或者回到县城`,
观众读完的反馈是「说了跟没说一样」。**因为它的信息量严格为零**:
每条都穷尽了全部可能性,等于什么都没说,只是把句法凑得像选项。
同类废句还有两种,一起拉黑:
| 废句类型 | 例子 | 为什么废 |
|---|---|---|
| 同义反复 | `结婚或者不结婚` | 穷尽全部可能 = 零信息 |
| 自指复读 | `这就是一个普通人的一生` | 把标题当结论念了一遍 |
| 鸡汤留白 | `没有人知道你会走到哪里,包括你自己` | 听起来像总结,其实没有指向任何事 |
**正确写法:不总结、不升华、不判断,只把「还没有结果的具体的事」按它本来的样子列出来。**
落点必须是**可核查的具体物**(一条没回的消息 / 一栏空着的三方协议 / 下周的复查),
而不是处境的名字。判据一句话:**把这句话里的名词换成任何其他片子也成立,就是废句。**
结构上让它成立的做法是**前面埋钩子、结尾不回收**——
全片早段写过「还有一个,在等消息」,结尾不是给答案,而是让这条消息**仍然悬着**。
配套的画面写法(比文字更省事):把结尾做成一页**勾选框全空的待办清单**
(`☐ 那条消息 ⋯⋯ 未回`),每行随念到它的那一句落下,一条都不打勾。
「未完成」不用靠形容词,靠空框本身。
44. **极简帧里同一信息只准出现一次。**
上一条的同一个镜头里,全片固定的左下角日期戳是 `2026 · 你 21 岁`,
右栏又放了一个 `.min-age` 写着同一句 —— peek 出图才发现画面上有两处一模一样的字。
极简帧元素少,重复格外显眼。定稿前**通读一遍画面上所有文字**(含全局固定元素:
左上系列标、左下日期、右下场次),逐个确认没有同义重复。同类:右上小标题与场次标记撞词。
45. **`--only <场景id>` 局部重渲的前提,和它的必然后续。**
改一句旁白不必重渲整片(本片 14 场 6194 帧,全渲 20 分钟,重渲末场 78 秒):
```bash
node scripts/render_video.mjs . --only <场景id> # 只重渲这一场
node scripts/render_video.mjs . --mux-only # 用现有帧重新合成
```
两个前提,缺一个就会出错:
① **只有末场安全**。脚本会提示被改场景是否为末场;非末场一旦时长变化,
其后所有场景的帧号整体前移,必须整片重渲。
② **变短之后必须清掉尾部过期帧**,否则 ffmpeg 会把旧帧一起编进去,成片比 layout 长。
先算出新总帧数(`round((Σduration + gap×(n-1)) × fps)`)再删:
```python
for f in (FRAMES := pathlib.Path('render/frames')).iterdir():
if f.name.startswith('f_') and int(f.name[2:8]) > TOTAL: f.unlink()
```
验收口径(别只看渲染日志的「✓ 成片」):**帧号连续无缺口 + 成片时长 == layout 总长**。
本片收尾实测 6194 帧、0 缺帧、`Duration 00:03:26.47` 与 layout 的 206.47s 逐位一致。
+24
View File
@@ -98,6 +98,30 @@ node <skill>/scripts/render_video.mjs . # PNG 精渲 + 音轨 mux
"$PY" <skill>/scripts/qc_check.py --project . # 体检 + 抽帧速览图
```
### 改一句文案之后
成片已经渲完,用户对某一场的**文案**(旁白/画面文字)有意见时,不必整片重渲:
```bash
# 1. 改 narration.json → 重跑阶段 2 三条命令(tts 缓存命中其余段,只重合成改过的段)
python <skill>/scripts/tts_build.py --project .
python <skill>/scripts/timeline_build.py --project .
python <skill>/scripts/subs.py --project . # beats.js / subs.json 全量刷新
# 2. 改帧代码(build_frames.py 或对应 frames/<id>.html)→ 重建帧
python build_frames.py
"$PY" <skill>/scripts/lint_frames.py --project . && "$PY" tools/check_beats.py
# 3. 只重渲改过的那一场 + 用现有帧重新合成
node <skill>/scripts/render_video.mjs . --only <场景id>
node <skill>/scripts/render_video.mjs . --mux-only
```
**两个前提**(详见 `lessons.md` 第 45 条):`--only` 只对**末场**安全;
被改场景变短后**必须删掉尾部的过期帧**,否则成片会比 layout 长。
验收口径是「帧号连续无缺口 + 成片时长 == layout 总长」,不能只看渲染日志的 ✓。
> 改文案之前先自查一遍**这句是不是废话**——尤其是结尾。三类稳定废句
> (`A 或者不 A` 的同义反复、复读标题的自指句、鸡汤式留白)见 `lessons.md` 第 43 条。
## 阶段 6 · QC 与修复
先看 `out/qc_report.md`(FAIL 必须清零)+ `out/qc_sheet.jpg` 肉眼过:
+40 -14
View File
@@ -5,11 +5,12 @@ qc_check.py —— 成片体检 + 抽帧速览图。
检查项(全部来自两个血统项目的实战教训):
① 流:必须有 h264 video + aac audio 两条流
② 时长:成片 vs 音轨 vs layout.json 期望帧数 —— 差 >0.5s 判 FAIL
(差 ~0.9s×N 段 = 帧时长用了词边界而非容器时长的典型症状)
② 时长:成片 vs layout.json 的 video_duration_sec —— 差 >0.5s 判 FAIL
(差 ~0.9s×N 段 = 帧时长用了词边界而非容器时长的典型症状;
成片比 layout 短 ~0.5s = 合成时被 -shortest 按音轨长度截了,见 lessons #33)
③ 音量:成片 mean_volume 与源音轨差 >3dB 判 FAIL(静音轨/占位轨)
④ 抽帧:按字幕切换点 + 每场景中段抽帧,纯背景帧(体积过小)判 FAIL
(1920×1080 纯背景 PNG ≈ 90KB 上下;有内容通常 220KB+)
④ 抽帧:按字幕切换点 + 每场景中段抽帧;体积低于本片中位数 35% 且画面近乎
无内容(唯一色数 <300 且 sd <6)才判可疑 —— 极简页压缩后天然体积小
⑤ 速览图:6 列 contact sheet → out/qc_sheet.jpg(肉眼过一遍字幕带/构图)
用法:
@@ -117,10 +118,18 @@ def main() -> int:
report.append(f"- ✓ 与期望差 {d:.2f}s")
if os.path.exists(audio):
adur, _, _ = probe(ff, audio)
d = abs(vdur - adur)
report.append(f"- 音轨 {adur:.2f}s,成片差 {d:.2f}s")
if d > 0.5:
fails.append(f"音画时长差 {d:.2f}s —— 典型原因:帧时长用了词边界时长(每段少 ~0.86s)")
# 有符号差:正数 = 成片比音轨长。layout 的 video_duration 比音轨多出
# 一小段片尾留白(末块字幕的收尾余韵),成片略长于音轨是正常的;
# 反过来「成片比音轨短」才是故障——视频被 -shortest 截了尾部,见 lessons #33。
d = vdur - adur
if d >= -0.15:
report.append(f"- 音轨 {adur:.2f}s,成片 {vdur:.2f}s"
f"({d:+.2f}s,片尾留白,正常)")
else:
report.append(f"- 音轨 {adur:.2f}s,成片 {vdur:.2f}s"
f"(★ 成片比音轨短 {-d:.2f}s,末段画面被截)")
fails.append(f"成片比音轨短 {-d:.2f}s —— 合成时视频尾部被截"
f"(render_video.mjs 应改用 apad 补静音 + -t,见 lessons #33)")
# ③ 音量
sec("音量")
@@ -167,12 +176,29 @@ def main() -> int:
floor = max(12_000, int(med * 0.35))
report.append(f"- 抽了 {len(sizes)} 帧 → out/qc_frames/"
f"(本片抽帧体积中位数 {med // 1024}KB,可疑线 {floor // 1024}KB)")
for t, sz, _ in sizes:
flag = " ← 可疑" if sz < floor else ""
report.append(f" - {t:>6.2f}s {sz // 1024:>4}KB{flag}")
if sz < floor:
warns.append(f"{t}s 抽帧 {sz // 1024}KB,低于本片中位数 {med // 1024}KB 的 35%"
f" —— 疑似空画面(主角没渲出来?核对 out/qc_frames/ 该帧)")
for t, sz, p in sizes:
suspicious = sz < floor
note = ""
# 体积小 ≠ 空画面:极简页(片头淡入起点、纯底 + 单行大字)压缩后天然极小。
# 见 lessons #32。二次判定——真的去看画面里有没有内容。
if suspicious:
try:
from PIL import Image
import numpy as _np
a = _np.asarray(Image.open(p).convert("RGB").resize((320, 180)))
ncol = len(_np.unique(a.reshape(-1, 3), axis=0))
sd = float(a.std())
if ncol >= 300 or sd >= 6:
suspicious = False
note = f" (体积低但有内容:{ncol} 色 / sd {sd:.1f},判为极简画面)"
except Exception:
pass
flag = " ← 可疑" if suspicious else ""
report.append(f" - {t:>6.2f}s {sz // 1024:>4}KB{flag}{note}")
if suspicious:
warns.append(f"{t}s 抽帧 {sz // 1024}KB,低于本片中位数 {med // 1024}KB 的 35%,"
f"且画面近乎无内容 —— 疑似空画面/主角没渲出来"
f"(核对 out/qc_frames/ 该帧)")
else:
report.append("- 未能抽到任何帧")
+52 -12
View File
@@ -24,7 +24,15 @@
* 用法(项目目录 = 含 project.json 的目录):
* node render_video.mjs <projectDir> [--out out/<slug>.mp4]
* [--preview 30] [--fps 30] [--concurrency 3] [--jpeg]
* [--scale 1] [--keep-frames]
* [--scale 1] [--keep-frames] [--mux-only] [--only <场景id,场景id>]
*
* --mux-only:跳过截图,用 render/frames 里已有的帧直接重新合成
* (改合参 / 换音轨后不用重渲 20 分钟)
*
* --only coda:改完某一场的文案/动效后,只重渲这一场的帧,其余帧保持不动。
* 仅当被改场景是**最后一场**时安全;若它在中间且时长变了,
* 其后场景的帧号会整体前移,必须整片重渲。
* 变短后记得删掉尾部的过期帧,否则成片会拖长(脚本会提示实际帧数)。
*
* 环境解析顺序:
* 浏览器 env BROWSER_PATH → Chrome → Edge → playwright 自带 chromium
@@ -56,6 +64,8 @@ function parseArgs(argv) {
else if (t === '--jpeg') a.jpeg = true;
else if (t === '--scale') a.scale = parseFloat(argv[++i]);
else if (t === '--keep-frames') a.keepFrames = true;
else if (t === '--mux-only') a.muxOnly = true;
else if (t === '--only') a.only = String(argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
else if (t === '--browser') a.browser = argv[++i];
else if (!t.startsWith('--')) a._.push(t);
}
@@ -275,7 +285,16 @@ async function main() {
log(`场景 ${scenes.length} 个 · 总时长 ${renderSec.toFixed(2)}s · ${totalFrames} 帧 @${fps}fps`);
log(`画布 ${W}x${H} · ${args.jpeg ? 'JPEG 草稿模式' : 'PNG 精细模式'} · 并发 ${args.concurrency || 3}`);
const browser = await chromium.launch(launchOpts);
let browser = null;
if (!args.muxOnly) {
browser = await chromium.launch(launchOpts);
} else {
// 只重新合成:不启动浏览器,直接用 render/frames 里已有的帧
const have = fs.existsSync(framesDir) ? fs.readdirSync(framesDir).filter(f => /^f_\d+\.(png|jpg)$/.test(f)).length : 0;
log(`--mux-only:跳过截图,用现有 ${have}/${totalFrames} 帧重新合成`);
if (have === 0) die('render/frames 里没有帧,无法只合成');
if (have < totalFrames) log(`⚠ 帧数不足(${have}/${totalFrames}),成片会短于 layout`);
}
// 章节刻度(可选:project.json.chapters = [{title, startSegment}])
const chapterTicks = (pj.chapters || [])
@@ -345,16 +364,30 @@ async function main() {
}
// 场景级并发(默认 3;帧级不需要——同 page 顺序 seek)
const conc = Math.max(1, Math.min(args.concurrency || 3, scenes.length));
const queue = [...scenes];
const workers = Array.from({ length: conc }, () => (async () => {
while (queue.length && !errors.length) { const sc = queue.shift(); await renderScene(sc); }
})());
await Promise.all(workers);
await browser.close();
if (errors.length) die(`渲染失败:\n ${errors.join('\n ')}`);
if (!args.muxOnly) {
// --only a,b:只重渲指定场景的帧(其余场景的帧保持不动)。
// ★ 只允许改「帧号全在片尾、不影响其他场景偏移」的场景(通常是最后一场);
// 若被改场景变短,其后所有场景的帧号都会前移,必须整片重渲。
const only = args.only && args.only.length ? args.only : null;
if (only) {
const unknown = only.filter((id) => !scenes.some((s) => s.id === id));
if (unknown.length) die(`--only 里这些场景不存在:${unknown.join(', ')}`);
const firstIdx = Math.min(...only.map((id) => order.indexOf(id)));
if (firstIdx < order.length - 1)
log(`⚠ --only 包含非末场场景(${order[firstIdx]})——若其时长变化,其后场景帧号会整体前移,请整片重渲`);
log(`--only:只渲 ${only.join(', ')},跳过其余 ${scenes.length - new Set(only).size} 个场景`);
}
const queue = only ? scenes.filter((s) => only.includes(s.id)) : [...scenes];
const conc = Math.max(1, Math.min(args.concurrency || 3, queue.length));
const workers = Array.from({ length: conc }, () => (async () => {
while (queue.length && !errors.length) { const sc = queue.shift(); await renderScene(sc); }
})());
await Promise.all(workers);
await browser.close();
if (errors.length) die(`渲染失败:\n ${errors.join('\n ')}`);
log(`截图完成:${doneFrames} 帧,耗时 ${((Date.now() - t0) / 1000).toFixed(1)}s`);
log(`截图完成:${doneFrames} 帧,耗时 ${((Date.now() - t0) / 1000).toFixed(1)}s`);
}
// ---------- ffmpeg 合成 ----------
const audioPath = path.join(projectDir, 'audio', 'narration-full.mp3');
@@ -370,7 +403,14 @@ async function main() {
'-crf', args.jpeg ? '23' : '18', '-pix_fmt', 'yuv420p',
'-movflags', '+faststart',
);
if (hasAudio) cmd.push('-c:a', 'aac', '-b:a', '160k', '-shortest');
if (hasAudio) {
cmd.push('-c:a', 'aac', '-b:a', '160k');
// 音轨短于视频时补静音,而不是用 -shortest 砍掉视频尾部。
// layout 的 video_duration_sec 含片尾留白(末块字幕的收尾余韵),
// -shortest 会按音轨长度截视频,把这段留白连同最后一块字幕一起切掉
// (实测:片尾字幕从设计的 1.5s 缩到 1.16s)。改用 apad 补静音 + -t 锁视频长度。
cmd.push('-af', 'apad', '-t', renderSec.toFixed(3));
}
cmd.push(outPath);
log(hasAudio ? `合成:${totalFrames} 帧 + ${audioPath}` : '合成:无音轨(配音还没生成)');