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

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 (63) hide show
  1. package/CHANGELOG.md +473 -0
  2. package/README.md +263 -327
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +31 -42
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1216 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +2 -4
  11. package/skills/video-studio/assets/golden_frames.json +13 -10
  12. package/skills/video-studio/assets/scenes.py +153 -65
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/appearance.md +123 -0
  15. package/skills/video-studio/references/audio-variation.md +74 -93
  16. package/skills/video-studio/references/audio.md +64 -66
  17. package/skills/video-studio/references/change-impact.md +5 -5
  18. package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
  19. package/skills/video-studio/references/frame-qa.md +48 -16
  20. package/skills/video-studio/references/pipeline.md +4 -4
  21. package/skills/video-studio/references/pitfalls.md +22 -3
  22. package/skills/video-studio/references/proposal.md +160 -0
  23. package/skills/video-studio/references/quality-gates.md +11 -11
  24. package/skills/video-studio/references/scene-authoring.md +2 -2
  25. package/skills/video-studio/references/scene-grammar.md +40 -42
  26. package/skills/video-studio/references/sfx-design.md +8 -6
  27. package/skills/video-studio/references/web-engine.md +8 -8
  28. package/skills/video-studio/references/word-timeline.md +9 -9
  29. package/skills/video-studio/requirements.txt +9 -13
  30. package/skills/video-studio/scripts/designkit.py +2115 -0
  31. package/skills/video-studio/scripts/filmcheck.py +22 -48
  32. package/skills/video-studio/scripts/filmkit.py +2980 -2904
  33. package/skills/video-studio/scripts/gates.py +4 -4
  34. package/skills/video-studio/scripts/grammar.py +73 -13
  35. package/skills/video-studio/scripts/musicgen.py +2042 -0
  36. package/skills/video-studio/scripts/preflight.py +14 -7
  37. package/skills/video-studio/scripts/previewkit.py +848 -0
  38. package/skills/video-studio/scripts/propose.py +825 -0
  39. package/skills/video-studio/scripts/qa_frames.py +73 -39
  40. package/skills/video-studio/scripts/qaasr.py +26 -7
  41. package/skills/video-studio/scripts/qavmaf.py +89 -23
  42. package/skills/video-studio/scripts/render.py +545 -231
  43. package/skills/video-studio/scripts/scorekit.py +55 -1755
  44. package/skills/video-studio/scripts/selfcheck.py +815 -215
  45. package/skills/video-studio/scripts/sfxkit.py +27 -26
  46. package/skills/video-studio/scripts/stagewatch.py +24 -8
  47. package/skills/video-studio/scripts/stylekit.py +207 -888
  48. package/skills/video-studio/scripts/voicekit.py +508 -387
  49. package/skills/video-studio/scripts/webengine.py +71 -72
  50. package/tools/audio_similarity.py +64 -37
  51. package/tools/ci_check.py +20 -14
  52. package/tools/estimate.py +34 -29
  53. package/tools/golden_frames.py +72 -28
  54. package/tools/silhouette_qa.py +520 -168
  55. package/docs/demo-pil-sheet.png +0 -0
  56. package/docs/styles/demo-results.json +0 -76
  57. package/docs/styles/sheet-8-styles.png +0 -0
  58. package/docs/styles/sheet-ai.png +0 -0
  59. package/docs/styles/sheet-before-after.png +0 -0
  60. package/docs/styles/sheet-craft.png +0 -0
  61. package/docs/styles/sheet-night.png +0 -0
  62. package/skills/video-studio/assets/score_demo.json +0 -307
  63. package/skills/video-studio/references/style-packs.md +0 -283
@@ -1,8 +1,8 @@
1
1
  # 音频:采样级混音、配乐与音效
2
2
 
3
- > **v0.3.0 新增三节**(§10–§12 是新的推荐做法,§1–§9.5 是底层原理与手写示例,仍然有效):
4
- > §10 提示点系统(声音跟着画面走)、§11 程序化配乐(scorekit)、§12 程序化音效(sfxkit)。
5
- > 先读 §10——它决定了另外两节怎么用。
3
+ > 读法:§1–§9.5 是底层原理与手写示例;§10–§12 是推荐做法——
4
+ > §10 提示点系统(声音跟着画面走)、§11 程序化配乐(musicgen 参数化编排)、§12 程序化音效(sfxkit)。
5
+ > 先读 §10,它决定了另外两节怎么用。
6
6
 
7
7
  ## 1. 为什么不用 ffmpeg 混音
8
8
 
@@ -28,8 +28,8 @@ 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 | 人声必须明显突出(v0.7.1 起有代码保障:`mix_film` 旁白侧链) |
32
- | 配乐电平 | 旁白以下 12–18 dB | 否则听不清字(v0.7.1 默认 `music.gain_db=-12`,旧版 -1.5 不合格) |
31
+ | 旁白与间隙比 | ≥ 12 dB | 人声必须明显突出(有代码保障:`mix_film` 旁白侧链) |
32
+ | 配乐电平 | 旁白以下 12–18 dB | 否则听不清字(默认 `music.gain_db=-12`;-1.5 时背景与台词几乎等高,不合格) |
33
33
 
34
34
  ## 3. 响度链路(三步)
35
35
 
@@ -42,7 +42,7 @@ wav = bus.mix(normalize=True, fade=0.6)
42
42
  af = K.loudnorm_two_pass("work/audio.wav", target_i=-16.0, target_tp=-1.5)
43
43
  ```
44
44
 
45
- > **下面是"设计建议",本仓库没有实现**(以前这段被写成既有链路,容易误导):
45
+ > **下面是"设计建议",本仓库没有实现**:
46
46
  > 「各轨目标 LUFS 旁白 -13.5 / 音效 -24.5 / 配乐 -26.5」+「前瞻限幅器补差额」——
47
47
  > 这套是专业分轨母带流程,本项目用"相对增益 + 最终 loudnorm"替代,**代码里没有逐轨 LUFS 对齐,
48
48
  > 也没有前瞻限幅器**。要那套效果得自己在 `mix_film` 之后接。
@@ -130,44 +130,45 @@ def loop_noise(dur=8.0, sr=48000, lo=180, hi=900, seed=5):
130
130
 
131
131
  ```python
132
132
  # edge-tts 每条尾部约 0.95 s 静音,55 条累计 61.5 s
133
- # v0.2 起 filmkit.tts(trim=True) 默认裁掉首尾静音,并把裁剪后的 wav 作为 decode 源:
133
+ # filmkit.tts(trim=True) 默认裁掉首尾静音,并把裁剪后的 wav 作为 decode 源:
134
134
  # meta.path -> xxx.trim.wav, meta.dur = 裁剪后实测时长;原 mp3 保留在 meta.mp3
135
- # 缓存 key = sha1(text|voice|rate|pitch);改台词会自动重新合成(v0.1 只判文件存在)
135
+ # 缓存 key = sha1(text|voice|rate|pitch);改台词会自动重新合成
136
136
  pcm = K.decode_pcm(mp3)
137
137
  trimmed = K.trim_silence(pcm, thresh_db=-42.0, keep_ms=80) # 不是 trim_tail:本仓库没这个函数
138
138
 
139
139
  # 轻 EQ:200 Hz 以下衰减 2 dB(去闷),3 kHz 提 1 dB(清晰度)
140
140
  ```
141
141
 
142
- ### 7.1 声线(persona)与语气(tone)—— v0.5.0
142
+ ### 7.1 声线(edge voice id)与语气(tone):没有 persona 身份表
143
143
 
144
- v0.4 及以前全片只有一个 `voice/rate/pitch`,于是"每部片的旁白都差不多"。
145
- v0.5 引入 **persona(声线)× tone(语气档)**,由 `voicekit.py` 解析成逐句 spec:
144
+ > **没有 persona 身份表**(`bright_female` / `narrator_male` 之类 9 个身份):`voice` 只认
145
+ > **裸 edge-tts voice id**(如 `zh-CN-YunxiNeural`)+ 实测响度补偿表 `assets/voice_loudness.json`;
146
+ > tone 的 6 档(hook/body/quote/outro/emphasis/calm)照旧。
146
147
 
147
148
  ```json
148
- {"voice": {"map": {"hook": "bright_female", "body": "narrator_female", "quote": "authority_male"},
149
+ {"voice": {"id": "zh-CN-YunxiNeural",
150
+ "map": {"hook": "zh-CN-YunxiaNeural", "body": "zh-CN-YunxiNeural",
151
+ "quote": "zh-CN-YunyangNeural", "outro": "zh-CN-YunxiNeural"},
149
152
  "jitter": 0.35, "max_distinct": 3}}
150
153
  ```
151
154
 
152
155
  ```bash
153
- python voicekit.py --list # 9 个 persona + 6 个 tone(含实测响度补偿 gain_db)
154
- python voicekit.py --plan film.json # 打印某份剧本的逐句声线计划(不发请求)
156
+ python <SKILL_DIR>/scripts/voicekit.py --list # 音色响度表(裸 id + 实测 gain_db)与 6 档 tone
157
+ python <SKILL_DIR>/scripts/voicekit.py --plan film.json # 打印逐句声线计划(不发请求)
155
158
  ```
156
159
 
157
- - **persona** 决定 edge-tts 音色 id + 基线 rate/pitch/volume/gain_db;
160
+ - **voice id** 直接就是 edge-tts 的音色名;`--list` 里带的是**实测响度补偿**(不同音色天然差 3–7 dB,
161
+ 实测跨度 6.0 dB)。写了表里没有的音色,补偿回落 0 = 旁白电平没校准。
158
162
  - **tone** 决定相对偏移与句前/句后停顿:hook / body / quote / outro / emphasis / calm
159
- (`body` 严格等于 v0.4 基准:lead 0.62 / tail 0.92 / 所有 delta=0);
160
- - **内容规则**会自动升级语气:引号句 -> quote(并换到 quote 声线)、数字密集 -> emphasis、
161
- 问句/感叹 -> 稍快稍亮、长句 -> 降速加尾部留白;显式 `lines[].voice/tone` 优先级最高;
162
- - **jitter**(默认 0.35)用 sha1 派生 ±1.5% / ±1Hz / ±0.04s 的微扰,去掉"每句等距等音量"的机械感;
163
+ (`body` 是基准档:lead 0.62 / tail 0.92 / 所有 delta=0)。
164
+ - **内容规则**会自动升级语气:引号句 → quote(并换到 quote 声线)、数字密集 → emphasis、
165
+ 问句/感叹 → 稍快稍亮、长句 → 降速加尾部留白;显式 `lines[].voice/tone` 优先级最高。
166
+ - **jitter**(默认 0.35)用 sha1 派生 ±1.5% / ±1Hz / ±0.04s 的微扰,去掉"每句等距等音量"的机械感。
163
167
  - **governor** 限制全片最多 `max_distinct` 把声音、相邻句不换人 —— 超过就变成广播剧,不是旁白。
164
168
 
165
- **响度校准**:不同音色的响度天然差 3–7 dB(实测 narrator_female 比 narrator_male 响约 5.7 dB),
166
- persona 表里的 `gain_db` 是**实测回填**的(`voicekit.py --calibrate` 可复测)。
167
- 混音侧每句只应用一次增益(v0.5 顺手修掉了 v0.4 `voice_gain` 被乘两次的静默 bug)。
168
-
169
- **缓存键 v2**:`sha1(v2|text|voice|rate|pitch|volume|trim|trim_db)`。v0.4 的 key 不含 trim,
170
- 于是把 `trim_silence` 改成 false 仍会命中旧的裁剪版 —— "关了裁剪其实没关"。v1 key 仍然保留给老缓存。
169
+ **缓存键**:`sha1(v2|text|voice|rate|pitch|volume|trim|trim_db)`。key 必须含 trim——
170
+ 不含 trim 时把 `trim_silence` 改成 false 仍会命中裁剪版,"关了裁剪其实没关"。
171
+ `v1` key 仍给既有缓存保留。
171
172
 
172
173
  ## 8. 混音自检(必做)
173
174
 
@@ -187,7 +188,7 @@ print("peak:", np.max(np.abs(bus.bus))) # 期望 < 0.99
187
188
  |---|---|---|
188
189
  | 后半段静音 | ffmpeg 多输入混流 | numpy 总线 |
189
190
  | 爆音/削波 | 先 astype 再判 dtype | 先判 dtype 再转;打印中间峰值 |
190
- | 听起来糊 | 配乐电平过高 | 配乐压到旁白以下 12–18 dB(v0.7.1 默认 -12 且带旁白侧链) |
191
+ | 听起来糊 | 配乐电平过高 | 配乐压到旁白以下 12–18 dB(默认 -12 且带旁白侧链) |
191
192
  | 听不清台词 | 背景与人声响度接近 | `music.gain_db` 降到 -12、`voice_duck_db` 开 6;别用"整体拉高"救 |
192
193
  | 响度上不去 | AAC 推高真峰后限幅 | 混流前留 1.4 dB 余量 |
193
194
  | 环境音接缝咔 | 直接拼接 | 频域整周期噪声 |
@@ -196,9 +197,6 @@ print("peak:", np.max(np.abs(bus.bus))) # 期望 < 0.99
196
197
 
197
198
  ## 9.5 两条混音路线的取舍(含实测坑)
198
199
 
199
- > 编号说明:本小节原为「§10」,与后加的「§10 提示点系统」重号(v0.3 加节时的历史遗留)。
200
- > 现改为 §9.5,保证 §10–§12 三段新内容编号唯一。
201
-
202
200
  ### 路线 A:numpy 采样级叠加(推荐)
203
201
 
204
202
  完全确定、精度 1/48000 s、可单层重放、可中途打印峰值。`filmkit.AudioBus` 就是这条。
@@ -234,13 +232,13 @@ bus[i0:i0+len(src)] += src * gain # 精确到 1/48000 秒
234
232
 
235
233
  ### 首镜必须从 0 开始
236
234
 
237
- 历史事故:第一个镜头 `start ≠ 0`,导致全片音画错位 1.2 s(画面先跑,声音从 0 开始)。**时间轴第一段 start 必须严格等于 0**,且 `voice_start ≥ start`。
235
+ 典型事故:第一个镜头 `start ≠ 0`,全片音画错位(实测 1.2 s:画面先跑,声音从 0 开始)。**时间轴第一段 start 必须严格等于 0**,且 `voice_start ≥ start`。
238
236
 
239
237
  ### BGM 循环的淡出陷阱
240
238
 
241
239
  BGM 素材自带淡出尾音,被 `-stream_loop` 循环后会在每个接缝处"死一下"。做法:循环时只取素材的**稳态段**(裁掉头尾淡入淡出),或改用频域整周期噪声。
242
240
 
243
- ## 10. 提示点系统(v0.3.0,先读这一节)
241
+ ## 10. 提示点系统(先读这一节)
244
242
 
245
243
  **问题**:手工把音效放到"第 12.5 秒",只要片子里改一句台词、加一个停留窗口、把 BPM 从 120 改成 100,
246
244
  所有音效就全错位。改一次要重新对一遍时间——这就是为什么很多程序化视频"声音和画面对不上"。
@@ -306,25 +304,25 @@ python qa_frames.py out/film.mp4 --cues work/cues.json # 看音画同步 dt
306
304
  **音画同步 dt > 60 ms 就是错**:人眼/耳对 40–60 ms 以内的错位不敏感,超过就会"感觉哪里不对但说不出来"。
307
305
  dt 大的典型原因:cue 挂在字幕出现的时刻上,而画面重击晚 100 ms——应该挂在动画时刻上。
308
306
 
309
- ## 11. 程序化配乐(scorekit,v0.3.0)
307
+ ## 11. 程序化配乐(musicgen)
308
+
309
+ > **没有「配乐模板」。** 配乐由 `musicgen.py` 按参数**现场编排**:没有可选曲风,也没有风格表,
310
+ > 只有参数。下面仍是「一张和弦表贯穿全片」的技术思路,实现落在参数化编排器上。
310
311
 
311
312
  ```python
312
- import scorekit as sk
313
- samples, report = sk.render(389.0, bpm=120, key="Dm",
314
- anchors=timeline["scenes"], # [{id,kind,start,end}]
315
- seed=7) # 同 seed 逐采样一致
316
- sk.save("work/music.wav", samples)
313
+ import musicgen as MG
314
+ # 参数就是「设计」:bpm / key / scale / mood / seed,加可选的编排参数
315
+ samples, report = MG.render(389.0, bpm=120, key="Dm", scale="minor",
316
+ mood="steady", seed=7) # 同参数逐采样一致
317
+ MG.save("work/music.wav", samples)
317
318
  ```
318
319
 
319
- 或者用面向对象的写法(要更细的控制时):
320
+ 要更细的控制就传 `sections` / `progressions` / `motif` / `drums` / `voices` ——
321
+ 它们真的参与事件展开,不是死旋钮(接口见 `musicgen.py` 模块 docstring)。
320
322
 
321
- ```python
322
- sc = sk.Score(389.0, bpm=120, key="Dm", seed=7)
323
- sc.plan_from_anchors(timeline["scenes"], default_energy=0.5) # 自动分段,边界对齐小节
324
- sc.section("finale", 360.0, 389.0, energy=0.95)
325
- sc.suck(360.0, db=-6, dur=0.22) # 最终重击前"吸一口气"
326
- samples = sc.render()
327
- ```
323
+ 底层原语(吸气、分段能量、过门)仍在 `scorekit.py` 里,但**它不是配乐引擎**:
324
+ 它只提供和声表 / 律动层 / 混音 / 响度测量这些可复用件。要自己拼一条非常规配乐可以直接
325
+ 调它的 `Score` 再用 `scorekit.save()` 落盘;成片链路用的是 musicgen。
328
326
 
329
327
  ### 11.1 为什么"一张和弦表贯穿全片"
330
328
 
@@ -354,7 +352,7 @@ samples = sc.render()
354
352
  - 同 seed 必须逐采样一致(`np.random.default_rng(seed)`,不许用全局 `np.random`)。
355
353
  - 配乐只保证"不削波、不直流、峰值 ≤ -1 dBFS";**最终响度交给 ffmpeg loudnorm**。
356
354
 
357
- ## 12. 程序化音效(sfxkit,v0.3.0)
355
+ ## 12. 程序化音效(sfxkit)
358
356
 
359
357
  ### 12.1 为什么不"画波形"
360
358
 
@@ -421,47 +419,47 @@ out.append({"at": "%s+0.3" % s["id"], "type": "text_stream",
421
419
  `sfxkit` 的算法思想改编自 `ai-history-film`(MIT)的 `sfx/` 包,实现为本项目单文件重写;
422
420
  文件头已注明出处。配方名、参数名保持与上游一致,方便对照上游文档。
423
421
 
424
- ## 13. 片间差异与片内分离(v0.7 新增)
422
+ ## 13. 片间差异与片内分离
425
423
 
426
- > 完整契约与改动清单见 **references/audio-variation.md**;这里只记"怎么用、怎么验"。
424
+ > 完整契约见 **references/audio-variation.md**;这里只记"怎么用、怎么验"。
427
425
 
428
- ### 13.1 为什么 v0.5 之后还会"差不多"
426
+ ### 13.1 为什么"差不多"
429
427
 
430
- v0.5 的 style pack 决定的是**风格**(8 套),不是**这一部的具体样子**:
428
+ "每片都不一样"不能只靠随机,得让**内容进结构**;下面三种做法就会让片子"差不多":
431
429
 
432
430
  - 配乐:内容指纹只进 `seed`(音色细节),和弦进行/动机/鼓型/段落骨架全固定
433
- → 同风格下两段 20 s 配乐相关性 **0.966**(实测)。
434
- - 音效:`CueBus.build_audio` 的 seed 是 `seed_base + i`(只跟 cue 顺序)
431
+ → 两段 20 s 配乐相关性 **0.966**(实测)。
432
+ - 音效:`CueBus.build_audio` 的 seed 取 `seed_base + i`(只跟 cue 顺序)
435
433
  → 同一批 cue 跨片逐样本相同。
436
434
  - 片内:提示音硬编码 D 小调五声、配乐过门与 sfx 转场在同一段落边界撞车、duck 只有 3 dB。
437
435
 
438
- ### 13.2 现在怎么变
436
+ ### 13.2 怎么变
439
437
 
440
438
  | 层 | 行为 | 开关 |
441
439
  |---|---|---|
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) |
440
+ | 配乐和声/动机/鼓型 | 由 musicgen 的编排参数直接决定:`sections` / `progressions` / `motif` / `drums` 参与事件展开(没有可挑的曲风表) | `music.seed_from_content` 控制 seed 是否掺内容指纹;结构差来自上述编排参数,而不是「换一套风格」 |
441
+ | 音效 seed | `content_seed(base, style, fp, type, t)`,随内容变 | `sfx.content_seed`(默认 true;false = 退回 `seed_base + i`) |
444
442
  | 提示音调性 | `in_key(freq, key)` 跟随配乐调性 | `CueBus.build_audio(key=...)`,render.py 自动传 |
445
443
  | 段落边界 | [边界-2.2s, 边界] 内的 whoosh/riser 自动 -3 dB | `sfx.duck_transitions`(默认 true) |
446
444
  | duck | 按风格 3.0–5.5 dB(默认 4.0) | `music.duck_db` 显式覆盖 |
447
445
 
448
- **legacy 兼容**:不写 `style` 字段 = 全部旧行为(配乐逐样本一致、音效 seed_base+i、提示音 Dm 五声)。
446
+ **兼容开关**:`sfx.content_seed: false` 时音效退回 `seed_base + i`(逐样本可复现);`key` 为空时提示音回落 D 小调五声。
449
447
 
450
- ### 13.2.1 旁白优先(v0.7.1)
448
+ ### 13.2.1 旁白优先
451
449
 
452
450
  视频以旁白为主,**BGM 与音效是可选层**:它们负责情绪与重音,不能抢字。具体做法:
453
451
 
454
452
  | 手段 | 默认值 | 说明 |
455
453
  |---|---|---|
456
- | 配乐基线 | `music.gain_db=-12` | 旧版 -1.5 时背景与台词几乎等高;-12 是"人声明显压住背景"的经验线 |
454
+ | 配乐基线 | `music.gain_db=-12` | -1.5 时背景与台词几乎等高;-12 是"人声明显压住背景"的经验线 |
457
455
  | 旁白侧链(配乐) | `voice_duck_db=6` | 有人声的地方再压 6 dB;间隙由包络自动恢复(不会整片闷住) |
458
456
  | 旁白侧链(音效) | `voice_duck_sfx_db=3` | 提示音/转场也让人声 |
459
457
  | 语音阈值 | `voice_thresh_db=-42` / `voice_floor_db=-60` | 10 ms 块 RMS 映射成 0~1 的活动度,起音快、释放慢 |
460
458
  | 旁白自身 | `voice_gain=1.0` | **不被任何侧链影响**——旁白优先不等于"压人声" |
461
459
  | SNR 兜底 | `guard_snr_db=12` / `guard_cap_db=30` | 混音后实测人声活跃区的人声/背景差,不足就整体补压背景(只压背景) |
462
460
 
463
- 实测(真实 edge-tts 旁白 + scorekit 配乐):旧混音 **SNR -4.2 dB**(背景比台词还响,就是"听不清"的原因),
464
- 新混音 **SNR 12.0 dB**;合成信号口径 15.0 dB,真实配乐素材口径 12.9 dB。
461
+ 实测(真实 edge-tts 旁白 + scorekit 配乐):不做上面这些处理时 **SNR -4.2 dB**(背景比台词还响,
462
+ 就是"听不清"的原因);按上表默认值 **SNR 12.0 dB**;合成信号口径 15.0 dB,真实配乐素材口径 12.9 dB。
465
463
  设 `voice_duck_db=0` / `voice_duck_sfx_db=0` / `guard_snr_db=0` 可分别关掉对应机制。
466
464
 
467
465
  ### 13.3 验收:别靠耳朵,跑脚本
@@ -470,13 +468,13 @@ v0.5 的 style pack 决定的是**风格**(8 套),不是**这一部的具
470
468
  python <REPO>/tools/audio_similarity.py --strict # 在包根执行
471
469
  ```
472
470
 
473
- A 组量"同风格换内容指纹"和"换风格"的相关性;B 组量"跨片音效是否还逐样本相同"(同时验
474
- legacy 兼容);C 组读 `render.py` 的 duck 默认值并打印质心对照;
471
+ A 组量"同参数换内容指纹"和"换编排参数"的相关性;B 组量"跨片音效是否还逐样本相同"(同时验
472
+ 位置派生可复现);C 组读 `render.py` 的 duck 默认值并打印质心对照;
475
473
  D 组构造"有人声段 + 间隙",量人声段旁白/背景响度差。任何一项不达标 exit 1。
476
474
 
477
475
  ### 13.4 改音频代码后的自检清单
478
476
 
479
- 1. `python <REPO>/tools/audio_similarity.py --strict` —— A/B/C 三组达标(在包根执行)。
480
- 2. `python selfcheck.py` —— 55 条全 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。
477
+ 1. `python <REPO>/tools/audio_similarity.py --strict` —— A/B/C/D 四组达标(在包根执行)。
478
+ 2. `python <SKILL_DIR>/scripts/selfcheck.py` —— **69 条**全 PASS(第 33 条盯内容派生 seed、第 35–38 条盯旁白优先,第 39 条盯音频边界)。
479
+ 3. `python musicgen.py --selftest` / `python sfxkit.py --selftest` —— 各自全 PASS(musicgen 的 26 项覆盖编排与确定性)。
480
+ 4. 改 `assets/scenes.py` 的 `cues()` 后,跑一次 `--stage cues` 看类型分布是否覆盖 hook/body/quote/outro。
@@ -1,7 +1,7 @@
1
1
  # 改了东西要重跑什么(改动影响表)
2
2
 
3
- > 这张表的存在理由:**"我就改一句台词,要重渲全片吗?"** 这个问题以前只能靠 agent 猜。
4
- > 现在有两个更硬的依据:`render.py --stage explain`(机器判断哪些阶段过时)与本表(人读的因果)。
3
+ > 这张表的存在理由:**"我就改一句台词,要重渲全片吗?"** ——别靠猜。
4
+ > 有两个依据:`render.py --stage explain`(机器判断哪些阶段过时)与本表(人读的因果)。
5
5
  > 两者不一致时**以 `--stage explain` 为准**——它看的是文件的真实指纹,不是经验。
6
6
 
7
7
  ## 0. 先记住三条
@@ -23,7 +23,7 @@
23
23
  | `lines[].sub`(字幕文案) | sub.srt / sub.ass / timeline 的字幕窗口 / frames / mux / qa | `layout → frames → mux → qa`(TTS 可复用) | 帧数÷40 s |
24
24
  | `lines[].hold` / `lead` / `tail` / `bar` | timeline / cues / frames / mux / qa | 同上 | 帧数÷40 s |
25
25
  | `lines[].kind` | 声线计划 / 配乐分段 / 画面版式(scenes 按 kind 选绘制函数) | `style → tts → layout → …` 全链 | 全片 |
26
- | `style`(含 `"auto"` 结果变了) | style.json / voice_plan / tts(换声线时) / score / audio / frames / qa | 看 `--stage explain`:通常是全链 | 全片 |
26
+ | `work/design.json`(设计变了:配色/字体/母题/版式/字幕/配乐/声线) | style.json / storyboard / voice_plan / tts(换声线时) / score / audio / frames / qa | 看 `--stage explain`:通常是全链 | 全片 |
27
27
  | `bpm` / `music.*` | score / audio / mux | `score → audio → mux → qa` | 秒级 |
28
28
  | `sfx.cues` / `scenes.py 的 cues()` | cues.json / sfx.wav / audio.wav / mux | `cues → sfx → audio → mux → qa` | 秒级 |
29
29
  | `scenes.py`(画面代码) | frames / qa(参考帧失效) | `frames → mux → qa`(音轨可复用) | 帧数÷40 s |
@@ -36,7 +36,7 @@
36
36
  | 错误做法 | 后果 | 正确做法 |
37
37
  |---|---|---|
38
38
  | 改了 `text` 只重跑 `tts` | 新语音配旧时间轴 → 字幕漂移、音效错位 | 跑 `--stage explain`,按它给的最小重跑命令 |
39
- | 改了 `style` 只重跑 `frames` | 画风换了但配乐/声线还是旧的(或反之) | `style` 是所有下游的上游,通常整链重跑 |
39
+ | 改了 `design.json`(或重新 `--confirm` 了另一份)只重跑 `frames` | 画风换了但配乐/声线还是旧的(或反之) | 设计是所有下游的上游,通常整链重跑;`--stage style` 先看解析结果 |
40
40
  | 手工删 `work/segments/_plan.json` 绕过指纹 | 拼接出的片子画面和旁白对不上,且**不报错** | 删 `work/segments` 整目录重渲 |
41
41
 
42
42
  ## 3. 一条命令代替这张表
@@ -51,4 +51,4 @@ python scripts/stagewatch.py --work work --json # 机读(给脚本/agent 用
51
51
 
52
52
  签核(`--stage accept`)绑的是**内容指纹**(剧本 + scenes.py + 引擎)。
53
53
  所以:**改了上面表里任何一项,之前的签核都会自动失效**,需要重新看、重新签。
54
- 这不是麻烦,是这套机制存在的意义——否则"我签过了"会变成一张永远有效的空头支票。
54
+ 这不是麻烦,是这套机制存在的意义——否则"我签过了"会变成一张永远有效的空头支票。
@@ -1,10 +1,10 @@
1
- # v0.8 三道新门禁:渲前预检 / 全片 VMAF / ASR 回读
1
+ # 渲染前预检 / 全片 VMAF / ASR 回读三道门禁
2
2
 
3
- > 本页只讲这三个**模块**(`G0` / `G11` / `G12`)。账本里 v0.8 还有第四行 `G10` 词级时间戳,
3
+ > 本页只讲这三个**模块**(`G0` / `G11` / `G12`)。账本里还有第四行 `G10` 词级时间戳,
4
4
  > 它的判据与关闭方式在 [word-timeline.md](word-timeline.md)。
5
5
 
6
- > 三个模块、三道账本门禁,都是**可选**的(不拷对应 .py 就记 `skipped` 并写明原因,不会让渲染失败)。
7
- > 三档语义沿用 v0.6:`pass` = 这次真的查了且通过;`fail` = 查了没过;
6
+ > 三个模块、三道账本门禁,都是**可选**的(模块不可用、web 引擎或显式关闭就记 `skipped` 并写明原因,不会让渲染失败)。
7
+ > 三档语义:`pass` = 这次真的查了且通过;`fail` = 查了没过;
8
8
  > `skipped` = **这次没执行**——它**不等于通过**,交付时要逐项说明为什么没跑。
9
9
 
10
10
  | 模块 | 门禁 id | 什么时候跑 | 实测代价 | 关掉 |
@@ -20,7 +20,7 @@
20
20
  ### 1.1 为什么要有它
21
21
 
22
22
  缺字形(✓ / emoji / 生僻字)和坏 TTS 音频都是"**渲染完才报**"的问题:`filmkit.glyph_missing_chars()`
23
- 本身是准的,但它之前在 `layout` / `qa` 才被调用——等 870 帧渲完,才发现某句 `big` 里有个方块。
23
+ 本身是准的,但只在 `layout` / `qa` 才被调用——等 870 帧渲完,才发现某句 `big` 里有个方块。
24
24
  preflight 不新写检测器,只把现成检测**前移**,并把结论整理成「字符 + 字段 + 句 id」:
25
25
 
26
26
  ```
@@ -80,7 +80,7 @@ python preflight.py --script film.json --work work --engine pil --json
80
80
 
81
81
  ### 2.3 阈值 92 / 96 是怎么来的(**只有一份权威定义**)
82
82
 
83
- 判据来自 qavmaf 作者对**同一部模板片**(参考帧含字幕与片头片尾淡入淡出)的三组实测:
83
+ 判据来自 qavmaf 作者对**同一部示例片**(参考帧含字幕与片头片尾淡入淡出)的三组实测:
84
84
  crf17 → **97.24**(pass)、crf30 → **95.74**(pass + warning)、二次编码 + 缩放坏片 → **29.45**(fail)。
85
85
 
86
86
  **阈值只有一份**:权威定义是 `qavmaf.py` 的 `VMAF_PASS=92.0` / `VMAF_WARN=96.0`
@@ -124,7 +124,7 @@ qavmaf 会在报告里打 warning 提醒这一点,但**不因此改判**——
124
124
 
125
125
  | 项 | 实测 |
126
126
  |---|---|
127
- | 29.011 s 模板片全片(870 帧,1080p 交付) | **14.2 s**(同机负载高时同一份代码测到过 28.8 s,见下"成本口径") |
127
+ | 29.011 s 示例片全片(870 帧,1080p 交付) | **14.2 s**(同机负载高时同一份代码测到过 28.8 s,见下"成本口径") |
128
128
  | 720p@24 交付(696 帧) | **7.2 s**(帧数少 + libvmaf 比较分辨率低) |
129
129
  | 分项吞吐 | 参考帧渲染受 ffmpeg 消费速度限制;libvmaf threads=min(8, CPU) 时约 124–160 帧/秒(threads=0 只有约 20 帧/秒) |
130
130
  | 抽样模式 | `--vmaf sample`(每 5 帧取 1)实测 8.0 s 跑完 184 帧,VMAF 与全片差 0.34 分 |
@@ -136,9 +136,9 @@ qavmaf 会在报告里打 warning 提醒这一点,但**不因此改判**——
136
136
  所以这里只写"这一跑"的数,不写承诺。纯 libvmaf 比较(参考帧已就绪)约 1.2× 实时——
137
137
  **别把"纯比较的 1.2×"当成端到端成本**。
138
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`。
139
+ **qa 比渲染还贵**:29 s 示例片 `--stage all --cost` 实测总 **47.3 s**,
140
+ 其中 `frames` 12.5 s、`qa` 23.9 s(VMAF 14.2 + ASR 8.7 + 其余)。
141
+ 决策影响:**批量出片的瓶颈在质检而不是渲染**——想压回渲染量级就 `--vmaf off` 或 `--vmaf sample`。
142
142
 
143
143
  ### 2.6 单独跑 / 可证伪性
144
144
 
@@ -158,7 +158,7 @@ python qavmaf.py --selftest # 内置自检:验证三档语义不许"没查
158
158
 
159
159
  ### 3.1 为什么
160
160
 
161
- v0.7 及以前的质检只看"容器 / 响度 / 帧 / 音画同步",都建立在一个假设上:
161
+ 容器 / 响度 / 帧 / 音画同步这几项质检都建立在一个假设上:
162
162
  **成片音轨里念的就是剧本那几句话**。可是 TTS 缓存串了、换声线时漏了某句、混音把某段压没了、
163
163
  重新渲了但音轨是旧的——这些从容器和波形上都看不出来。把音轨转回文字逐句对账才能发现。
164
164
 
@@ -188,13 +188,13 @@ v0.7 及以前的质检只看"容器 / 响度 / 帧 / 音画同步",都建立
188
188
 
189
189
  ### 3.4 为什么默认线是 0.85 而不是 0.95
190
190
 
191
- 实测(29 s 模板片,含 "DeepSeek 4.1 Flash / DeepSeek Harness" 这类中英混排专名):
191
+ 实测(29 s 示例片,含 "DeepSeek 4.1 Flash / DeepSeek Harness" 这类中英混排专名):
192
192
 
193
193
  | 场景 | 召回 |
194
194
  |---|---|
195
- | 干净模板片 1080p(4 段全对上、缺句 0) | **0.803** |
196
- | 干净模板片 720p@24(同剧本) | **0.853** |
197
- | 干净模板片(另一轮) | **0.811 – 0.828** |
195
+ | 干净示例片 1080p(4 段全对上、缺句 0) | **0.803** |
196
+ | 干净示例片 720p@24(同剧本) | **0.853** |
197
+ | 干净示例片(另一轮) | **0.811 – 0.828** |
198
198
  | 丢句反证:把 l03 从音轨剪掉、timeline 仍写 3 句 | **0.677**(`missing_lines=["l03"]`、gate=fail、exit 1) |
199
199
 
200
200
  0.803–0.853 不是流水线坏了,是 ASR 对中英混排专名的正常误差——实测把 "DeepSeek 4.1 Flash"
@@ -203,7 +203,7 @@ v0.7 及以前的质检只看"容器 / 响度 / 帧 / 音画同步",都建立
203
203
 
204
204
  - **< 0.72 才说明"整句丢了 / 音轨跟剧本不是一套"**——这是真正要阻断的;
205
205
  - 0.72–0.85 给 warning:仍然出片,但提示可能是缓存串了;
206
- - **≥ 0.95 会把干净的模板片判成 fail**,所以不能拿短测试片的 0.985(19.87 s 纯中文素材,
206
+ - **≥ 0.95 会把干净的示例片判成 fail**,所以不能拿短测试片的 0.985(19.87 s 纯中文素材,
207
207
  没有专名)当默认线。
208
208
 
209
209
  ### 3.5 实测耗时
@@ -233,8 +233,6 @@ python qaasr.py out/film.mp4 --timeline work/timeline.json --script film.json \
233
233
  # 退出码:0=pass;1=fail;2=skipped
234
234
  # 阈值只有一份权威:qaasr.py 的 QA_RECALL_PASS=0.85 / QA_RECALL_WARN=0.72,
235
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
236
  python qaasr.py --selftest # 不联网自检
239
237
  # 离线回放(CI 用,不加载模型):
240
238
  python qaasr.py film.mp4 --timeline ... --asr-json <已有 ASR 结果.json>
@@ -1,6 +1,6 @@
1
1
  # 帧级深度质检(qa_frames)
2
2
 
3
- > v0.3.0 新增。成片质检 v0.2 只看容器、响度、黑帧、锐度——"能出片"不等于"画面里没有一帧白闪"。
3
+ > 成片质检的容器 / 响度 / 黑帧 / 锐度几项只看"能出片",看不出"画面里有一帧白闪"——本模块补的正是这一层。
4
4
  > 这份文档讲清楚 **判据、阈值来源、报告字段、怎么调参、以及这套方法的边界**。
5
5
 
6
6
  实现:`skills/video-studio/scripts/qa_frames.py`(只依赖 stdlib + numpy + Pillow,全部解码走 ffmpeg)。
@@ -102,10 +102,10 @@ s[t] = max over 8x8 blocks of mean(d[t]) # 分块取最大均值(块内
102
102
 
103
103
  实测正常运动画面 `s <= 6.4`,真实异常 `s = 10–15`,所以阈值默认 `6.0`。
104
104
 
105
- > **历史**:早期版本还有一条"严格孤立"(要求 `s[t-1] < thresh` 且 `s[t+1] < thresh`)。
106
- > 它被证明只会制造假阳性(16.933 s 案例就是两侧恰好低谷),已删除,只保留比值判据。
105
+ > **不要加回"严格孤立"**(要求 `s[t-1] < thresh` 且 `s[t+1] < thresh`):它只会制造假阳性
106
+ > (16.933 s 案例就是两侧恰好低谷),判据只保留比值。
107
107
 
108
- **第三步:必须"回到原位"**(`FLICKER_RETURN_RATIO = 1.6`,v0.3.0 实测补充):
108
+ **第三步:必须"回到原位"**(`FLICKER_RETURN_RATIO = 1.6`):
109
109
 
110
110
  ```
111
111
  r[t] = |F[t+1] - F[t-1]| # 同一块上的"前后两帧彼此差多少"
@@ -122,7 +122,7 @@ r[t] = |F[t+1] - F[t-1]| # 同一块上的"前后两帧彼此差多少"
122
122
  | 判定 | 记 error | 跳过 |
123
123
 
124
124
  没有这道闸时,**每一段"缓动入场 / 逐字入场 / 转场黑场起坡"的最快帧都会被报成闪烁**。
125
- 实测反例(本技能自带的 `assets/web/` 模板片):12 s 的片子里报了 3 处——
125
+ 实测反例(本技能自带的 `assets/web/` 示例片):12 s 的片子里报了 3 处——
126
126
 
127
127
  | 时刻 | score | 真相 |
128
128
  |---|---|---|
@@ -175,9 +175,9 @@ r[t] = |F[t+1] - F[t-1]| # 同一块上的"前后两帧彼此差多少"
175
175
  **绝对响度是否合规(-16 LUFS ±、真峰 ≤ -1 dBTP)以 `filmkit.loudness()`(ffmpeg ebur128)为门禁**,
176
176
  本曲线只回答"哪段掉下去了、重击比环境高多少、空窗处声音是不是也停了"。
177
177
 
178
- ### 2.4b `_static_warnings` — 画面长时间静止(v0.4 重写)
178
+ ### 2.4b `_static_warnings` — 画面长时间静止
179
179
 
180
- 判据(**替换** v0.3 的"全帧平均亮度 3 s 内波动 < 0.002"):
180
+ 判据(**不要用**"全帧平均亮度 3 s 内波动 < 0.002"这类公式):
181
181
 
182
182
  ```
183
183
  drift[t] = max over 8x8 blocks of mean |F[t] - F[t-lag]| * 255 (lag = FLAT_LAG_S = 0.5 s)
@@ -188,8 +188,8 @@ drift[t] = max over 8x8 blocks of mean |F[t] - F[t-lag]| * 255 (lag = FLAT_L
188
188
  人眼看到的就是一张静止的图。用"最活跃块"而不是"全帧均值",是因为逐字入场、数字滚动、卡片切换
189
189
  只占全屏几个百分点,全帧均值几乎不动,而观众显然看得到它们在动。
190
190
 
191
- 为什么旧判据要废:全帧均值对局部动画天生不敏感。自带模板片(30.10 s)实测误报 **4 段**
192
- (411/814 个滑动窗口命中),而画面一直在动。新判据在同一片上 **0 段**。
191
+ 为什么不用全帧均值:它对局部动画天生不敏感。自带示例片(30.10 s)实测误报 **4 段**
192
+ (411/814 个滑动窗口命中),而画面一直在动。现判据在同一片上 **0 段**。
193
193
 
194
194
  实测分布(160×90 解码,H.264):
195
195
 
@@ -214,7 +214,7 @@ drift[t] = max over 8x8 blocks of mean |F[t] - F[t-lag]| * 255 (lag = FLAT_L
214
214
  2. 否则**时间最近**:任意事件里取最近者,**但只有 `|dt| <= tol` 才认可**——用来佐证"确实同步了";
215
215
  3. 都找不到 → 视为"这个 cue 没有对应画面事件",**不判超差**。
216
216
 
217
- 第 2 条为什么收紧(v0.3.0 实测):画面事件和音频 cue 本来就不是一一对应。
217
+ 第 2 条为什么只认 `|dt| <= tol` 的最近事件:画面事件和音频 cue 本来就不是一一对应。
218
218
  实测反例:4.100 s 的 `data_chirp` 被 3.700 s 的"交界黑场起坡"事件配对,报 `dt = -0.400 s` 超差;
219
219
  8.020 s 的 `reverse_swell` 被 7.700 s 的事件配成 `-0.320 s`。两者都是**假 FAIL**——
220
220
  跨 kind 的"最近事件"根本不是"预期事件"。
@@ -229,9 +229,9 @@ drift[t] = max over 8x8 blocks of mean |F[t] - F[t-lag]| * 255 (lag = FLAT_L
229
229
  `flash / shake / ring` 而 cue 是 `impact / whoosh`,同 kind 永远配不上,跨 kind 又只做确认,
230
230
  结果就是 **`sync` 恒为空、检查静默全过**——看起来"零超差",其实是"一条都没在查"。
231
231
 
232
- #### v0.4:PIL 路线的两条补救(这条门禁以前真的没在查)
232
+ #### PIL 路线的两条补救(否则这条门禁不会真的在查)
233
233
 
234
- `--engine pil` 根本没有页面、没有 `events`,所以上面的配对必然配不上。v0.4 给了两条路:
234
+ `--engine pil` 根本没有页面、没有 `events`,所以上面的配对必然配不上。两条补救:
235
235
 
236
236
  1. **cue 自带画面锚点**:给 cue 加 `"visual_at"`(或 `"visual_t"`)字段,值是画面上该事件
237
237
  **真正发生的绝对秒数**。`sync_check` 会直接算 `dt = visual_at - cue.t` 并判超差,
@@ -462,9 +462,9 @@ JSON 里 `flicker` 两项:
462
462
 
463
463
  ---
464
464
 
465
- ## 9. 案例:16.933 s「小元素动画」误报(判据是怎么演化的)
465
+ ## 9. 案例:16.933 s「小元素动画」误报
466
466
 
467
- PIL 模板演示片(30 s / 1080p30)早期版本报过 `t=16.933s score=6.61`,bbox `96,40–104,48`。
467
+ PIL 模板演示片(30 s / 1080p30)报过 `t=16.933s score=6.61`,bbox `96,40–104,48`。
468
468
  逐层复现(160×90、8×8 块、area 采样)后,数据是这样的:
469
469
 
470
470
  | 帧号(t) | 504 (16.800) | 505 (16.833) | 506 (16.867) | 507 (16.900) | 508 (16.933) | 509 (16.967) | 510 (17.000) |
@@ -481,8 +481,8 @@ PIL 模板演示片(30 s / 1080p30)早期版本报过 `t=16.933s score=6.61`
481
481
  3. **真相是"小元素在动"**:卡片上一个绿色光点在漂移(全分辨率二阶差分 > 30 的像素只占全屏 0.33%),
482
482
  504–510 帧连续都在动;508 只是恰好被两侧 507/509 的低谷夹住,`ratio = 6.48 / max(5.66,3.52) = 1.15 < 1.5`。
483
483
 
484
- **判据因此改了一次**:删掉"要求邻居低于阈值"的严格路径(它只会制造这种假阳性),
485
- 只保留比值判据;后来又加了"回到原位"闸(§2.2)处理加速运动。
484
+ **所以判据不要求"邻居低于阈值"**:那条严格路径只会制造这种假阳性,只保留比值判据;
485
+ 另加"回到原位"闸(§2.2)处理加速运动。
486
486
  复核这类案例最快的两个线索:**`median`(这里是 1.0,真白闪是 143–199)** 和
487
487
  **变化像素占全屏的比例(这里是 0.33%)**。
488
488
 
@@ -500,3 +500,35 @@ PIL 模板演示片(30 s / 1080p30)早期版本报过 `t=16.933s score=6.61`
500
500
  | 空窗漏报 | 近黑但没到 0.045(例如深灰 0.06)→ 调高 `BLACK_LUMA_MAX`;或时长不足 0.8 s |
501
501
  | 同步全不报 | ① `cues.json` 没有 `events`(页面未导出);② **events.kind 与 cue 配方名不同名**(最常见,且不会任何报错);③ cue 与事件时间差 > 同 kind 搜索窗 2 s |
502
502
  | 想确认同步检查真的在工作 | 故意把某个 cue 的时刻改错 0.3 s 重跑,**必须**报出超差;不报就是没配上(见上一行) |
503
+
504
+ ## 11. 取景抖动(噪声沸腾)—— 单帧闪烁判据抓不到的一类
505
+
506
+ **现象**:成片看起来"一直在抖",但 `flicker_scan` 报 0、`ssim`/`vmaf` 都很好。
507
+ 用户的一句话往往是"生成的视频有明显的抖动感,这是为啥?"
508
+
509
+ **根因**:底图把颗粒(`_grain_layer`)和材质一起烘进了 1920×1080 的静态图层,逐帧再用
510
+ `Image.EXTENT + NEAREST` 按镜头缓推的缩放比 `s` 取景。NEAREST 的采样网格随 `s` 整像素跳变,
511
+ 颗粒图案跟着**整体错位 1px**——这就是"满屏噪点在沸腾"。它是**位移**型瑕疵,不是亮度型,
512
+ 所以按"前后帧平均亮度差"定义的 `flicker_scan` 对它完全不敏感。
513
+
514
+ **探测方法**(不用整片,10 秒就够):
515
+
516
+ ```python
517
+ import numpy as np
518
+ from PIL import Image
519
+ # 同一主题,连续取 s = 1.0 -> 1+push 的 20~40 帧
520
+ frames = [mod._bg_plate(th, 1.0 + th["push"] * i / 39.0) for i in range(40)]
521
+ worst = max(int(np.abs(np.asarray(a, np.int16) - np.asarray(b, np.int16)).max())
522
+ for a, b in zip(frames, frames[1:]))
523
+ print(worst) # NEAREST 直采 4~6 灰阶(可见沸腾);端点预烘 + blend 后 0~1
524
+ ```
525
+
526
+ **修法**:不要用 NEAREST 直采缩放(BILINEAR/BICUBIC 整帧重采样 29/64 ms,超单帧预算)。
527
+ 改成**端点预烘 + 逐帧 blend**:`s=1.0` 与 `s=1+push` 各烘一张 BILINEAR 底版(每张约 60ms,
528
+ 只做一次),逐帧用 `Image.blend(lo, hi, (s-1)/push)` 插值(约 4.4 ms/帧)。
529
+ 端点内部是平滑重采样,没有网格跳变;颗粒质感保留。上限 `1+push` 是精确边界
530
+ (`_push_scale` 恒 <= 1+push),超出就钳到端点上,不做外推。
531
+
532
+ **护栏**:`selfcheck` 第 68 条(`t_zoom_stability`)断言相邻帧最大差 <= 2 灰阶。
533
+ 改回 NEAREST 直采在这条上直接 fail——这就是"再也不会悄悄退回 NEAREST"的机制。
534
+
@@ -45,7 +45,7 @@ BPM 96, 4/4 → 1 小节 = 2.5 s = 75 帧 @30fps
45
45
  2. 单独一节"不能上屏"黑名单:写清为什么不行、实际是什么
46
46
  3. 口径表:同一数字在报告/字幕/画面里表述完全一致
47
47
 
48
- 真实收益(来自会话考古):75 条主张 + 25 条黑名单,抓出 8 处硬伤,其中"疫情在拆把手后很快平息"这类因果讲反的错误会被评论区第一条指出。
48
+ 真实收益(一次实际核查的账):75 条主张 + 25 条黑名单,抓出 8 处硬伤,其中"疫情在拆把手后很快平息"这类因果讲反的错误会被评论区第一条指出。
49
49
 
50
50
  ## P3 TTS(唯一正确的时长来源)
51
51
 
@@ -73,10 +73,10 @@ tl = K.build_timeline(lines, lead=0.55, tail=0.85, min_dur=3.0)
73
73
  字幕窗口规则(防交叠,来自实战):
74
74
  ```
75
75
  t0 = voice_start - 0.12
76
- t1 = voice_end + 0.45 # v0.2 起不再有 5.2 s 硬钳;要钳短显式传 max_hold
76
+ t1 = voice_end + 0.45 # 没有 5.2 s 硬钳;要钳短显式传 max_hold
77
77
  t1 = min(t1, next.voice_start - 0.12) # 绝不压住下一句
78
78
  ```
79
- 停留时长参考 `0.5 + 字数/9` 秒。**没有默认上限**(v0.2 移除了 5.2 s 硬钳)——
79
+ 停留时长参考 `0.5 + 字数/9` 秒。**没有默认上限**——
80
80
  真要缩短就显式传 `max_hold` / `sub_max_hold`。按字符比例平均分配会产生大量交叠。
81
81
 
82
82
  ## P5 混音(numpy 总线)
@@ -109,7 +109,7 @@ python render.py ... --stage qa # 只读质检,不重
109
109
  ```python
110
110
  sink = K.FrameSink("work/silent.mp4", crf=18, preset="medium") # rawvideo 直通
111
111
  ...
112
- # 视频流默认 -c:v copy(不再二次编码);需要改分辨率时 reencode_video=True
112
+ # 视频流默认 -c:v copy(不二次编码);需要改分辨率时 reencode_video=True
113
113
  K.mux("work/silent.mp4", "work/audio.wav", "out/film.mp4")
114
114
  ```
115
115
 
@@ -9,7 +9,7 @@
9
9
  | 3 | **音频实测反推时间,绝不按字数估算** | 字幕漂移是观众最先察觉的瑕疵 |
10
10
  | 4 | **字幕文本与朗读文本分离**(`sub` 短 / `text` 长) | 上屏可读性与朗读自然度是两个约束 |
11
11
  | 5 | **提示点由画面声明、由声音消费** | 音画对齐从"感觉"变成"数据" |
12
- | 6 | **分块键用绝对帧号**(`seg_025200_025560.mp4`),拼接时显式检缺口;再存一份**计划指纹**(`_plan.json`) | 唯一一次"少了 183 秒还不报错"的根因。只按"文件在不在"跳过还会导致"改了台词却复用旧画面"——v0.4 起指纹不符会**拒绝拼接**并提示删目录 |
12
+ | 6 | **分块键用绝对帧号**(`seg_025200_025560.mp4`),拼接时显式检缺口;再存一份**计划指纹**(`_plan.json`) | 唯一一次"少了 183 秒还不报错"的根因。只按"文件在不在"跳过还会导致"改了台词却复用旧画面"——计划指纹不符会**拒绝拼接**并提示删目录 |
13
13
  | 7 | **JPEG q94 截图替代 PNG**(浏览器方案) | 215 → 55 ms/帧,PSNR 41–44 dB 肉眼无差 |
14
14
  | 8 | **worker 复用浏览器、出错才重建、finally 必关** | 每块开新页会把进程堆到 28 个、内存 2.7 GB |
15
15
  | 9 | **前瞻限幅器补响度,不整体拉高** | 只压峰值不改主体响度 |
@@ -25,7 +25,7 @@
25
25
  | 19 | **写入边界 + 接口先写死 + 一份施工文档** | 多 agent 并行不打架的三个前提 |
26
26
  | 20 | **交付文档如实声明已知边界** | 把"局限"变成可信度,而不是藏起来 |
27
27
 
28
- ## 二、16 条反模式(全部有实证)
28
+ ## 二、17 条反模式(全部有实证)
29
29
 
30
30
  | # | 反模式 | 后果 | 正确做法 |
31
31
  |---|---|---|---|
@@ -45,8 +45,9 @@
45
45
  | 14 | 音频用单声道总线后复制成双声道 | 损失立体声场 | 全程 float32 立体声(配乐做宽度) |
46
46
  | 15 | 静态图叠加忘了 `-loop 1` | 时间轴被截成 1 帧 | `-loop 1 -framerate ` |
47
47
  | 16 | 靠"注意准确性"这种叮嘱做质量 | 无法验收 | 事实核查有编号、出处、黑名单三件套 |
48
+ | 17 | 把颗粒烘进底图,再用 NEAREST 逐帧缩放取景 | **满屏噪点沸腾**(用户原话"明显的抖动感"),而单帧闪烁门禁全程报 0 | 端点预烘 + `Image.blend` 取景;颗粒留在底图但只走平滑重采样(见复盘 ④) |
48
49
 
49
- ## 三、三个非确定性 bug 复盘(最值得读)
50
+ ## 三、四个 bug 复盘(最值得读)
50
51
 
51
52
  ### ① 音轨中断:成片只有前 15 秒有声
52
53
 
@@ -81,3 +82,21 @@ if (inP < 1 && act.i > 0) { ... } // 永远不成立
81
82
  字段从 `t0` 改名成 `start` 后,全片十幕的淡入特效与所有图名框**静默失效**。没有任何报错,画面照常渲染,只是"少了一点东西"。
82
83
 
83
84
  **教训**:质检分两类——**"不能有"**(闪烁、重叠、出画、削波)和**"必须有"**(图名框、淡入、字幕、每段至少一个动态元素)。后者更难,因为需要一份"应然清单"。
85
+
86
+ ### ④ 整片"发抖":颗粒 + NEAREST 取景
87
+
88
+ **现象**:成片从头到尾有轻微抖动感,用户说"有明显的抖动感",但 `flicker_scan` 0 条、
89
+ VMAF 97+、SSIM 0.999——所有现有门禁都说没问题。
90
+
91
+ **根因**:`_bg()` 把颗粒(逐像素 hash 噪声)烘进了静态底图,逐帧再用
92
+ `EXTENT + NEAREST` 按缓推比例 `s` 取景。NEAREST 的采样网格随 `s` 整像素跳变,
93
+ 颗粒图案整体错位 1px。实测背景-only 相邻帧差 **4~6 灰阶**(整屏低幅位移),
94
+ 而"单帧闪烁"判据比的是前后帧平均亮度——压根看不到这种位移。
95
+
96
+ **修复**:`s=1.0` 与 `s=1+push` 两个端点各烘一张 BILINEAR 底版(每张约 60 ms,一次性),
97
+ 逐帧 `Image.blend(lo, hi, (s-1)/push)`(约 4.4 ms/帧)。实测相邻帧差降到 **0~1 灰阶**。
98
+ 为什么不用 BILINEAR/BICUBIC 直采:整帧重采样 29/64 ms,超 15 ms 单帧预算。
99
+
100
+ **教训**:**"能出片 + 指标好看"不等于"看着不抖"**。质检判据要按**瑕疵的物理类型**分类:
101
+ 亮度型(闪烁/黑帧)、位移型(抖动/漂移)、时序型(同步/丢句)。缺哪一类,
102
+ 那一类就会一直漏。补判据的同时补一条 `selfcheck` 断言(第 68 条),否则下次改回 NEAREST 没人发现。