@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +1109 -0
  2. package/README.md +76 -10
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/docs/styles/sheet-8-styles.png +0 -0
  5. package/docs/styles/sheet-before-after.png +0 -0
  6. package/index.js +4 -1
  7. package/package.json +7 -3
  8. package/schemas/film.schema.json +329 -2
  9. package/skills/video-studio/SKILL.md +270 -13
  10. package/skills/video-studio/assets/golden_frames.json +65 -0
  11. package/skills/video-studio/assets/scenes.py +2162 -1190
  12. package/skills/video-studio/assets/web/engine.js +63 -2
  13. package/skills/video-studio/assets/web/film.js +224 -101
  14. package/skills/video-studio/assets/web/index.html +4 -2
  15. package/skills/video-studio/assets/web/qa_text.js +36 -8
  16. package/skills/video-studio/references/audio-variation.md +4 -3
  17. package/skills/video-studio/references/audio.md +2 -2
  18. package/skills/video-studio/references/change-impact.md +1 -1
  19. package/skills/video-studio/references/quality-gates.md +25 -3
  20. package/skills/video-studio/references/scene-grammar.md +306 -0
  21. package/skills/video-studio/references/style-packs.md +108 -3
  22. package/skills/video-studio/references/v08-quality.md +256 -0
  23. package/skills/video-studio/references/web-engine.md +19 -2
  24. package/skills/video-studio/references/word-timeline.md +147 -0
  25. package/skills/video-studio/requirements.txt +14 -0
  26. package/skills/video-studio/scripts/filmcheck.py +218 -0
  27. package/skills/video-studio/scripts/filmkit.py +521 -38
  28. package/skills/video-studio/scripts/gates.py +88 -4
  29. package/skills/video-studio/scripts/grammar.py +559 -0
  30. package/skills/video-studio/scripts/preflight.py +774 -0
  31. package/skills/video-studio/scripts/qa_frames.py +49 -8
  32. package/skills/video-studio/scripts/qaasr.py +1563 -0
  33. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  34. package/skills/video-studio/scripts/render.py +764 -32
  35. package/skills/video-studio/scripts/scorekit.py +16 -30
  36. package/skills/video-studio/scripts/selfcheck.py +483 -0
  37. package/skills/video-studio/scripts/sfxkit.py +130 -14
  38. package/skills/video-studio/scripts/stagewatch.py +19 -6
  39. package/skills/video-studio/scripts/stylekit.py +242 -12
  40. package/skills/video-studio/scripts/voicekit.py +15 -7
  41. package/skills/video-studio/scripts/webengine.py +1119 -72
  42. package/tools/ci_check.py +173 -31
  43. package/tools/estimate.py +130 -19
  44. package/tools/golden_frames.py +664 -0
  45. package/tools/silhouette_qa.py +910 -0
@@ -0,0 +1,256 @@
1
+ # v0.8 三道新门禁:渲前预检 / 全片 VMAF / ASR 回读
2
+
3
+ > 本页只讲这三个**模块**(`G0` / `G11` / `G12`)。账本里 v0.8 还有第四行 `G10` 词级时间戳,
4
+ > 它的判据与关闭方式在 [word-timeline.md](word-timeline.md)。
5
+
6
+ > 三个模块、三道账本门禁,都是**可选**的(不拷对应 .py 就记 `skipped` 并写明原因,不会让渲染失败)。
7
+ > 三档语义沿用 v0.6:`pass` = 这次真的查了且通过;`fail` = 查了没过;
8
+ > `skipped` = **这次没执行**——它**不等于通过**,交付时要逐项说明为什么没跑。
9
+
10
+ | 模块 | 门禁 id | 什么时候跑 | 实测代价 | 关掉 |
11
+ |---|---|---|---|---|
12
+ | `scripts/preflight.py` | `G0` | `frames` 之前(任何 stage 开渲前) | < 1 s | `--no-preflight`(留痕,不算通过) |
13
+ | `scripts/qavmaf.py` | `G11` | `qa` 收尾,全片逐帧 | 29 s 片 **14.2 s**(同机负载高时 28.8 s) | `--vmaf off` / `"qa": {"vmaf": {"mode": "off"}}` |
14
+ | `scripts/qaasr.py` | `G12` | `qa` 收尾,转写成片音轨 | 29 s 片 **8.7 s**(faster-whisper small / CPU) | `"qa": {"asr": {"enabled": false}}` |
15
+
16
+ ---
17
+
18
+ ## 1. G0:渲染前资源预检(`preflight.py`)
19
+
20
+ ### 1.1 为什么要有它
21
+
22
+ 缺字形(✓ / emoji / 生僻字)和坏 TTS 音频都是"**渲染完才报**"的问题:`filmkit.glyph_missing_chars()`
23
+ 本身是准的,但它之前在 `layout` / `qa` 才被调用——等 870 帧渲完,才发现某句 `big` 里有个方块。
24
+ preflight 不新写检测器,只把现成检测**前移**,并把结论整理成「字符 + 字段 + 句 id」:
25
+
26
+ ```
27
+ [FAIL][preflight] 2 处问题(下面是前几条):
28
+ - l02.big:缺字形 '✓'(字体候选里没有这个字,渲染时会画成方框)
29
+ - l03.sub:缺字形 '🚀'
30
+ ```
31
+
32
+ ### 1.2 五个检查项(三档语义,**"没查"不许写成"通过"**)
33
+
34
+ | id | 查什么 | skipped 何时出现 |
35
+ |---|---|---|
36
+ | `script` | 剧本可解析、最小结构合法(title / lines / id 唯一 / text 非空) | 不 skip |
37
+ | `glyphs` | 所有上屏文本逐字过 `glyph_missing_chars`;**按字段实际绘制的字重**检测(big/kicker=bold、lead=light、sub=reg+light、items=reg、text=bold+reg、title/chapter=reg) | 不 skip |
38
+ | `tts` | `work/tts/*.mp3` **若存在**,必须能被 ffprobe 解析出音频流(坏文件/半截缓存在这里就暴露) | `work` 没给 / 还没有 TTS 产物 |
39
+ | `fonts` | 字体候选至少一个真的能加载(对 bold / reg / light 各取一次 `filmkit.font()`) | 不 skip |
40
+ | `web_page` | `engine="web"` 时入口页存在(`script.web.entry` 或 `<剧本目录>/web/index.html`) | pil 引擎 |
41
+
42
+ > 为什么要按权重分:`msyhbd / msyh / msyhl` 是三个文件,字形覆盖不完全相同。用错权重会把
43
+ > "只有 reg 缺、但该字段画 reg"误报成没事,或者反过来。
44
+
45
+ ### 1.3 单独跑 / 接 CI
46
+
47
+ ```bash
48
+ python preflight.py --script film.json --work work --engine pil --json
49
+ # --json 时 stdout 只有 JSON,人读提示走 stderr
50
+ # 退出码 0 = 没有 error;1 = 有 error;--strict 时 skipped 也算 error
51
+ ```
52
+
53
+ 它是**纯函数**:`check(script, *, work=None, engine="pil", timeline=None) -> {ok, errors, warnings, checks, engine}`,
54
+ 不打印、不落盘、不改入参。`render.py` 调 `_run_preflight`,有 error 就**在渲染前**返回非 0
55
+ (`--no-preflight` 可通过,但会在 QC 报告"本次绕过的门禁"里留痕)。
56
+
57
+ ---
58
+
59
+ ## 2. G11:全片 VMAF / SSIM(`qavmaf.py`)
60
+
61
+ ### 2.1 这条门禁的关键不是阈值,是**参考帧从哪来**
62
+
63
+ **参考帧必须由场景函数实时重渲染**(`scenes.draw_frame`),绝不能拿成片自己抽的帧当参考——
64
+ 那是"拿成片和成片比",VMAF 恒等于 100,门禁形同虚设。
65
+
66
+ 实现路径与 `frames` 阶段同一套绘制管线:`draw_frame -> draw_subtitle -> 片头/片尾淡入淡出`,
67
+ 逐帧生成 rgb24 经 **stdin 管道**喂给 ffmpeg 的 rawvideo 输入,与成片解码流做 libvmaf / ssim 比较。
68
+ **只渲一遍帧**:一次 ffmpeg 调用里用 `filter_complex + split` 同时算 VMAF 与 SSIM。内存恒定(不缓存整片帧序列)。
69
+
70
+ ### 2.2 三档语义与阈值
71
+
72
+ | 档 | 条件 | 账本 |
73
+ |---|---|---|
74
+ | `fail` | VMAF 均值 **< 92.0**(`qavmaf.VMAF_PASS`) | G11 = fail,阻断交付 |
75
+ | `pass` + warning | **92.0 ≤ VMAF < 96.0**(`qavmaf.VMAF_WARN`) | G11 = pass,但打印"建议复核 / 别降档" |
76
+ | `pass` | VMAF ≥ 96.0 | G11 = pass |
77
+ | `skipped` | 没拷 `qavmaf.py` / web 引擎(没有可重渲的参考帧)/ `--vmaf off` / 成片不存在 / 缺 timeline / 缺 filmkit / ffmpeg 无 libvmaf / 解码失败 / 比较 0 帧 | G11 = skipped,写明原因 |
78
+
79
+ **SSIM 是诊断项**,低于阈值只记 warning、不单独阻断(文本画面的 SSIM 对字幕边缘过敏感,VMAF 才是感知门禁)。
80
+
81
+ ### 2.3 阈值 92 / 96 是怎么来的(**只有一份权威定义**)
82
+
83
+ 判据来自 qavmaf 作者对**同一部模板片**(参考帧含字幕与片头片尾淡入淡出)的三组实测:
84
+ crf17 → **97.24**(pass)、crf30 → **95.74**(pass + warning)、二次编码 + 缩放坏片 → **29.45**(fail)。
85
+
86
+ **阈值只有一份**:权威定义是 `qavmaf.py` 的 `VMAF_PASS=92.0` / `VMAF_WARN=96.0`
87
+ (SSIM 0.95 / 0.98 只作诊断、不单独阻断)。`render.py` **刻意不再设第二套常量**——
88
+ 同一事实只留一个权威值,改阈值只改 `qavmaf.py` 一处。
89
+
90
+ 按片收紧或放宽:`film.json` 的 `"qa": {"vmaf": {"pass": 92, "warn": 96}}`(会透传给 qavmaf)。
91
+
92
+ > 参考:另一份 1080p **无字幕纯测试图**的 CRF 阶梯是 96.98 / 96.32 / 94.87 / **92.25** / 83.03 / 50.23
93
+ > (crf18 / 22 / 26 / 30 / 36 / 46)——它的 crf30 恰好压在 92 的失败线上。**素材不同不可直接比较**,
94
+ > 所以判据以带字幕的真实成片标定为准;这也说明"crf30 附近会抖动",交付时按片覆盖阈值或直接降一步 CRF。
95
+
96
+ **门禁反证(判据必须能失败)**:
97
+
98
+ | 素材 | VMAF | 判定 |
99
+ |---|---|---|
100
+ | crf17 正常片 | **97.24** | pass |
101
+ | crf30 正常片(模块默认 92/96 下) | **95.74** | pass + warning(低于 warn 96) |
102
+ | 二次编码 + 缩放坏片 | **29.45** | **fail** |
103
+
104
+ 也就是说:好片落在 95+、坏片直接掉到 29,中间没有灰区——阈值取 92/96(模块默认)时,
105
+ "crf30 该提醒"和"坏片该阻断"两件事同时成立。
106
+
107
+ **720p / 540p 的阈值沿用 1080p 标定**:分数不完全可比(降采样后比较分辨率变了),
108
+ qavmaf 会在报告里打 warning 提醒这一点,但**不因此改判**——实测同片 720p VMAF 比 1080p 只低
109
+ **0.12 分**(另一份独立素材测得 -0.22),说明"内部 1080p 构图 + 编码时 lanczos 下采样"
110
+ 确实是**超采样式降规格**,不是糊。
111
+
112
+ ### 2.4 分辨率:下采样合法,上采样是 fail
113
+
114
+ 结构性不符是 **fail**(不是"没法查"):
115
+
116
+ - 成片分辨率 **> 内部构图 1920x1080**:上采样不是合法交付(把 1080p 包装成"更高分辨率");
117
+ - 宽高比不是 16:9 横屏;成片尺寸与 `timeline.video` 声明的交付尺寸不一致;读不出成片分辨率;
118
+ - **下采样交付是合法配置**:内部构图恒 1080p,编码时 lanczos 缩到 720p / 540p。此时 qavmaf
119
+ **在成片分辨率下比较**——参考流在 ffmpeg 滤镜图里用 `scale=w:h:flags=lanczos` 缩到成片分辨率
120
+ (与 `FrameSink` 编码时同口径,不用 PIL resize),返回的 `ref_scale` 记录映射关系。
121
+ VMAF 阈值仍按 1080p 标定沿用(实测 720p 与 1080p 的 VMAF 差不到 3 分)。
122
+
123
+ ### 2.5 实测耗时与成本
124
+
125
+ | 项 | 实测 |
126
+ |---|---|
127
+ | 29.011 s 模板片全片(870 帧,1080p 交付) | **14.2 s**(同机负载高时同一份代码测到过 28.8 s,见下"成本口径") |
128
+ | 720p@24 交付(696 帧) | **7.2 s**(帧数少 + libvmaf 比较分辨率低) |
129
+ | 分项吞吐 | 参考帧渲染受 ffmpeg 消费速度限制;libvmaf threads=min(8, CPU) 时约 124–160 帧/秒(threads=0 只有约 20 帧/秒) |
130
+ | 抽样模式 | `--vmaf sample`(每 5 帧取 1)实测 8.0 s 跑完 184 帧,VMAF 与全片差 0.34 分 |
131
+ | 分数稳定性 | 对 n_threads **完全不敏感**(threads=0/1/4/8/16 与跨进程复跑的逐帧分数 sha1 全同) |
132
+
133
+ **成本口径要说清**:全片门禁 = 参考帧渲染 + 比较,模板场景 1080p 约 **0.5× 片长**(29 s 片 14.2 s);
134
+ 创意场景 `draw_frame` 更重(模板 6–10 ms/帧,复杂场景 30–50 ms/帧),整体按 **0.4–1× 片长** 估,
135
+ 12 分钟片按 0.4× 外推约 **5 分钟**。**同一台机器负载不同能差近 2×**(另一轮同代码实测 28.8 s),
136
+ 所以这里只写"这一跑"的数,不写承诺。纯 libvmaf 比较(参考帧已就绪)约 1.2× 实时——
137
+ **别把"纯比较的 1.2×"当成端到端成本**。
138
+
139
+ **v0.8 之后 qa 比渲染还贵**:29 s 模板片 `--stage all --cost` 实测总 **47.3 s**,
140
+ 其中 `frames` 12.5 s、`qa` 23.9 s(VMAF 14.2 + ASR 8.7 + 其余);v0.7 的全链约 28 s。
141
+ 决策影响:**批量出片的瓶颈从渲染转到质检**——想压回渲染量级就 `--vmaf off` 或 `--vmaf sample`。
142
+
143
+ ### 2.6 单独跑 / 可证伪性
144
+
145
+ ```bash
146
+ python qavmaf.py --film out/film.mp4 --timeline work/timeline.json \
147
+ --scenes scenes.py --script film.json --mode full --out work/qa/vmaf.json --json
148
+ # 退出码:0=pass;1=fail;2=skipped(CI 必须把 2 也当"没通过"处理)
149
+ python qavmaf.py --selftest # 内置自检:验证三档语义不许"没查写成通过"
150
+ ```
151
+
152
+ **证明它真的在查**:把 `crf` 从 18 调到 36 重渲一遍,G11 **必须**从 pass 掉进 fail。
153
+ 不掉就是参考帧/对齐环节没接对。
154
+
155
+ ---
156
+
157
+ ## 3. G12:ASR 回读(`qaasr.py`)
158
+
159
+ ### 3.1 为什么
160
+
161
+ v0.7 及以前的质检只看"容器 / 响度 / 帧 / 音画同步",都建立在一个假设上:
162
+ **成片音轨里念的就是剧本那几句话**。可是 TTS 缓存串了、换声线时漏了某句、混音把某段压没了、
163
+ 重新渲了但音轨是旧的——这些从容器和波形上都看不出来。把音轨转回文字逐句对账才能发现。
164
+
165
+ ### 3.2 引擎优先级与匹配方式
166
+
167
+ 1. **faster-whisper**(本机已装,import 失败才降级)→
168
+ 2. **ffmpeg whisper 滤镜**(ffmpeg 9.0.2-full_build 带 `--enable-whisper`,实测有 whisper 滤镜、没有 asr 滤镜)→
169
+ 3. 两者都不可用 → `skipped`(**不许把"没查"写成 pass**)。
170
+
171
+ 匹配:按 `timeline.json` 每段 `start/end` 取 ASR 结果里落在窗口内的文本,
172
+ 去标点 / 空白 / 数字写法差异 / 常用繁体(whisper 中文输出常夹繁体)后做**字符级多重集召回**。
173
+ - 字符级:中文没有词边界,词级召回不成立;
174
+ - 多重集而不是集合:"是"这种字重复出现要计数,否则少一句也能凑出高召回。
175
+
176
+ 每句还报 ASR 首字相对 `voice_start`(缺省 `start`)的偏移,`|偏移| > 0.35 s` 给 warning。
177
+
178
+ ### 3.3 三档语义与阈值
179
+
180
+ | 档 | 条件 | 账本 |
181
+ |---|---|---|
182
+ | `fail` | 字符召回 **< 0.72**(`render.QA_ASR_RECALL_WARN`) | G12 = fail |
183
+ | `pass` + warning | **0.72 ≤ 召回 < 0.85**(`render.QA_ASR_RECALL_PASS`) | G12 = pass,但提示"可能串了缓存" |
184
+ | `pass` | 召回 ≥ 0.85 | G12 = pass |
185
+ | `skipped` | 没拷 `qaasr.py` / `qa.asr.enabled=false` / 两个引擎都不可用 / 模型下载失败 / 成片没音轨 | G12 = skipped,写明原因 |
186
+
187
+ 按片覆盖:`"qa": {"asr": {"recall_pass": 0.85, "recall_warn": 0.72, "model": "small"}}`。
188
+
189
+ ### 3.4 为什么默认线是 0.85 而不是 0.95
190
+
191
+ 实测(29 s 模板片,含 "DeepSeek 4.1 Flash / DeepSeek Harness" 这类中英混排专名):
192
+
193
+ | 场景 | 召回 |
194
+ |---|---|
195
+ | 干净模板片 1080p(4 段全对上、缺句 0) | **0.803** |
196
+ | 干净模板片 720p@24(同剧本) | **0.853** |
197
+ | 干净模板片(另一轮) | **0.811 – 0.828** |
198
+ | 丢句反证:把 l03 从音轨剪掉、timeline 仍写 3 句 | **0.677**(`missing_lines=["l03"]`、gate=fail、exit 1) |
199
+
200
+ 0.803–0.853 不是流水线坏了,是 ASR 对中英混排专名的正常误差——实测把 "DeepSeek 4.1 Flash"
201
+ 听成过 "DIXX4.1flash"、"DeepX Carnacy" 等变体,但 4 段**全对上、缺句 0**。
202
+ 所以这条门禁的定位是**"抓整句丢失 / 音轨不是这一版",不是逐字校对**:
203
+
204
+ - **< 0.72 才说明"整句丢了 / 音轨跟剧本不是一套"**——这是真正要阻断的;
205
+ - 0.72–0.85 给 warning:仍然出片,但提示可能是缓存串了;
206
+ - **≥ 0.95 会把干净的模板片判成 fail**,所以不能拿短测试片的 0.985(19.87 s 纯中文素材,
207
+ 没有专名)当默认线。
208
+
209
+ ### 3.5 实测耗时
210
+
211
+ | 阶段 | 实测 |
212
+ |---|---|
213
+ | 模型加载(small,CPU int8) | 1.99 s |
214
+ | 转写 28.9 s 音轨 | 3.79 s(RTF ≈ 0.13) |
215
+ | 合计(第一次实测) | **6.18 s**(`elapsed_s`) |
216
+ | 合计(全链 `--stage all` 里) | 1080p 交付 **8.7 s** / 720p 交付 **6.4 s**(同机负载与交付时长不同) |
217
+ | 模型体积 | 484 MB(HF 缓存 972 MB = fp16 + int8 两份),内存峰值 < 1 GB |
218
+ | tiny 模型 | 只适合快速冒烟:加载 1.36 s、转写 0.50 s,但错字多、时间戳只到整秒 |
219
+
220
+ ### 3.6 两个已知限制(写下来别装看不见)
221
+
222
+ 1. **中英混排专名会掉召回**:这是判据本身的限制,不是片子坏了。真要更严就按片覆盖
223
+ `recall_pass`,但要先确认"专名被听错"和"真的丢了句"能区分开——用 `missing_lines` 定位。
224
+ 2. **偏移检查依赖 `voice_start`**:手搓的 timeline 若没有 `voice_start`,偏移会退化成"相对段 start",
225
+ 于是每句都多算一个 `lead`(默认 0.55 s)→ 全片报偏移 warning。`filmkit.build_timeline` 写的
226
+ timeline 一定带 `voice_start`,只有手搓才会缺。
227
+
228
+ ### 3.7 单独跑 / 可证伪性
229
+
230
+ ```bash
231
+ python qaasr.py out/film.mp4 --timeline work/timeline.json --script film.json \
232
+ --model small --lang zh --out work/qa/asr.json
233
+ # 退出码:0=pass;1=fail;2=skipped
234
+ # 阈值只有一份权威:qaasr.py 的 QA_RECALL_PASS=0.85 / QA_RECALL_WARN=0.72,
235
+ # render.py 的 G12 直接引用同一组常量(CLI 默认值与 --recall-pass/--recall-warn 同源)。
236
+ # v0.8.0 之前 CLI 默认是 0.95/0.90,于是同一部片 render 判 pass、单独跑 CLI 判 fail——
237
+ # 两套权威已合并,别再按旧口径传 0.95。
238
+ python qaasr.py --selftest # 不联网自检
239
+ # 离线回放(CI 用,不加载模型):
240
+ python qaasr.py film.mp4 --timeline ... --asr-json <已有 ASR 结果.json>
241
+ ```
242
+
243
+ **证明它真的在查**:拿一条音轨把某句剪掉、timeline 不动,G12 **必须** fail(实测召回 0.677、exit 1)。
244
+ 不 fail 就是窗口/对齐没接对。
245
+
246
+ ---
247
+
248
+ ## 4. 三道门禁一起关掉会怎样
249
+
250
+ `skipped` 是**合法的**(例如线上机器没装 faster-whisper、或 web 引擎路线),
251
+ 但交付前请把每一行 `skipped` 的理由抄进验收话术:**"没查"必须说出来,不能混进"通过"里**。
252
+ `--strict-gates` 会把所有 `skipped` 升级成失败——交付流水线里建议开着它跑最后一次。
253
+
254
+ 另外 `<REPO>/tools/golden_frames.py` 是**像素级**金帧回归(不进门禁账本):
255
+ `--check` 渲 3 帧算 SHA-256 与 `assets/golden_frames.json` 比对,
256
+ 同环境像素变了 = fail,换机器(字体指纹不同)= skipped。改绘制代码后先跑它,再跑 `selfcheck.py`。
@@ -110,7 +110,9 @@ assets/web/film.js 是 12 秒 / 3 个场景的完整模板:
110
110
  1. 不碰 Math.random / Date.now / performance.now;随机数走 FK.hash / FK.rng(seed)。
111
111
  2. 不读上一帧状态:每帧清空画布、从头按 T 重画(不要写"每帧位移 += speed"这种累加)。
112
112
  3. 字体固定:canvas 内 fallback 到 "Microsoft YaHei", sans-serif;文档字体加载完才 ready。
113
- 4. 视口固定 1920x1080、devicePixelRatio=1;画布 CSS 尺寸与分辨率一致,截图才是 1:1 像素。
113
+ 4. 视口默认 1920x1080、devicePixelRatio=1;画布 CSS 尺寸与分辨率一致,截图才是 1:1 像素。
114
+ (v0.8:`style/width/height/fps` 可让画布跟着交付规格走——只允许 **16:9 横屏偶数尺寸**,
115
+ 竖屏 / 4:3 / 奇数在 `open_page` 入口就 raise;尺寸由 `window.__STYLE__.canvas` 注入。)
114
116
  5. 动画相位只用绝对时间 T(如 Math.sin(T*2)),不要用帧计数差或挂钟。
115
117
  6. 离屏缓存(发光精灵)在 boot 阶段建好,之后只读不写。
116
118
  7. 质检项:自检里"同一帧两次截图 byte 完全相同"就是这条守则的守门员。
@@ -121,12 +123,27 @@ assets/web/film.js 是 12 秒 / 3 个场景的完整模板:
121
123
  |---|---|
122
124
  | 1920x1080 PNG 截图 + 页面绘制 | 约 70–120 ms/帧(2 workers 时 70–85 ms/帧均摊) |
123
125
  | 12 秒 / 360 帧 / 2 workers / chunk 3s | 28.0 s 出片(约 78 ms/帧均摊) |
124
- | 自检(24 项,含真实无头浏览器与跨会话确定性验证) | 约 15 s |
126
+ | 自检(**35 项**,含真实无头浏览器、跨会话确定性验证与 v0.8 风格注入断言) | 约 60 s |
125
127
 
126
128
  调参顺序:先降 workers(1–2 个足够,多了内存涨),再降 chunk_s(块小重渲代价低),
127
129
  最后才动 crf/preset(crf 17 / preset slow 是质量档;预览可用 crf 20 / preset veryfast)。
128
130
  PNG 截图比 JPEG q94 慢但无压缩伪影,本模块按契约固定 PNG。
129
131
 
132
+ ### 8.1 v0.8:跟随风格包(`window.__STYLE__`)
133
+
134
+ `--engine web` 以前画风写死深蓝橙,现在是「`style` plan 驱动」:`open_page` 在 `page.goto`
135
+ **之前**用 `add_init_script` 注入 `window.__STYLE__`(主题色板 / 版式 / 字体栈 / 画布 `w·h·fps`),
136
+ `assets/web/engine.js` 与 `film.js` 优先读它、取不到就退回 v0.7 的深蓝橙常量。
137
+
138
+ | 情形 | 行为 |
139
+ |---|---|
140
+ | `style=None`(pil 路线 / 显式关闭) | **连 `add_init_script` 都不调用**;页面里不存在 `window.__STYLE__`,像素与 v0.7 逐字节一致(自检有断言) |
141
+ | 页面不认 `__STYLE__`(老 `engine.js`) | **当场报错**并给出入口页与 grep 提示,不能"截图能出图"就当通过 |
142
+ | 注入的画布尺寸与页面实际画布不一致 | 报错(页面没按 `panel` 建画布);`fps` 注入了却与 `window.FPS` 不一致也报错 |
143
+
144
+ > 为什么要在 goto 之前注入:`engine.js` / `film.js` 是同步 IIFE,加载时就要读到 `__STYLE__`,
145
+ > 页面开始跑之后再注入就晚了。
146
+
130
147
  ## 9. 文字质检 qaText
131
148
 
132
149
  r = qa_text("assets/web/index.html", 0, 12, 0.2)
@@ -0,0 +1,147 @@
1
+ # 词级时间轴与词锚点(v0.8.0)
2
+
3
+ > 本页是 `work/words.json` 的**契约**、`cue at` 词锚点的**语义**,以及四个在真实跑里踩过的坑。
4
+ > 设计前提一句话:**句子时长仍以 TTS 实测时长为权威**;词级时间戳只是附加值,
5
+ > 用来折字幕、打锚点、让门禁发现"词事件突然没了"。
6
+
7
+ ---
8
+
9
+ ## 1. 数据从哪来
10
+
11
+ ```
12
+ film.json 的 "words": true(默认)
13
+ ↓ --stage tts
14
+ edge_tts.Communicate(text, voice, rate=, pitch=, volume=,
15
+ boundary="WordBoundary").stream()
16
+ ↓ 同一路 async for 里 chunk["type"]=="audio" 写文件 / 其余进 raw_events
17
+ work/tts/<id>.mp3.part --os.replace--> work/tts/<id>.mp3
18
+ ↓ decode_pcm + trim_silence_bounds(算出被裁掉的句首秒数 shift)
19
+ work/tts/<id>.mp3.json 的 meta:words / words_key / words_events / words_shift / words_ok
20
+ ↓ _write_words()
21
+ work/words.json ← G10、字幕折行、cue 词锚点**唯一**的事实来源
22
+ ↓ build_timeline(..., fps=)
23
+ timeline.json 的 scenes[].words_abs (句内词表平移到绝对时间)
24
+ ```
25
+
26
+ 三道防呆,缺一条都会出问题:
27
+
28
+ | 防呆 | 为什么 |
29
+ |---|---|
30
+ | 显式 `boundary="WordBoundary"` + `stream()` | 用 `save()` 会把词事件丢掉;不传 `boundary` 时默认 `SentenceBoundary`,**一条词事件都收不到**(实测 edge-tts 7.3.1 / zh-CN:词事件数 = 0) |
31
+ | 音频先写 `.part` 再 `os.replace` | 旧写法直接 `open(目标, "wb")`,一次断网/超时就把**已缓存的好音频截成 0 字节**。现在失败只丢本次新音频,旧缓存还在 |
32
+ | 词表独立缓存键 `tts_words_key` | 并进 `tts_key_v2` 会让**所有老项目的音频缓存一次性失效**(必须整片重新联网合成)。分开记 -> 老音频照旧复用,只在"这次显式要词级"时补采一次 |
33
+
34
+ ### 1.1 `work/words.json` 契约
35
+
36
+ ```jsonc
37
+ {
38
+ "schema": 1,
39
+ "enabled": true,
40
+ "lines": {
41
+ "l03": {
42
+ "dur": 7.1836, // 该句语音实测时长(秒,裁剪后)
43
+ "n": 13, // 词事件条数
44
+ "covered": 5.2013, // 词时长之和 —— 注意它**小于** dur(词间空隙 + 句尾静音)
45
+ "tail": 0.0901, // dur - 末词 end,句尾静音量
46
+ "start": 0.0412, // 首词起点(相对本句语音起点)
47
+ "gaps": [0.183, 0.207], // 相邻词之间的空隙(秒)
48
+ "mono": true, // 词偏移是否单调
49
+ "words": [{"text": "画质", "start": 0.0412, "dur": 0.2814, "end": 0.3226}, ...]
50
+ }
51
+ },
52
+ "stats": { "lines": 4, "words": 64, "empty": [] } // empty = 拿不到词事件的句 id
53
+ }
54
+ ```
55
+
56
+ `stats.empty` 非空就是"这些句这次没有词级"。它**不是崩溃**,而是服务端行为变了或者该句被裁坏了;
57
+ G10 会因此记 `skipped` 或 `fail`(看是全部为空还是个别句为空)。
58
+
59
+ ---
60
+
61
+ ## 2. 关键函数(`filmkit.py`)
62
+
63
+ | 函数 | 作用 | 退化行为 |
64
+ |---|---|---|
65
+ | `tts_words_key(text, voice, rate, pitch, volume, trim, trim_db)` | 词级缓存的独立键 | — |
66
+ | `trim_silence_bounds(pcm, sr, thresh_db=-42.0, keep_ms=80)` | 返回 `trim_silence` 会保留的样本区间 `[a, b)`;`a/sr` 就是 `words_shift` | 全静音 -> `(0, 0)`,等于不裁剪 |
67
+ | `words_normalize(events, *, offset_shift=0.0, dur=None, min_gap=0.0)` | 100 ns 事件 -> `{text,start,dur,end}`;减 `offset_shift`;负起点钳到 0;按 `prev_end` 夹紧保证单调;末词 end 不超过 `dur` | 空事件 -> `[]` |
68
+ | `words_summary(words, dur=None)` | 出 `n / covered / gaps / tail / mono / start`(G10 判据全在这里算) | — |
69
+ | `sub_words_at(scenes, t)` | 当前上屏字幕对应的 `words_abs`,没有就 `None` | 无词级 -> `None`(调用方回退整句) |
70
+ | `wrap_by_words(text, f, max_w, words)` | 按真实词边界折行;词表与原文对不上(有尾差/切不开)就返回 `None` | `None` -> `fit_sub_lines` 回退字符折行 |
71
+ | `fit_sub_lines(..., words=)` / `draw_subtitle(..., words=)` | 排版入口,多一个可选参数 | `words=None` 时与 v0.7 逐像素一致 |
72
+ | `build_timeline(..., fps=)` | 给每段补 `words_abs`(句内词表平移到绝对时间) | 该句没有 `words` 就没有 `words_abs` |
73
+ | `cue_time(at, scenes=, total=)` | 解析 `at`;v0.8 增词锚点 | 见 §3 |
74
+
75
+ ---
76
+
77
+ ## 3. 词锚点:`"l03:w2"` / `"l03:w2+0.1"`
78
+
79
+ ```jsonc
80
+ {"at": "l03:w2", "type": "impact"} // 第 3 段第 2 个词的起点
81
+ {"at": "l03:w2+0.1", "type": "impact"} // 该词起点 + 0.1 s(偏移可带符号)
82
+ {"at": "l03:w2-0.05", "type": "whoosh"} // 提前 50 ms
83
+ ```
84
+
85
+ - **下标从 1 开始**:`w1` = 第一个词(和"第 1 句是 l01"一致,不玩 0 基)。
86
+ - 解析优先读 `scenes[].words_abs`,没有就退到 `scenes[].words`(句内相对时间)。
87
+ - **找不到该词时静默退化**:`l03:w99` 不会炸,只打一条
88
+ `[WARN] cue at='l03:w99':l03 只有 13 个词,w99 不存在,退化为句偏移`,然后按 `l03+偏移` 处理。
89
+ —— 所以**改完台词一定要看 stdout 有没有这条 WARN**,否则你会以为自己精确打到了某个词。
90
+ - 场景 id 本身不存在时才 `raise ValueError`(先跑 layout,或者改用 `abs:秒`)。
91
+ - 只有 `abs:12.5` / `12.5` / `l03` / `l03+0.35` 这几种老写法时行为与 v0.7 **完全一致**。
92
+
93
+ ---
94
+
95
+ ## 4. 四个坑(都是实测踩出来的)
96
+
97
+ ### 4.1 词间有 0.1–0.3 s 的空隙
98
+
99
+ edge-tts 的 WordBoundary 只覆盖"发音区间",相邻词之间的停顿**不在任何事件里**。
100
+ 所以 `covered = Σ dur` 永远小于句长。模板片实测 4 句 64 词,覆盖率约 0.7 量级。
101
+ **判据不能写 `covered == dur`**——G10 的覆盖率下限是 **45%**(低于它才是"明显少了")。
102
+
103
+ ### 4.2 句尾静音不在事件里(约 0.5 s)
104
+
105
+ 排轴用的是**整句实测时长**,不是词时长求和。G10 只检查"末词终点离句尾不超过 **0.40 s**"
106
+ (模板片实测 0.09 s)。这条超过 0.40 s,通常不是"片子坏了",而是词事件本身整体错位了。
107
+
108
+ ### 4.3 裁剪会让词整体提前(`words_shift`)
109
+
110
+ `trim_silence=True`(默认)会裁掉句首尾静音,时间轴用的是裁剪后的语音;但词事件的 offset 是相对
111
+ **原始 mp3** 的。`trim_silence_bounds()` 与 `trim_silence()` 共用同一套判据,
112
+ `words_normalize(offset_shift=a/sr)` 负责平移。**不平移,词会整体提前 0.1–1 s**
113
+ (模板句实测 shift ≈ 0.10 s 量级)。这两个函数必须**永远**共用判据,改一个不改另一个就会系统偏移。
114
+
115
+ ### 4.4 首词起点也要查
116
+
117
+ 裁剪后首词通常离句首 ≤ 0.05 s;G10 的上限是 **0.20 s**。这条超了,多半是"裁掉的静音量算错了"
118
+ 或"词事件从更早的句子串过来了"——两种都会让字幕比声音先亮。
119
+
120
+ ---
121
+
122
+ ## 5. 自己验一遍(不联网也能查一半)
123
+
124
+ ```bash
125
+ # 1) 词级有没有真的落盘(看每句 n / covered / tail)
126
+ python -c "import json;d=json.load(open('work/words.json',encoding='utf-8'));print(d['stats']);[print(k, v['n'], v['covered'], v['tail']) for k,v in d['lines'].items()]"
127
+
128
+ # 2) 词锚点解析(故意写一个不存在的词,必须看到 WARN)
129
+ python -c "import filmkit as K, json; tl=json.load(open('work/timeline.json',encoding='utf-8')); \
130
+ print(K.cue_time('l01:w1', scenes=tl['scenes'])); print(K.cue_time('l01:w999', scenes=tl['scenes']))"
131
+
132
+ # 3) 全片门禁视角
133
+ python render.py ... --stage qa # 看 QC-REPORT.md 的 G10 行
134
+ ```
135
+
136
+ **可证伪性检查**(这条最值钱):随便把某句的 `work/words.json` 手改一个词的 `start` 到乱序,
137
+ 重跑 `--stage qa`,G10 **必须**报"词偏移非单调"。不报就说明门禁没读到那个文件。
138
+
139
+ ---
140
+
141
+ ## 6. 关掉它
142
+
143
+ - 单片:`film.json` 写 `"words": false` -> `--stage tts` 不再收词事件,G10 `skipped`(写明"本片未启用"),
144
+ 字幕退回整句折行。**老项目想完全保持 v0.7 行为就写这一行。**
145
+ - 只跳过门禁不跳过能力:没有开关——G10 就是词级数据的体检,没数据就 skipped,不会 fail。
146
+ - 词事件拿不到(服务端变了):不阻断出片,G10 `skipped` / `fail` 二选一看覆盖面,
147
+ meta 里 `words_ok=false`、`words_events=0` 是排查入口。
@@ -24,6 +24,12 @@ opencv-python-headless>=4.8
24
24
  # 装完还要下载浏览器:python -m playwright install chromium
25
25
  # playwright>=1.40
26
26
 
27
+ # ---- 可选:v0.8.0 ASR 回读门禁(qaasr.py)----
28
+ # 把成片音轨转回文字与剧本对账,抓"TTS 缓存串了 / 混音丢了句子 / 音轨是旧的"。
29
+ # 缺了会依次降级:faster-whisper -> ffmpeg 的 whisper 滤镜(需 ggml 模型)-> skipped。
30
+ # 实测(CPU-only, int8):small 模型加载 2.0–3.3 s、转写 RTF≈0.17;29 s 模板片约 6 s。
31
+ # faster-whisper>=1.0
32
+
27
33
  # ---- v0.6.0 新增文件:都是纯标准库,**不需要**任何 pip 包 ----
28
34
  # scripts/gates.py 门禁账本 / QC 报告 / 人工签核(必需:render.py 直接 import)
29
35
  # scripts/stagewatch.py 阶段新鲜度(必需:同上)
@@ -32,6 +38,14 @@ opencv-python-headless>=4.8
32
38
  # 若你从旧版本升级,记得把 gates.py / stagewatch.py 一起拷到工作目录,
33
39
  # 否则 render.py 会 ModuleNotFoundError。
34
40
 
41
+ # ---- v0.8.0 新增文件(同样不需要 pip 包;升级记得一起拷)----
42
+ # scripts/preflight.py 渲染前资源闸门(可选:缺了就记 skipped,门禁不冒充通过)
43
+ # scripts/qavmaf.py 全片 VMAF/SSIM(可选:缺了就 skipped)
44
+ # scripts/qaasr.py ASR 回读(可选:缺了就 skipped)
45
+ # tools/golden_frames.py 金帧回归(可选:仓库工具,不进工作目录)
46
+ # 注意:这三个都**不是** render.py 的硬依赖(用了 _try_module),少拷只会让对应门禁
47
+ # 记 skipped,不会 ModuleNotFoundError;但 qa 报告里会明确写"本次没查"。
48
+
35
49
  # ---- 不需要 ----
36
50
  # moviepy / pydub / soundfile / librosa / manim:本技能一行都不 import。
37
51
  # ffmpeg 不是 pip 包:系统 PATH 里要有(含 libx264 / aac / loudnorm),