@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.8.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 (43) hide show
  1. package/CHANGELOG.md +951 -0
  2. package/README.md +89 -21
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/index.js +23 -7
  5. package/package.json +7 -3
  6. package/schemas/film.schema.json +235 -4
  7. package/skills/video-studio/SKILL.md +299 -27
  8. package/skills/video-studio/assets/film.json +7 -14
  9. package/skills/video-studio/assets/golden_frames.json +65 -0
  10. package/skills/video-studio/assets/scenes.py +115 -10
  11. package/skills/video-studio/assets/web/engine.js +81 -3
  12. package/skills/video-studio/assets/web/film.js +249 -101
  13. package/skills/video-studio/assets/web/index.html +9 -2
  14. package/skills/video-studio/assets/web/qa_text.js +36 -8
  15. package/skills/video-studio/references/audio-variation.md +159 -0
  16. package/skills/video-studio/references/audio.md +67 -5
  17. package/skills/video-studio/references/change-impact.md +2 -2
  18. package/skills/video-studio/references/quality-gates.md +25 -3
  19. package/skills/video-studio/references/style-packs.md +7 -3
  20. package/skills/video-studio/references/v08-quality.md +256 -0
  21. package/skills/video-studio/references/web-engine.md +19 -2
  22. package/skills/video-studio/references/word-timeline.md +147 -0
  23. package/skills/video-studio/requirements.txt +16 -1
  24. package/skills/video-studio/scripts/filmcheck.py +202 -12
  25. package/skills/video-studio/scripts/filmkit.py +792 -56
  26. package/skills/video-studio/scripts/gates.py +161 -14
  27. package/skills/video-studio/scripts/preflight.py +774 -0
  28. package/skills/video-studio/scripts/qa_frames.py +154 -23
  29. package/skills/video-studio/scripts/qaasr.py +1563 -0
  30. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  31. package/skills/video-studio/scripts/render.py +1134 -78
  32. package/skills/video-studio/scripts/scorekit.py +305 -76
  33. package/skills/video-studio/scripts/selfcheck.py +576 -2
  34. package/skills/video-studio/scripts/sfxkit.py +262 -44
  35. package/skills/video-studio/scripts/stagewatch.py +159 -36
  36. package/skills/video-studio/scripts/stylekit.py +137 -18
  37. package/skills/video-studio/scripts/voicekit.py +23 -7
  38. package/skills/video-studio/scripts/webengine.py +1186 -85
  39. package/tools/audio_similarity.py +279 -0
  40. package/tools/ci_check.py +310 -59
  41. package/tools/estimate.py +251 -61
  42. package/tools/golden_frames.py +664 -0
  43. package/tools/selftest_noscipy.py +70 -0
@@ -0,0 +1,159 @@
1
+ # 音频多样性改造(v0.7)—— 接口契约
2
+
3
+ > 起草:lead,2026-11-09。本文件是本次改造的**唯一定义源**。
4
+ > 三路并行改动的文件写域不重叠,但**接口必须按本文件逐字实现**,否则集成会失败。
5
+ > 诊断证据见 `tools/audio_similarity.py` 的输出(改完后必须重跑,阈值见 §5)。
6
+
7
+ ## 1. 要解决的两个问题(都有实测数据)
8
+
9
+ | 问题 | 实测 | 根因 |
10
+ |---|---|---|
11
+ | A. 跨视频配乐雷同 | 同风格/同 bpm/同调、只换内容指纹:corr **0.966** | 指纹只改 seed(音色细节),和弦进行/动机/鼓型/段落骨架全固定 |
12
+ | B. 跨视频音效雷同 | 同 cue 序列两次渲染逐样本相同 | `CueBus.build_audio` seed = `seed_base + i`,只跟 cue 顺序有关 |
13
+ | C. 片内配乐与音效难分 | impact 质心 1001 Hz / whoosh 1589 Hz / music 5451 Hz;提示音硬编码 Dm 五声 | 提示音调性硬编码 + 配乐过门与 sfx 轨同边界同频段 + duck 仅 2.5–4.5 dB |
14
+
15
+ ## 2. 改动总览(按写域分三路)
16
+
17
+ | 路 | 负责文件 | 改什么 |
18
+ |---|---|---|
19
+ | A 配乐变奏 | `skills/video-studio/scripts/scorekit.py` | 指纹参与**和声/动机/鼓型/结构**,不再只影响音色细节 |
20
+ | B 音效差异化 | `skills/video-studio/scripts/sfxkit.py`、`assets/scenes.py`、`assets/film.json` | `in_key` 跟随配乐调性;`cues()` 按画面内容生成;模板 cue 去固定化 |
21
+ | C 接线与混音 | `skills/video-studio/scripts/filmkit.py`(lead)、`render.py`(lead)、`stylekit.py`(只读)、`selfcheck.py`(lead) | CueBus 内容派生 seed、调性传给 sfxkit、duck 加深、风格过门避让 |
22
+
23
+ **写域规则**:A 路只改 scorekit.py;B 路只改 sfxkit.py / assets/scenes.py / assets/film.json;
24
+ C 路由 lead 改 filmkit.py / render.py / selfcheck.py。**任何一路不得改别人的文件**(包括顺手"优化")。
25
+
26
+ ## 3. 接口契约(逐字实现,向后兼容)
27
+
28
+ ### 3.1 sfxkit(B 路)
29
+
30
+ ```python
31
+ # 1) 调性跟随:新增 key 参数,默认 None = 旧行为(Dm 五声)
32
+ def in_key(freq: float, key: str | None = None) -> float:
33
+ """key=None -> 旧行为(硬编码 D 小调五声,逐样本兼容)。
34
+ key 给 "Dm"/"C"/"Am"/... -> 吸附到该调(小调用自然小调五声,大调用大调五声)。"""
35
+
36
+ # 2) 所有走 in_key 的配方接受 key(通过 **kw 透传,签名末位加 key=None 或直接从 kw 取):
37
+ # ui_blip / answer_chime / text_stream(voice="tone") / data_chirp / roulette_tick
38
+ # 未知调名必须 raise ValueError(不静默降级,与 _enum 同口径)。
39
+
40
+ # 3) 内容派生 seed 的辅助函数(供 filmkit.CueBus 用)
41
+ def content_seed(base: int, style: str, content_fp: str, recipe: str, t: float) -> int:
42
+ """稳定的 32 位非负 int:sha1(\"v0.7|%d|%s|%s|%s|%.3f\") -> int(h[:8],16)%9000+1000。"""
43
+ ```
44
+
45
+ ### 3.2 filmkit.CueBus.build_audio(C 路,lead)
46
+
47
+ ```python
48
+ def build_audio(self, total=None, sr=SR, base_db=None, default_gain_db=0.0,
49
+ seed_base=1000, verbose=True,
50
+ style=None, # 风格 id;None = 旧行为
51
+ content_fp=None, # 内容指纹 full;None = 旧行为
52
+ key=None): # 配乐调性;None = 旧行为(sfxkit 用 Dm)
53
+ """每条 cue 的 seed:
54
+ - style/content_fp 任一为 None -> seed = seed_base + i(旧路径,逐样本兼容)
55
+ - 都给 -> seed = sfxkit.content_seed(seed_base, style, content_fp, kind, t)
56
+ 并把 key 透传给配方(render(kind, seed=..., key=key, **params))。"""
57
+ ```
58
+
59
+ ### 3.3 scorekit.render_styled(A 路)
60
+
61
+ 签名不变。**内部**行为变化:
62
+
63
+ - `full` 指纹(已有 `fingerprint` 参数,当前只用于 seed)必须影响**和声与动机**:
64
+ - 和弦替换:按指纹从该风格的 `progressions` 里选 tier,再对**每两小节**做一次稳定替换
65
+ (从同大小的替代级数集里挑:i↔i7 / VI↔VI7 / III↔III7 / VII↔VII7;大调侧 I↔Imaj7 / IV↔IVmaj7)。
66
+ 只在与乐句末/片尾规则不冲突时替换。
67
+ - 动机变形:`motif` 的每个 (拍, 级数, 时值) 按指纹做 **±1 级数的稳定偏移**,
68
+ 只改级数不改节奏;问题句偏移与答句偏移必须独立派生(保证两部片子不会同步变形)。
69
+ - 鼓型:`drums[tier]` 的 kick 落点按指纹加 **最多一个 ghost 落点**(在 0.5 拍网格上,
70
+ 不与现有落点重复);clap 位置按指纹 ±0.5 拍。
71
+ - **必须保证**:同 `fingerprint` 逐采样一致;不同 fingerprint 的相关性显著下降(§5 阈值)。
72
+ - 新增报告字段:`variation`(dict:`{"chord_swaps": n, "motif_shifts": n, "drum_ghosts": n}`),
73
+ 写进 work/score.json,方便审计。
74
+ - legacy 路径(style=None)**逐字节不变**(`scorekit.py --selftest` 的第 20 条会验;
75
+ 别和 `selfcheck.py` 的编号搞混——那边第 21 条只查 scorekit 同 seed 确定性)。
76
+
77
+ ### 3.4 assets/scenes.py 的 cues()(B 路)
78
+
79
+ 现在只出 `ui_blip × items` + `answer_chime × outro`。改为按画面事件生成,并且**类型按场景 kind 与内容变化**:
80
+
81
+ - `hook`:段首 `impact`(size 按字数 0.9~1.3),落定 `riser`。
82
+ - `body`:条目落位仍用 `ui_blip`,但 **pitch 由条目文本 sha1 派生**(json 的 items 文本);
83
+ 最后一个条目后加 `answer_chime`(若 kind=body 且 items≥3);段尾 `whoosh`(going 到下一段)。
84
+ - `quote`:`reverse_swell` + 落定 `impact`(轻)。
85
+ - `outro`:`answer_chime` + 片尾 `sub_drop`。
86
+ - 不许再出现"每段都一样的三连击":同类型 cue 在一段内重复时,`gain` 必须递减(-2/-4/-6),pitch 必须不同。
87
+ - **保持向后兼容**:仍有 `stagger` 对齐逻辑(与 _draw_items 同节拍),返回 schema 不变。
88
+
89
+ ### 3.5 assets/film.json 模板(B 路)
90
+
91
+ 模板里的 `sfx.cues` 固定三条 whoosh/riser/impact 是"跨片雷同"的直接来源之一。改为:
92
+
93
+ - 模板只保留 1 条转场 whoosh 作为示例,并加注释说明"具体提示点建议交给 scenes.py 的 cues() 钩子"。
94
+ - `sfx` 增加可选字段 `content_seed: true`(默认 true):走内容派生 seed;显式 false 回到旧行为。
95
+
96
+ ## 4. render.py 接线(C 路,lead)
97
+
98
+ - `st_score`:把 `plan_s["music"]["key"]` 与 `plan_s` 的 `fingerprint` 传给 sfx 阶段(经 `script["_style"]`)。
99
+ - `st_sfx`:读 `script["_style"]`,把 `style` / `content_fp` / `key` / `sfx.content_seed` 传给
100
+ `bus.build_audio(...)`;没有 `_style`(legacy)时不传,保持旧行为。
101
+ - `st_audio`:duck 默认值改为按风格:在现有表基础上整体 +1.0 dB,且 `music.duck_db` 显式值仍然优先。
102
+ 另外新增**过门避让**:`scorekit` 的过门音效已经在 music.wav 里,sfx 轨在
103
+ [段落边界 - 2.2s, 边界] 区间内的 whoosh/riser/reverse_swell 需要自动 -3 dB(在 filmkit 里做,
104
+ 或 st_sfx 后处理;由 lead 决定实现位置,**B 路不要碰**)。
105
+
106
+ ## 5. 验收阈值(改完必须重跑 `python <REPO>/tools/audio_similarity.py`)
107
+
108
+ | 指标 | 改前 | 目标 | 落地实测(2026-11-09) |
109
+ |---|---|---|---|
110
+ | A 同风格换指纹 corr | 0.966 | **≤ 0.85**(仍要像同一风格,但不能像同一首) | **0.645** |
111
+ | A 换风格 corr | 0.534 | ≤ 0.52(不能变得更像) | **0.514**(v0.7.2 复测,余量 0.006;连跑 3 次逐位相同。余量薄是有意的:抬到 0.53 就离改造前的 0.534 只剩 0.004,这条门禁就没意义了) |
112
+ | B 同 cue 序列跨片逐样本相同 | True | **False**(不同内容指纹必须不同) | False(同 fp 仍逐样本相同) |
113
+ | B legacy(不传 style/fp) | True | **必须仍为 True**(向后兼容) | True |
114
+ | C duck 默认 | 2.5–4.5 dB | 3.5–5.5 dB | 4.0(mono 3.0 / cinema 5.5) |
115
+ | D 人声段旁白/背景响度差 | 约 1.5 dB(配乐 -1.5 无侧链) | **≥ 12 dB** | **15.0 dB** |
116
+ | D 旁白处背景额外让位 | 0 | ≥ 4 dB | 6.0 dB |
117
+
118
+ > v0.7.1 追加 D 组(旁白优先):视频以旁白为主,BGM 与音效是可选层。`mix_film` 的
119
+ > `voice_duck_db=6` / `voice_duck_sfx_db=3` + `music.gain_db=-12` 把"人声比背景响 12 dB"
120
+ > 从文档口号变成可执行断言(selfcheck 35/36 条 + 本脚本 D 组)。
121
+
122
+ **已知边界(不是回归,是设计取舍)**:同风格不同内容指纹有约 1/3 概率落到同一个
123
+ 和弦进行档位偏移上,此时两段配乐 corr 约 0.93(只剩七和弦色彩与鼓型 ghost 的差别)。
124
+ 要再压下去需要"每段独立选档"或"同档时轮转",会削弱"片内是一条曲子"的连贯性,
125
+ 本轮不做;`--strict` 用固定的 content-a / content-b 做验收,两者落在不同档。
126
+
127
+ 另有硬性回归(任何一条红都不算完成):
128
+
129
+ > 下面 1–4 的路径都按**仓库根**写(`python skills/video-studio/scripts/…`);如果你已经 `cd` 进技能目录,
130
+ > 去掉 `skills/video-studio/` 前缀即可。`<REPO>` = 仓库根 / 装好后的包根。
131
+
132
+ 1. `python skills/video-studio/scripts/selfcheck.py` 全 PASS(现在 51 条;v0.7.2 时的 43 条是旧的)。
133
+ 2. `python skills/video-studio/scripts/scorekit.py --selftest` 全 PASS。
134
+ 3. `python skills/video-studio/scripts/sfxkit.py --selftest` 全 PASS。
135
+ 4. `python <REPO>/tools/ci_check.py` 全 PASS(在包根执行)。
136
+ 5. legacy 逐样本不变:`scorekit.py --selftest` 第 20 条 + `selfcheck.py` 第 26(风格 legacy)/33(音效 legacy)条,**这些断言不要改**。
137
+
138
+ ## 6. 协作规则
139
+
140
+ - 每个 teammate 只跑自己文件的 `--selftest`,**全量 selfcheck / ci_check 由 lead 统一跑**(避免并行抢 CPU 和临时目录)。
141
+ - 改完在 `CHANGELOG.md` 追加的条目由 lead 统一写(teammate 不要改 CHANGELOG)。
142
+ - 任何接口拿不准的地方,按本文件逐字实现;本文件没写的自由发挥请控制在"不改变签名"的范围内。
143
+ - 完成后向 lead 报告:改了哪些函数、自测输出摘要、与契约的偏差(如果有)。
144
+
145
+ ## 7. 实现备注(落地后回填,2026-11-09)
146
+
147
+ 三路改动全部落盘后,实测与契约之间的**已知偏差**都记在这里(不改契约本身):
148
+
149
+ | 项 | 契约原文 | 实际实现 | 判断 |
150
+ |---|---|---|---|
151
+ | 配乐 tier 选择 | §3.3 只列了和弦替换/动机偏移/鼓型 ghost | 额外让指纹从 `progressions` 的 calm/body/drive 三档里选一档(该风格自带的三套进行,不发明新和弦) | 接受:单靠七和弦替换压不到 0.85,实测换 tier 后 0.966 → 0.645;仍是"同风格同一套素材" |
152
+ | `roulette_tick` 调性 | §3.1 列进"走 in_key 的配方" | v0.6 本来没走 in_key;实现为 key=None 保持旧公式、key 非空才吸附 | 接受:不破坏 legacy 逐样本 |
153
+ | 同段重复 cue 的 gain | §3.4 写 -2/-4/-6 | 每次再降 2 dB 不封顶(items 很多时会到 -2n) | 待观察:模板 5 条时最末一条 -8 dB;若听感偏轻改 `_cue_gain_decay` 一行 |
154
+ | `content_seed` 实现位置 | §3.1 放 sfxkit | filmkit 与 sfxkit 各有一份逐字相同的实现(filmkit 走函数内延迟 import,模块级 from 会改导入时机) | 接受:两侧同参同值已核对;注释互相指认 |
155
+
156
+ **回归基线**(改前先录、改后逐条比对):
157
+
158
+ - sfxkit 9 条渲染的 sha256(ui_blip ×3 / answer_chime / text_stream ×2 / data_chirp / roulette_tick ×2)逐条相同。
159
+ - scorekit legacy 路径(style=None)6 s 渲染逐样本与 v0.6 相同。
@@ -28,15 +28,17 @@ wav = bus.mix(normalize=True, fade=0.6)
28
28
  | 声道 | 单声道总线 → 双声道输出 | 旁白单声道足够 |
29
29
  | 母带响度 | **-16 LUFS ± 1** | 网络视频标准 |
30
30
  | 真峰 | **≤ -1 dBTP** | AAC 编码会推高约 0.9 dB,混流前留 1.4 dB 余量 |
31
- | 旁白与间隙比 | ≥ 12 dB | 人声必须明显突出 |
32
- | 配乐电平 | 旁白以下 12–18 dB | 否则听不清字 |
31
+ | 旁白与间隙比 | ≥ 12 dB | 人声必须明显突出(v0.7.1 起有代码保障:`mix_film` 旁白侧链) |
32
+ | 配乐电平 | 旁白以下 12–18 dB | 否则听不清字(v0.7.1 默认 `music.gain_db=-12`,旧版 -1.5 不合格) |
33
33
 
34
34
  ## 3. 响度链路(三步)
35
35
 
36
36
  ```python
37
37
  # 本仓库**实际**链路(filmkit.mix_film + mux):
38
- # 旁白 voice_gain=1.0 居中 → 配乐 music.gain_db(默认 -1.5) → 音效按 CUE_BASE_DB 逐条
39
- # → duck(3 dB) → 峰值保护 0.89 → ffmpeg loudnorm 两遍到 -16 LUFS / 目标真峰 -1.5
38
+ # 旁白 voice_gain=1.0 居中(不被侧链动)
39
+ # → 音效按 CUE_BASE_DB 逐条,再按旁白包络压 3 dB
40
+ # → 配乐 music.gain_db(默认 -12) → 音效响时 duck(按风格 3.0–5.5) → 旁白包络再压 6 dB
41
+ # → 求和 → 峰值保护 0.89 → ffmpeg loudnorm 两遍到 -16 LUFS / 目标真峰 -1.5
40
42
  af = K.loudnorm_two_pass("work/audio.wav", target_i=-16.0, target_tp=-1.5)
41
43
  ```
42
44
 
@@ -185,7 +187,8 @@ print("peak:", np.max(np.abs(bus.bus))) # 期望 < 0.99
185
187
  |---|---|---|
186
188
  | 后半段静音 | ffmpeg 多输入混流 | numpy 总线 |
187
189
  | 爆音/削波 | 先 astype 再判 dtype | 先判 dtype 再转;打印中间峰值 |
188
- | 听起来糊 | 配乐电平过高 | 配乐压到旁白以下 12–18 dB |
190
+ | 听起来糊 | 配乐电平过高 | 配乐压到旁白以下 12–18 dB(v0.7.1 默认 -12 且带旁白侧链) |
191
+ | 听不清台词 | 背景与人声响度接近 | `music.gain_db` 降到 -12、`voice_duck_db` 开 6;别用"整体拉高"救 |
189
192
  | 响度上不去 | AAC 推高真峰后限幅 | 混流前留 1.4 dB 余量 |
190
193
  | 环境音接缝咔 | 直接拼接 | 频域整周期噪声 |
191
194
  | 配音机械 | 语速超过 +15% | 降到 +6% 左右,加自然停顿 |
@@ -418,3 +421,62 @@ out.append({"at": "%s+0.3" % s["id"], "type": "text_stream",
418
421
  `sfxkit` 的算法思想改编自 `ai-history-film`(MIT)的 `sfx/` 包,实现为本项目单文件重写;
419
422
  文件头已注明出处。配方名、参数名保持与上游一致,方便对照上游文档。
420
423
 
424
+ ## 13. 片间差异与片内分离(v0.7 新增)
425
+
426
+ > 完整契约与改动清单见 **references/audio-variation.md**;这里只记"怎么用、怎么验"。
427
+
428
+ ### 13.1 为什么 v0.5 之后还会"差不多"
429
+
430
+ v0.5 的 style pack 决定的是**风格**(8 套),不是**这一部的具体样子**:
431
+
432
+ - 配乐:内容指纹只进 `seed`(音色细节),和弦进行/动机/鼓型/段落骨架全固定
433
+ → 同风格下两段 20 s 配乐相关性 **0.966**(实测)。
434
+ - 音效:`CueBus.build_audio` 的 seed 是 `seed_base + i`(只跟 cue 顺序)
435
+ → 同一批 cue 跨片逐样本相同。
436
+ - 片内:提示音硬编码 D 小调五声、配乐过门与 sfx 转场在同一段落边界撞车、duck 只有 3 dB。
437
+
438
+ ### 13.2 现在怎么变
439
+
440
+ | 层 | 行为 | 开关 |
441
+ |---|---|---|
442
+ | 配乐和声/动机/鼓型 | 内容指纹做稳定局部替换(每两小节、±1 级数、ghost 击) | `style: auto`(默认);`seed_from_content: false` **只关 seed 派生**(音符细节不再随内容变),结构变奏照旧——`render.py` 只要不是 legacy 就把 `fingerprint` 传给 scorekit(`st_score` 里 `fp_hex` 那段)。想连结构一起回到旧行为只有不写 `style`(legacy) |
443
+ | 音效 seed | `content_seed(base, style, fp, type, t)`,随内容变 | `sfx.content_seed`(默认 true;false = 旧 seed_base+i) |
444
+ | 提示音调性 | `in_key(freq, key)` 跟随配乐调性 | `CueBus.build_audio(key=...)`,render.py 自动传 |
445
+ | 段落边界 | [边界-2.2s, 边界] 内的 whoosh/riser 自动 -3 dB | `sfx.duck_transitions`(默认 true) |
446
+ | duck | 按风格 3.0–5.5 dB(默认 4.0) | `music.duck_db` 显式覆盖 |
447
+
448
+ **legacy 兼容**:不写 `style` 字段 = 全部旧行为(配乐逐样本一致、音效 seed_base+i、提示音 Dm 五声)。
449
+
450
+ ### 13.2.1 旁白优先(v0.7.1)
451
+
452
+ 视频以旁白为主,**BGM 与音效是可选层**:它们负责情绪与重音,不能抢字。具体做法:
453
+
454
+ | 手段 | 默认值 | 说明 |
455
+ |---|---|---|
456
+ | 配乐基线 | `music.gain_db=-12` | 旧版 -1.5 时背景与台词几乎等高;-12 是"人声明显压住背景"的经验线 |
457
+ | 旁白侧链(配乐) | `voice_duck_db=6` | 有人声的地方再压 6 dB;间隙由包络自动恢复(不会整片闷住) |
458
+ | 旁白侧链(音效) | `voice_duck_sfx_db=3` | 提示音/转场也让人声 |
459
+ | 语音阈值 | `voice_thresh_db=-42` / `voice_floor_db=-60` | 10 ms 块 RMS 映射成 0~1 的活动度,起音快、释放慢 |
460
+ | 旁白自身 | `voice_gain=1.0` | **不被任何侧链影响**——旁白优先不等于"压人声" |
461
+ | SNR 兜底 | `guard_snr_db=12` / `guard_cap_db=30` | 混音后实测人声活跃区的人声/背景差,不足就整体补压背景(只压背景) |
462
+
463
+ 实测(真实 edge-tts 旁白 + scorekit 配乐):旧混音 **SNR -4.2 dB**(背景比台词还响,就是"听不清"的原因),
464
+ 新混音 **SNR 12.0 dB**;合成信号口径 15.0 dB,真实配乐素材口径 12.9 dB。
465
+ 设 `voice_duck_db=0` / `voice_duck_sfx_db=0` / `guard_snr_db=0` 可分别关掉对应机制。
466
+
467
+ ### 13.3 验收:别靠耳朵,跑脚本
468
+
469
+ ```bash
470
+ python <REPO>/tools/audio_similarity.py --strict # 在包根执行
471
+ ```
472
+
473
+ A 组量"同风格换内容指纹"和"换风格"的相关性;B 组量"跨片音效是否还逐样本相同"(同时验
474
+ legacy 兼容);C 组读 `render.py` 的 duck 默认值并打印质心对照;
475
+ D 组构造"有人声段 + 间隙",量人声段旁白/背景响度差。任何一项不达标 exit 1。
476
+
477
+ ### 13.4 改音频代码后的自检清单
478
+
479
+ 1. `python <REPO>/tools/audio_similarity.py --strict` —— A/B/C 三组达标(在包根执行)。
480
+ 2. `python selfcheck.py` —— 51 条全 PASS(第 33 条盯 legacy 逐样本兼容,第 35–38 条盯旁白优先,第 39 条盯音频边界)。
481
+ 3. `python scorekit.py --selftest` / `python sfxkit.py --selftest` —— 各自全 PASS。
482
+ 4. 改 `assets/scenes.py` 的 `cues()` 后,跑一次 `--stage cues` 看类型分布是否覆盖 hook/body/quote/outro。
@@ -37,14 +37,14 @@
37
37
  |---|---|---|
38
38
  | 改了 `text` 只重跑 `tts` | 新语音配旧时间轴 → 字幕漂移、音效错位 | 跑 `--stage explain`,按它给的最小重跑命令 |
39
39
  | 改了 `style` 只重跑 `frames` | 画风换了但配乐/声线还是旧的(或反之) | `style` 是所有下游的上游,通常整链重跑 |
40
- | 手工删 `work/_plan.json` 绕过指纹 | 拼接出的片子画面和旁白对不上,且**不报错** | 删 `work/segments` 整目录重渲 |
40
+ | 手工删 `work/segments/_plan.json` 绕过指纹 | 拼接出的片子画面和旁白对不上,且**不报错** | 删 `work/segments` 整目录重渲 |
41
41
 
42
42
  ## 3. 一条命令代替这张表
43
43
 
44
44
  ```bash
45
45
  python render.py --stage explain # 哪些阶段过时了、为什么、最小重跑命令
46
- python render.py --stage explain --json # 机读(给脚本/agent 用)
47
46
  python render.py --stage qa --strict-stale # 有过时就失败(CI / 交付前)
47
+ python scripts/stagewatch.py --work work --json # 机读(给脚本/agent 用;render.py 没有 --json)
48
48
  ```
49
49
 
50
50
  ## 4. 和人工签核的关系
@@ -1,7 +1,27 @@
1
- # 质量门禁(G0–G6)
1
+ # 质量门禁(方法论 G0–G8)
2
2
 
3
3
  Agent 看不见画面、听不见声音,所以**验收必须脚本化 + 静帧目检**双轨。
4
4
 
5
+ > ## ⚠ 两套编号别混用
6
+ >
7
+ > 本页的 `G0–G8` 是 v0.3 起的**方法论分组**(按"查什么"分类,写给写剧本/写场景的人看)。
8
+ > `render.py` 落进 `work/gates.json` 与 `out/QC-REPORT.md` 的,是**运行时账本 id**(按"哪一步判"排)。
9
+ > 两套编号**有重名但不同义**,看到 `G0` 先确认是哪一套:
10
+ >
11
+ > | 本页(方法论) | 账本 id(运行时) | 关系 |
12
+ > |---|---|---|
13
+ > | G0 内容门禁:上屏数字可溯源 | `G0` 渲染前资源预检(v0.8) | **同名不同义**:账本 G0 查字形/字体/TTS/web 页,不核对数字出处 |
14
+ > | G1 帧级门禁(确定性/单帧预算/元素存在性) | — | 人工/代码评审,不进账本 |
15
+ > | G2 静帧门禁(母版预览目检) | `signoff`(`stills` 闸) | 目检结论走人工签核 |
16
+ > | G3 音频门禁(音效/混音/母带) | `G5` 响度/真峰 | 账本只查成片级的两个数 |
17
+ > | G4 成片门禁(容器/时长/锐度/字幕/黑帧) | `G4` 容器 · `G5` 响度 · `G6` 锐度比 · `G7` 字幕条数 · `G8` 黑帧 | 账本把 G4 拆成 5 行 |
18
+ > | G7 时间层门禁(闪烁/空窗/同步) | `G9` 帧级深检 | **同名不同义**:账本 G9 才是时间层 |
19
+ > | G8 页面层门禁(web 文字重叠/出画) | web 引擎 qa_text 结果 | 只有 `--engine web` 才有 |
20
+ > | — | `G10` 词级时间戳 · `G11` VMAF/SSIM · `G12` ASR 回读 | **v0.8 新增**,详见 [v08-quality.md](v08-quality.md) 与 [word-timeline.md](word-timeline.md) |
21
+ >
22
+ > 一句话记法:**本页 G0–G8 是"查什么",账本 G0 + G4–G12 是"哪一行判"**。
23
+ > 账本里**没有** `G1`/`G2`/`G3`:`G0` 之后直接从 `G4` 开始(容器),人工目检走 `signoff` 的 `stills` 闸,不进 gate matrix。
24
+
5
25
  ## G0 内容门禁:上屏数字可溯源
6
26
 
7
27
  | 判据 | 做法 |
@@ -61,7 +81,9 @@ pr = K.probe(film); f = pr["format"]
61
81
  # 容器
62
82
  assert f["duration"] > 0
63
83
  v = [s for s in pr["streams"] if s["codec_type"] == "video"][0]
64
- assert (v["width"], v["height"]) == (W, H)
84
+ # v0.8:比的是**交付规格**不是内部构图。qa_report(expect_w=, expect_h=, expect_fps=) 会从
85
+ # timeline.json 的 video 块取期望值;缺省回落 1920x1080@30(老项目行为不变)。
86
+ assert (v["width"], v["height"]) == (W, H) # W/H 这里代表交付宽高,不是 1920x1080 常量
65
87
  assert any(s["codec_type"] == "audio" for s in pr["streams"]), "无音轨"
66
88
 
67
89
  # 时长
@@ -221,5 +243,5 @@ python webengine.py textqa ./web/index.html --step 0.2
221
243
  信号:-16.3 LUFS / 真峰 -1.2 dBTP / 锐度比 0.94 / 黑帧 0 / 字幕 86 条 = 剧本 86 句
222
244
  时间:单帧闪烁 0 / 画面空窗 0 / 音画同步最大偏差 38 ms
223
245
  页面:文字重叠 0 / 出画 0 / 一闪而过 0 / 从没露面 0
224
- 自检:python selfcheck.py -> PASS 29 / FAIL 0 / SKIP 0
246
+ 自检:python selfcheck.py -> PASS 51 / FAIL 0 / SKIP 0
225
247
  ```
@@ -112,7 +112,9 @@ v0.4 及以前,所有"身份参数"都是模块级常量:
112
112
  | voice.jitter | 0.35 | 语速/音高微扰,0 = 关 |
113
113
  | voice.max_distinct | 3 | 全片最多几把声音 |
114
114
  | pacing.* | 风格默认 | 语前留白/语后留白/最短段长/条目落位步长 |
115
- | lines[].voice/tone/rate/pitch/volume/lead/tail | — | 逐句覆盖(最高优先级) |
115
+ | lines[].persona / lines[].voice / lines[].tone | — | 逐句覆盖(最高优先级,voicekit 真的读这三个) |
116
+ | lines[].lead | — | 逐句语前留白(filmkit.build_timeline 读) |
117
+ | lines[].rate / pitch / volume / tail | — | **当前不生效**(保留字段):voicekit 只认 persona/voice/tone,filmcheck 会对这三个报"未知键";要改语速/音高请换 persona 或 tone,或在顶层写 rate/pitch/volume |
116
118
 
117
119
  ## 4. 三级指纹:改错别字不会换整首曲子
118
120
 
@@ -123,7 +125,9 @@ v0.4 及以前,所有"身份参数"都是模块级常量:
123
125
  | style | 标题(配合关键词评分破平) | 风格 id |
124
126
 
125
127
  所以:改一个错别字 -> full 变(音色纹理微变),tempo/style 不变(还是同一首曲子、同一个调);
126
- 换一部片 -> 三个都变。要完全锁死就写 music.seed + music.seed_from_content: false。
128
+ 换一部片 -> 三个都变。`music.seed_from_content: false` 只锁住 **seed 派生**(音色细节);
129
+ `full` 指纹仍会传给 scorekit 参与结构变奏(和弦替换/动机偏移/鼓型 ghost,见
130
+ [audio-variation.md](audio-variation.md) §3.3)。要把声音完全锁回 v0.4 行为,只能不写 `style`(legacy)。
127
131
 
128
132
  ## 5. 片内变化:整体是一套,但每段有呼吸
129
133
 
@@ -173,6 +177,6 @@ stylekit.theme_lint(theme) 在 --stage style 就拦,不用等渲完 300 帧:
173
177
  |---|---|---|
174
178
  | 画面换了、配乐还是老的 | 只改了 visual.theme 没改 music.style | 直接改 style,或两个都写 |
175
179
  | 同一部片前后像两部片 | 段界跨了明暗族 | 用同族主题;或拆成两部 |
176
- | 改台词后配乐变了 | 改了标题或 kind 结构 -> tempo 指纹变 | 写死 music.key/music.bpm,或 seed_from_content:false |
180
+ | 改台词后配乐变了 | 改了标题或 kind 结构 -> tempo 指纹变(BPM/调性跟着换) | 写死 music.key / music.bpm(`seed_from_content:false` 只管音色 seed,压不住 BPM/调性变化) |
177
181
  | 旁白听感像广播剧 | max_distinct 太大 / 相邻句频繁换人 | 降到 2,靠 tone 而不是换人做变化 |
178
182
  | 字幕在浅色主题上看不清 | 用了 v0.4 的黑底白字 | 传 theme= 给 draw_subtitle(render.py 已自动传) |
@@ -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`。