@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
@@ -26,6 +26,11 @@ description: 用代码从零生成中文视频(科普片/纪录片/宣传片/
26
26
  > 下文的 `<SKILL_DIR>` 指**本次加载技能时 `skill` 工具回报的 `resourceBase.path`**。装成插件后它就是
27
27
  > 本插件包内的 `skills/video-studio/`(profile 的 `node_modules/@dsh-gis/dsh-video-studio-skill` 是链接)。
28
28
  > 找不到时按名字定位:`find ~ -path '*/skills/video-studio/scripts/filmkit.py'`。
29
+ >
30
+ > `<REPO>` 指**包根**(仓库根,或装好后 profile 里的 `node_modules/@dsh-gis/dsh-video-studio-skill/`),
31
+ > 即 `<SKILL_DIR>` 往上两级。**`tools/` 与 `schemas/` 在 `<REPO>` 下,不在 `<SKILL_DIR>` 下**:
32
+ > 本节的 `cp <SKILL_DIR>/scripts/...` 把技能拷成 `myfilm/` 之后,那个工作目录里**没有** `tools/`,
33
+ > 所以 `tools/estimate.py` 这类仓库工具要按 `<REPO>/tools/...` 调用(或从包/仓库里取一份过来)。
29
34
 
30
35
  ```bash
31
36
  # 1) 建工作目录,拷模板
@@ -37,9 +42,13 @@ cp <SKILL_DIR>/assets/scenes.py . # 场景模板(改画面即可)
37
42
  cp <SKILL_DIR>/scripts/{filmkit.py,stylekit.py,render.py,selfcheck.py,gates.py,stagewatch.py} .
38
43
  # 可选(v0.3):要程序化音效/配乐就一并拷;要走浏览器渲染再拷 webengine.py + assets/web/
39
44
  cp <SKILL_DIR>/scripts/{sfxkit.py,scorekit.py,voicekit.py,qa_frames.py} .
45
+ # v0.8.0 起渲染前预检 / 全片 VMAF / ASR 回读是三个**可选模块**:
46
+ # 拷了才有对应门禁(G0 / G11 / G12);不拷就记 skipped 并写明原因,不会让渲染失败。
47
+ # 但"skipped != 通过"——交付前请确认这三项是被你**主动**跳过的。
48
+ cp <SKILL_DIR>/scripts/{preflight.py,qavmaf.py,qaasr.py} .
40
49
  cp -r <SKILL_DIR>/assets/web ./web
41
50
 
42
- # 2) 先跑一次自检:32 条回归护栏,20 秒内出结论(不联网;可选模块缺失会标 SKIP)
51
+ # 2) 先跑一次自检:51 条回归护栏,20 秒内出结论(不联网;可选模块缺失会标 SKIP)
43
52
  python selfcheck.py
44
53
 
45
54
  # 3) 【v0.5 新步骤】先确认风格,再改文字
@@ -59,10 +68,29 @@ python render.py --script film.json --scenes scenes.py --work work --stage still
59
68
  # 4.5) 【v0.6】先算成本再开渲:帧数 / 各阶段耗时 / 磁盘 / 分块建议
60
69
  # 项目原则是"成本敏感",而在此之前开渲前拿不到任何数字。
61
70
  # (--json 可机读;有 work/timeline.json 时用真实时长,否则按字数粗估并标注)
62
- python <SKILL_DIR>/../../../tools/estimate.py --script film.json --jobs 4
71
+ # 注意:estimate.py 是**仓库/包里的工具**(<REPO>/tools/),不在 skills/video-studio/ 里,
72
+ # quickstart 拷出来的 myfilm/ 也不会有它;想用就按包根路径调用,或从包/仓库里复制过来。
73
+ python <REPO>/tools/estimate.py --script film.json --jobs 4
74
+ # 【v0.8 必看】estimate 已经算进新增的三道门禁,预演时间不再只是渲染时间:
75
+ # 29 s 模板片从 v0.7 的 total 28.4 s 变成 37.5–83.0 s(vmaf 单阶段 12.6 s 起)。
76
+ # 预演给的是**区间上界**(悲观侧),实测全链约 47.3 s —— 别把区间上界当承诺。
77
+ # 要压回原来的量级:在 film.json 写 "qa": {"vmaf": {"mode": "off"}}——estimate 与
78
+ # render.py 都读这个字段,G11 记 skipped。注意 estimate **没有** --vmaf 参数:
79
+ # --vmaf off|sample 是 render.py 的命令行开关,加在 estimate 上会直接报错;
80
+ # 而 --vmaf sample 只是每 5 帧取 1(仍然跑 VMAF,pass/fail 照判),只有 off 才 skipped。
63
81
 
64
82
  # 5) 一条命令出片(TTS→排版→混音→渲染→合成→质检)
65
83
  python render.py --script film.json --scenes scenes.py --out out/film.mp4 --work work
84
+ # 【v0.8 会变慢,别以为卡死】一条 29.011 s / 870 帧的模板片实测:
85
+ # · 全链 --stage all:47.3 s(v0.7 约 28 s),其中 frames 12.5 s、qa 23.9 s
86
+ # · 渲前资源预检(G0) < 1 s(查字形/字体/已有 TTS 音频是否可解析)
87
+ # · 全片 VMAF+SSIM(G11) 14.2 s(参考帧要按场景函数重渲一遍 870 帧)
88
+ # · ASR 回读(G12) 8.7 s(faster-whisper small / CPU;没装就 skipped)
89
+ # —— **qa 已经超过渲染本身**(23.9 s vs 12.5 s)。同一台机器负载不同能差近 2×,
90
+ # 别把单次秒数当承诺;12 分钟片按 0.4× 实时外推 VMAF ≈ 5 分钟。
91
+ # 想先快速看画面:--preview 15(只渲前 15 秒出 mp4+gif,不写交付账本);
92
+ # 想省掉 VMAF:--vmaf off 或 film.json 的 "qa": {"vmaf": {"mode": "off"}}(记 skipped)。
93
+ # web 引擎没有可重渲的参考帧,G11 恒 skipped——这不是漏查,是路线限制。
66
94
 
67
95
  # 6) 亲眼看质检抽出的帧
68
96
  # work/qa/sheet.png 联系表(全片调性)
@@ -206,10 +234,16 @@ python webengine.py textqa ./web/index.html # 文字重叠/出画/一
206
234
  | 配乐 | `scorekit.py`(程序化) | `work/music.wav` | 和弦表 + 律动层 + 分段能量;段落边界对齐小节线 |
207
235
  | 音效 | `sfxkit.py`(物理建模) | `work/sfx.wav` | 按 `cues.json` 里登记的提示点逐条合成 |
208
236
 
209
- 三条总线在 `--stage audio` 里合成:**音效响起时配乐自动让位**(`duck_db`,默认 3 dB),
237
+ 三条总线在 `--stage audio` 里合成:**音效响起时配乐自动让位**(`duck_db`,v0.7 默认 4 dB,
238
+ cinema/epic 5.5 dB;旧版 3 dB 在段落过门时配乐几乎与音效等高),
210
239
  最后交给 ffmpeg 两遍 loudnorm 到 -16 LUFS / 真峰 ≤ -1 dBTP。**不要**自己在 numpy 里做最终响度标准化——
211
240
  你有 `loudnorm`,它比手写归一化准得多,而且真峰才是门禁。
212
241
 
242
+ > **v0.7 片内分离**:配乐在段落交界本来就有过门(scorekit 的 swell/riser/sub_boom),
243
+ > sfx 轨不再在同一个边界重复放 whoosh/riser——`--stage sfx` 会对落在
244
+ > [边界 - 2.2 s, 边界] 内的转场类 cue 自动 -3 dB(`sfx.duck_transitions=false` 可关)。
245
+ > 提示音的音高现在跟随配乐调性(`in_key(freq, key)`),不再硬编码 D 小调五声。
246
+
213
247
  ### 5.2 提示点:让声音跟着画面走
214
248
 
215
249
  **这是 v0.3 最重要的一个概念。** 画面在该响一下的地方登记一个提示点,音频阶段按提示点合成音效:
@@ -344,21 +378,60 @@ python render.py ... --stage style # 用某份 film.json 解析风格并打印"
344
378
  ### 5.6 混音链(照着做,别自创)
345
379
 
346
380
  ```
347
- 旁白(单声道, 居中) ─┐
348
- 配乐(立体声) ───────┼─> duck(音效包络, -3dB) ─> 求和 ─> 峰值保护(0.89) ─> ffmpeg loudnorm 两遍 ─> -16 LUFS / TP<=-1
349
- 音效(立体声) ───────┘
381
+ 旁白(单声道, 居中) ─────────────────────────────┐
382
+ 配乐(立体声, -12dB 基线) ─> 音效duck(-4dB) ─> 旁白duck(-6dB) ─┤─> 求和 ─> 峰值保护(0.89) ─> loudnorm 两遍 ─> -16 LUFS / TP<=-1
383
+ 音效(立体声) ───────────────────> 旁白duck(-3dB) ─────────────┘
350
384
  ```
351
385
 
352
- - **旁白优先**(听清台词是第一优先):`mix_film` 里旁白固定 `voice_gain=1.0` 居中,配乐默认
353
- `music.gain_db=-1.5`、音效按 `CUE_BASE_DB` 逐条定电平。**"高 12 dB"是人耳经验值,代码不测量也不保证**——
354
- 交付前用 `filmkit.loudness` 或回听确认旁白压得住,别把它当门禁。
355
- - 音效响时配乐自动让位 3 dB(`mix_film(duck_db=3.0)`;注意 `duck()` 函数自身默认是 4.0,
356
- `mix_film` 传的是 3.0——看源码时别被两个默认值绕住)。让得太多会"音乐一抖一抖"。
386
+ - **旁白优先是硬行为(v0.7.1)**,不是文档口号:`mix_film` 里旁白固定 `voice_gain=1.0` 居中、不被任何侧链影响;
387
+ 配乐基线 `music.gain_db=-12`(旧版 -1.5,导致背景与台词几乎等高);有人声的地方再按旁白包络压
388
+ 配乐 6 dB、音效 3 dB(`music.voice_duck_db` / `music.voice_duck_sfx_db`,间隙自动恢复)。
389
+ 实测人声段"旁白/背景"响度差 **12.9 dB**(真实配乐素材下;合成信号口径 15 dB),
390
+ selfcheck 第 35/36 条与 `audio_similarity.py --strict` 的 D 组守着这条。
391
+ - **SNR 兜底(v0.7.1 最后一道闸)**:混音后按台词活跃区实测"人声/背景",不足 12 dB 就整体再压背景
392
+ (`music.guard_snr_db`,最多 `guard_cap_db=30`;补压 >18 dB 会提示检查素材电平)。
393
+ 这是唯一一条"混音后自己改电平"的门禁,保证换任何配乐素材都不会把旁白盖住。
394
+ - **BGM 与音效是可选层**:它们的作用是铺情绪与打重音,不该抢字。想更安静就调低 `music.gain_db`
395
+ 或设 `music.enabled=false`;"让观众听清旁白"永远优先于"背景好听"。
396
+ - 音效响时配乐自动让位:v0.7 起风格表默认 4.0 dB(mono 3.0 / lo-fi·magazine 3.5 /
397
+ cinema·epic 5.5),`film.json` 的 `music.duck_db` 显式值永远优先。旧版 `mix_film` 默认 3.0 dB,
398
+ 在段落过门时配乐几乎与音效等高——这是"配乐和音效听着像同一套"的混音侧成因,已由 `duck_default` 抬高。
399
+ 让得太多会"音乐一抖一抖",所以按风格分级而不是一律压狠。
357
400
  - **本项目不做母带压缩**:链路是 求和 → duck → 峰值保护 0.89 → `ffmpeg loudnorm` 两遍。
358
401
  代码里**没有压缩器**(想加请自行在 `mix_film` 之后接),所以也不存在"压太狠"的问题;
359
402
  要让重击前后有能量差,靠的是配乐分段 energy 与 `suck()`,不是压缩。
360
403
  - 成片音轨必须 48 kHz(`mux` 已显式 `-ar 48000`,selfcheck 第 12 条守着)。
361
404
 
405
+ ### 5.7 声音的片间差异与片内分离(v0.7 新增)
406
+
407
+ > v0.5 解决的是"配乐风格都差不多",但**同一风格下每部片的配乐仍像同一首**——
408
+ > 内容指纹只进了 seed(改音色细节),和弦进行/动机/鼓型全固定。
409
+ > 实测:同风格、同 bpm、同调、只换内容指纹,两段配乐相关性 **0.966**。
410
+
411
+ v0.7 把指纹推进到**结构层**(详见 **references/audio-variation.md**):
412
+
413
+ | 层 | v0.6 | v0.7 |
414
+ |---|---|---|
415
+ | 配乐和声 | 风格固定进行 | 指纹决定**每两小节一次的稳定和弦替换**(不影响乐句末与片尾规则) |
416
+ | 配乐进行 | 风格固定一套进行 | 指纹在风格自带的 calm/body/drive 三档里选一档(同风格素材,只换功能顺序) |
417
+ | 配乐动机 | 风格固定动机 | 指纹对级数做 **±1 稳定偏移**(节奏不变,问句/答句独立派生;跨主音带八度修正) |
418
+ | 配乐鼓型 | 固定落点 | 指纹加 ghost kick(不与现有落点重复),clap 位置 ±0.5 拍 |
419
+ | 出片接线 | 指纹只进 seed | `render.py` 的 `st_score` 把 `fingerprint.full` 传进 `scorekit.render()`;legacy 不传 |
420
+ | 音效 seed | `seed_base + i`(只跟 cue 顺序) | `content_seed(seed_base, style, content_fp, type, t)`(跟内容走) |
421
+ | 提示音音高 | 硬编码 D 小调五声 | `in_key(freq, key)` 跟随配乐调性 |
422
+ | 段落边界 | 配乐过门 + sfx 转场各响各的 | 边界前 2.2 s 内的 whoosh/riser **自动 -3 dB**(不与过门撞车) |
423
+ | duck | 3 dB 一刀切 | 按风格 3.0–5.5 dB,配乐让得更彻底 |
424
+ | 旁白优先(v0.7.1) | 配乐 -1.5 dB、无语音侧链 | 配乐 -12 dB 基线 + 人声处再压 6 dB(音效 3 dB),人声段 SNR 实测 15 dB |
425
+
426
+ **向后兼容**:不写 `style` 字段(legacy)或 `sfx.content_seed: false` 时,音效走旧的
427
+ `seed_base + i` 路径,老片逐样本不变(selfcheck 第 33 条守着这条)。
428
+
429
+ 验收工具(改音频相关代码后必须跑):
430
+
431
+ ```bash
432
+ python <REPO>/tools/audio_similarity.py --strict # A/B/C 三组阈值,失败 exit 1(在包根执行)
433
+ ```
434
+
362
435
  ## 6. 停留窗口:让观众看清楚,而不是整片放慢(v0.3.0)
363
436
 
364
437
  > 来自同一部成片的复盘:"部分画面没看清就被切走了"。**解决办法不是把整片放慢,而是按内容加停留。**
@@ -379,11 +452,62 @@ python render.py ... --stage style # 用某份 film.json 解析风格并打印"
379
452
  2. 出现了**要理解的结构**(网络层次、坐标轴、流程图)→ 留到能扫一眼看完。
380
453
  3. 两个镜头之间**没有内容连续性**(黑一下、空一拍)→ 让下一幕提前开始,而不是加长黑场。
381
454
 
455
+ ## 6.1 词级时间轴与词锚点(v0.8.0)
456
+
457
+ > **一句话**:句时长仍然是权威;词级时间戳是**附加值**,只用来「字幕按词折行 / cue 打到某个词上 /
458
+ > 门禁判它有没有变」——它不参与排轴,也**绝不**用词时长之和去推算句子时长。
459
+
460
+ **开与关**:`film.json` 顶层 `"words": true|false`,默认 **true**。
461
+ `--stage tts` 会额外收 edge-tts 的 `WordBoundary` 事件,落盘 `work/words.json`:
462
+
463
+ ```jsonc
464
+ { "schema": 1, "enabled": true,
465
+ "lines": { "l03": { "dur": 7.18, "n": 13, "covered": 5.2, "tail": 0.09,
466
+ "gaps": [0.18, 0.21, ...], "mono": true,
467
+ "words": [{"text":"画质","start":0.04,"end":0.32}, ...] } },
468
+ "stats": { "lines": 4, "words": 64, "empty": [] } }
469
+ ```
470
+
471
+ **它怎么来的(实现口径,改代码前先看)**:
472
+ - 必须显式 `edge_tts.Communicate(..., boundary="WordBoundary").stream()` 同时收音频与词事件。
473
+ 用 `save()` 会把词事件丢掉;不传 `boundary` 时默认是 `SentenceBoundary`,**一条词事件都收不到**
474
+ (实测 edge-tts 7.3.1 / zh-CN:默认设置下词事件数 = 0)。
475
+ - 音频先写 `<句>.mp3.part` 再 `os.replace`。旧写法直接 `open(..., "wb")`,一次断网就把**已缓存的好音频
476
+ 截成 0 字节**;现在失败只丢这次的新音频,旧缓存还在。
477
+ - 词事件 offset 是相对**原始 mp3** 的 100 ns 单位;`trim_silence=True` 时时间轴用的是裁剪后的语音,
478
+ 所以 `words_normalize(offset_shift=裁掉的句首秒数)` 会把词表整体平移。**不平移,词会整体提前 0.1–1 s**
479
+ (实测模板句 shift = 0.10 s 量级)。
480
+ - 词表有独立缓存键 `tts_words_key`,没并进 `tts_key_v2`:老音频照旧复用,只在"这次显式要词级"时补采一次。
481
+ - 词事件收不到**不是失败**:meta 里记 `words_ok=false`、`words_events=0`,打印 WARN 并复用已有音频;
482
+ G10 会记 `skipped`("词事件为空"),不许冒充 pass。
483
+
484
+ **下游怎么用**:
485
+
486
+ | 能力 | 用法 | 退化行为 |
487
+ |---|---|---|
488
+ | 字幕按词折行 | `fit_sub_lines(..., words=)` / `draw_subtitle(..., words=)` | 词表对不上原文或折出来超过 2 行 -> **回退字符折行**(老项目排版不变) |
489
+ | cue 词锚点 | `"at": "l03:w2"` / `"at": "l03:w2+0.1"`(**从 1 数**,w1 = 第一个词) | 该句没有那么多词 -> 退化为 `l03+偏移` 并打 WARN;场景 id 也不存在才报错 |
490
+ | 门禁判据 | G10 读 `work/words.json`(唯一事实来源,不再各自翻 `*.mp3.json`) | 没开/没数据 -> skipped |
491
+
492
+ **三个坑(都在真实跑里踩过)**:
493
+
494
+ 1. **词间有空隙**:实测 edge-tts 相邻词之间有 0.1–0.3 s 不在任何事件里。所以"词时长求和"永远小于句长,
495
+ G10 的覆盖率阈值才定在 45%(低于它才是"明显少了"),别拿 `sum(dur) == dur` 当判据。
496
+ 2. **句尾静音不在词事件里**:约 0.5 s,靠**整句实测时长**兜底。G10 只检查"末词终点离句尾不超过 0.40 s"
497
+ (模板片实测 0.07–0.12 s),超过它通常意味着词事件本身错位了。
498
+ 3. **词锚点会静默退化**:`l03:w99` 不会炸,只会 WARN 后退回句偏移。**改台词后先看 stdout 有没有这条 WARN**——
499
+ 否则你会以为自己精确打到了某个词。同样的,`words=false` 或词事件为空时,字幕不会报错,只是退回整句断行。
500
+
501
+ **老项目第一次升级**:默认 `words=true` 会让每句**重新联网合成一次**(老缓存里没有 `words_key`)。
502
+ 一次全片 TTS 的网络成本,换词级能力;不想付就把 `"words": false` 写进 `film.json`,行为回到 v0.7。
503
+
504
+ 细则(词级契约、时长权威性、裁剪平移公式、词锚点退化路径)见 [references/word-timeline.md](references/word-timeline.md)。
505
+
382
506
  ## 7. 质检门禁(error 必过 / warning 别当看不见)
383
507
 
384
508
  ```bash
385
509
  python render.py ... --stage qa # 只读质检:分辨率/音轨/时长/响度/真峰/锐度比/黑帧/字幕条数 + 帧级深检
386
- python selfcheck.py # 技能自身回归(32 条曾真的坏过的点)
510
+ python selfcheck.py # 技能自身回归(51 条曾真的坏过的点)
387
511
  python qa_frames.py out/film.mp4 --cues work/cues.json # 单独跑帧级深检,出 JSON + 图
388
512
  # v0.6:qa / all 收尾会自动写 out/QC-REPORT.md(机器可读交付结论)
389
513
  # 并落 work/gates.json(三档 gate matrix)与 work/cost.jsonl(实测耗时台账)
@@ -409,6 +533,11 @@ python render.py ... --stage qa --strict-gates # 交付前:skipped 也算失
409
533
  python render.py ... --stage accept --gate stills --note "看过联系表" # 人工签核
410
534
  ```
411
535
 
536
+ **报告头部有「运行编号」与「输入指纹」两行**(v0.8):前者是 `--run-id`(不给就按时间戳生成),
537
+ 后者是 script / scenes / words / filmkit 四个内容的指纹。同一部片跑了好几版之后,
538
+ "上一版的门禁结论到底是什么"就靠 `work/runs/<runId>/` 里的快照回答(只归档小文件,
539
+ 视频/音频/分块就地保留)。
540
+
412
541
  **四道人工签核闸**(`style` 风格与声线 / `stills` 母版静帧 / `facts` 上屏数字 / `release` 成片验收):
413
542
 
414
543
  - 签核前会检查"证据产物存在"(如 `stills` 要求 `work/qa/sheet.png`),**没有证据不许签**——
@@ -428,14 +557,32 @@ python render.py ... --stage accept --gate stills --note "看过联系表" #
428
557
 
429
558
  ```bash
430
559
  python render.py ... --stage explain # 哪些阶段过时了、为什么、最小重跑命令
431
- python render.py ... --stage explain --json # 机读
432
560
  python render.py ... --stage qa --strict-stale # 有过时就失败(CI / 交付前)
561
+ # 机读:问 stagewatch(render.py 的 explain 只打人读文本,不接受 --json)
562
+ python scripts/stagewatch.py --work work --json
433
563
  ```
434
564
 
435
565
  机制:每个 stage 结束往 `work/manifest.json` 记一行 `{stage, at, inputs:{名:指纹}, outputs:{名:指纹}}`;
436
566
  下次该 stage 开始前重算上游输入与自己的产物指纹。**默认只告警**(`--strict-stale` 升级为失败,
437
567
  `--allow-stale` 显式放行并留痕)。改一句要重跑什么,见 [references/change-impact.md](references/change-impact.md)。
438
568
 
569
+ **v0.8 的四道新门禁**(账本 id:`G0` / `G10` / `G11` / `G12`,报告里按记的顺序排在 `G4` 前后):
570
+
571
+ | 门禁 | 查什么 | 三档语义(`pass` / `fail` / `skipped`) |
572
+ |---|---|---|
573
+ | `G0` 渲染前资源预检 | 剧本结构、全部上屏文本逐字过字形检测(按字段选字重)、`work/tts/*.mp3` 能否被 ffprobe 解析、字体候选能否加载、web 入口页是否存在 | 有 error -> `fail`;只有 warning -> `pass`(warning 仍进账本);没拷 `preflight.py` -> `skipped` |
574
+ | `G10` 词级时间戳 | `work/words.json` 的覆盖率/单调性/末词与首词偏差 | 判据不过 -> `fail`;`words=false`、没有 `words.json`、词事件为空 -> `skipped`(写明原因) |
575
+ | `G11` 全片 VMAF/SSIM | 参考帧由**场景函数实时重渲**,经 rawvideo 喂 ffmpeg 与成片逐帧比(不是从成片抽帧——那样恒等于 100) | VMAF < **92** -> `fail`;[92, 96) -> `pass` + warning;缺 `qavmaf.py` / web 引擎 / `--vmaf off` / 没 libvmaf -> `skipped`。阈值只有一份,定义在 `qavmaf.VMAF_PASS / VMAF_WARN` |
576
+ | `G12` ASR 回读 | 成片音轨转文字,按 timeline 每段窗口算**字符级多重集召回**(中文没有词边界,字符级才成立;多重集是因为"是"这种字要计次数) | 召回 < 0.72 -> `fail`;[0.72, 0.85) -> `pass` + warning;两个引擎都不可用 / 没装 `qaasr.py` / 显式关闭 -> `skipped` |
577
+
578
+ **为什么 G11 值得全片跑**:锐度比只在 4 个时刻采样,能发现"整体发虚",发现不了"某 30 秒里字幕糊了"。
579
+ VMAF 是唯一覆盖整条时间轴的客观感知指标。代价是**每片多渲一遍参考帧**(29 s 片实测 14.2 s 全跑完;
580
+ > 机器被其它任务占满时同一份代码测到过 28.8 s,同机不同负载差近 2×,只认"这一跑"的数字)。
581
+ **为什么 G12 值得跑**:容器/响度/帧级质检全都默认"音轨里念的就是剧本那几句"——缓存串了、漏句、被压没了,
582
+ 从波形和容器上都看不出来。代价约 6 s/片(small 模型,CPU)。
583
+
584
+ G0 / G11 / G12 三个新模块(外加 `G10` 词级)的实测数据、阈值来源与关闭方式见 [references/v08-quality.md](references/v08-quality.md)。
585
+
439
586
  **质检分三层**,一层比一层更接近"观众真的看到了什么":
440
587
 
441
588
  | 层 | 查什么 | 工具 | 抓得住什么 |
@@ -456,7 +603,7 @@ python render.py ... --stage qa --strict-stale # 有过时就失败(CI / 交
456
603
 
457
604
  | 判据 | 阈值 | 失败怎么办 |
458
605
  |---|---|---|
459
- | 分辨率/帧率 | 严格等于设定值 | 检查 FrameSink 参数;`strict=True` 会直接报错而不是静默缩放 |
606
+ | 分辨率/帧率 | 严格等于**交付规格**(v0.8:`timeline.video` 的 out_w/out_h/fps;缺省回落 1080p/30) | 检查 `FrameSink` 参数与 `film.json` 的 `video` 块是否一致;`strict=True` 会直接报错而不是静默缩放 |
460
607
  | 音轨缺失 | 必须有音轨 | mux 参数漏了 `-i audio.wav` |
461
608
  | 时长 | 与 `frames/FPS` 差 < 0.5 s | 断点续渲漏帧;分块模式检查缺口检测 |
462
609
  | **真峰** | **> -1.0 dBTP**(-1.0 ~ -1.4 之间只 warn) | 混流前目标设 -1.5(AAC 会把真峰抬高 ~0.9 dB) |
@@ -466,6 +613,10 @@ python render.py ... --stage qa --strict-stale # 有过时就失败(CI / 交
466
613
  | **音画同步(v0.3)** | 判到超差(`dt > 60 ms`)即 error;**但默认可能根本没在查**(见下方两小节) | 要给 cue 写 `visual_at`,或设 `QA_REQUIRE_SYNC=1` 把"没查"也变成 error |
467
614
  | **字幕条数** | **= 剧本句数** | 少一条就是静默丢字幕,查 `sub_window` 与 `write_srt` |
468
615
  | **时长(时间层复核)** | 与预期差 > 0.5 s | `qa_frames` 会独立再查一次时长 |
616
+ | **渲染前资源预检(v0.8,G0)** | 有任何 error(缺字形/坏 TTS 音频/字体全不可加载/web 入口页不存在) | 按报错里的「字符 + 字段 + 句 id」改:换说法、改用矢量画、`--force-tts` 重合成。**这一步在渲第一帧之前**,改起来最便宜;`--no-preflight` 可跳过(会在 QC 报告留痕,不算通过) |
617
+ | **词级时间戳(v0.8,G10)** | 词覆盖率 < 45%、词偏移非单调、末词终点离句尾 > 0.40 s、首词起点离句首 > 0.20 s、或有句子一条词事件都收不到 | 多为 edge-tts 服务端行为变了或该句被裁坏了:先看 `work/words.json` 的 `stats.empty`,再 `--force-tts` 重采;长期不可靠就把 `"words": false`(退回整句字幕,G10 记 skipped) |
618
+ | **全片 VMAF/SSIM(v0.8,G11)** | VMAF 均值 **< 92** fail;**[92, 96)** 只记 warning(能出片,提示复核/别降档);SSIM(0.95/0.98)是诊断项不单独阻断 | 先看是不是 CRF/preset 调高了或走了二次编码;阈值只有一份(`qavmaf.py` 的 `VMAF_PASS/WARN`),按片覆盖写 `"qa": {"vmaf": {"pass": 92, "warn": 96}}`;`--vmaf off` 可关(记 skipped)。web 引擎恒 skipped(没有可重渲的参考帧) |
619
+ | **ASR 回读(v0.8,G12)** | 字符召回 **< 0.72** fail;**[0.72, 0.85)** pass + warning;**≥ 0.85** 干净 | 抓的是"音轨念的不是剧本":TTS 缓存串了、换声线漏句、混音压没了。低于 0.72 先 `--force-tts` + 检查 `work/tts.json`;`"qa": {"asr": {"enabled": false}}` 可关(记 skipped) |
469
620
 
470
621
  #### warning 档(不阻断,但别当看不见)
471
622
 
@@ -554,6 +705,84 @@ v0.4 给了两条真正能用的路:
554
705
  两条都没做时,`deep.json` 里 `sync` 恒为 `[]`,同时会有一条 warning 明说"同步对照跳过"。
555
706
  **看到 `sync: []` 先确认是"真的同步"还是"根本没查"**——`warnings` 里有没有那句话就是判据。
556
707
 
708
+ ## 7.5 v0.8 交付生产线:画幅 / 批量 / 预览 / 运行版本化
709
+
710
+ ### 7.5.1 画幅与帧率(只做 16:9 横屏)
711
+
712
+ ```jsonc
713
+ "video": { "width": 1280, "height": 720, "fps": 24 } // 不写 = 1920x1080@30(v0.7 行为)
714
+ ```
715
+
716
+ | 规则 | 实际行为 |
717
+ |---|---|
718
+ | 内部构图 | **恒为 1920x1080**;输出更小时在编码阶段用 ffmpeg `scale`(lanczos)下采样(`FrameSink(out_w=, out_h=)`) |
719
+ | 允许的输出尺寸 | 1920x1080 / 1280x720 / 960x540;2560x1440 在"允许列表"里但**会被上采样规则拒掉**(见下) |
720
+ | 允许的 fps | 24 / 25 / 30 / 50 / 60 |
721
+ | 竖屏 | **直接报错**。安全区、版式几何、HUD、字幕折行全部按 16:9 的像素坐标写死,竖屏是另一套版式 |
722
+ | 非 16:9 | **直接报错**(换宽高比要重做版式几何与字幕安全区,不是把宽高换一下) |
723
+ | 上采样 | **直接报错**。输出大于 1920x1080 = 把 1080p 包装成"更高分辨率"(假 4K);要真高清得改内部构图 |
724
+ | 非法 fps | **直接报错**(报错里列出可用值) |
725
+
726
+ 链路:`filmkit.video_plan(script)` 在 `layout` 阶段就解析并校验(错了不拖到渲帧),
727
+ 结果写进 `timeline.json` 的 `video` 块;`G4` 容器门禁改成**按交付规格**比对(不是恒看 1920x1080);
728
+ `encode_quality` 的参考帧会先缩到交付分辨率再比锐度。
729
+
730
+ > **跨分辨率别比锐度比**:同一片 1080p 交付实测 1.009、720p 交付实测 1.206——数值不可横向比较
731
+ > (参考帧也被缩到交付分辨率,笔画占画面的比例变了)。它只用来判"这一版有没有比上一版糊",别拿它当"720p 更清晰"。
732
+ >
733
+ > 720p 交付已实测通过全部门禁:`G4 = 1280x720@24`、`G11 = VMAF 97.10 / SSIM 0.9925`(696 帧,7.2 s)。
734
+ > `filmkit.qa_report` 为此新增 `expect_w/expect_h/expect_fps`——容器门禁比的是**交付规格**,
735
+ > 不是死比 1920x1080;改规格时记得让 `timeline.video` 与之一致(qavmaf 会核对两者是否同源)。
736
+
737
+ ### 7.5.2 批量生产(`--batch`)
738
+
739
+ ```jsonc
740
+ // films.json —— 两种写法都接受
741
+ ["a/film.json", "b/film.json"]
742
+ {"films": [{"script": "a/film.json", "scenes": "a/scenes.py",
743
+ "work": "a/work", "out": "out/a.mp4", "label": "A"}]}
744
+ ```
745
+
746
+ ```bash
747
+ python render.py --batch films.json
748
+ ```
749
+
750
+ | 行为 | 细节 |
751
+ |---|---|
752
+ | 上限 | **100 条 / 1 MiB**(清单本身不该是个大 JSON);超了直接报错让你拆份 |
753
+ | 开跑前 | **全量 `filmcheck` 预检**:任一条不通过就**一条都不跑**,把明细一次性报全(`batch-report.json` 里 `phase="preflight"`) |
754
+ | 执行 | 逐条起**子进程**渲染(每条都是完整的 `--stage all`),**单条失败不阻断其余** |
755
+ | 收尾 | `batch-report.json`(`phase="render"`,含每条 `exit` / `wall_s` / 日志尾)+ 退出码:全成功 `0`,**部分失败 `1`** |
756
+ | 预检失败的退出码 | 也是 `1`(`SystemExit` 带文字,Python 的默认行为);区别看 `batch-report.json` 的 `phase` |
757
+
758
+ 实测(本机,2 条 29 s 模板片,v0.8 早期一轮):**67.2 s / 60.1 s**,各 `pass 11 · fail 0 · skipped 1`。
759
+ 注意每条都要付 G11 的参考帧成本——批量不是"摊销",是线性叠加。**而且瓶颈已经转到 qa**:
760
+ 单条全链实测 47.3 s 里 `qa` 占 23.9 s(见 §1 的时间表),批量时这个比例会原样复制。
761
+
762
+ ### 7.5.3 预览片(`--preview N`)
763
+
764
+ ```bash
765
+ python render.py ... --preview 15 # 只渲前 15 秒
766
+ ```
767
+
768
+ - 产物:`out/preview/<片名>_preview.mp4` + 同名 `.gif`(480 px / 10 fps,两遍调色板生成)。
769
+ - 音轨:有 `work/audio.wav` 就**先截到预览时长再混入**。直接拿全长音轨去配短片,`mux` 会判
770
+ "音视频时长差 ≥ 0.05 s" 而失败(v0.8 开发期实测踩过);没有音轨就出无声版,不阻断。
771
+ - **不落交付账本**:它是给人快速看效果的,不是交付证据;正式结论仍以 `--stage qa` 的报告为准。
772
+
773
+ ### 7.5.4 运行版本化(`--run-id` + `work/runs/`)
774
+
775
+ ```bash
776
+ python render.py ... --run-id hero-v3 # 不给就按时间戳生成 rYYYYMMDD-HHMMSS
777
+ ```
778
+
779
+ - `work/gates.json` 增 `runId` 与 `inputs`(**script / scenes / words / filmkit** 四个内容指纹)。
780
+ 为什么要逐项指纹:`contentHash` 只说"变了没",说不出"变了哪个";批量出片最常见的事故就是
781
+ "这一条用的还是上一版的词级时间戳"。
782
+ - 每次收尾(`gates.archive_run`)把**账本快照 + QC-REPORT.md + run.json** 复制到
783
+ `work/runs/<runId>/`。只归档小文件;视频/音频/分块体积大,就地保留。
784
+ - `out/QC-REPORT.md` 头部多两行「运行编号」「输入指纹」,交付时一眼能对上是哪一版跑的。
785
+
557
786
  ## 8. 常见故障速查
558
787
 
559
788
  | 现象 | 根因 | 修法 |
@@ -589,11 +818,15 @@ v0.4 给了两条真正能用的路:
589
818
  - `references/pipeline.md` — 八阶段详细规格、契约 JSON 格式、目录约定、并行分工
590
819
  - `references/scene-authoring.md` — 场景函数写法、构图规则、Canvas/CDP 方案、超采样与后处理
591
820
  - `references/audio.md` — 采样级混音、程序化配乐/音效、响度链路、循环环境音、提示点系统
821
+ - `references/audio-variation.md` — **(v0.7)片间差异**:内容指纹怎么进和声/进行/动机/鼓型,legacy 兼容与验收阈值
592
822
  - `references/sfx-design.md` — **(v0.3)音效设计**:物理拆解流程、配方表、听感诊断表、QC 规则
593
823
  - `references/web-engine.md` — **(v0.3)浏览器引擎**:页面契约、确定性时间轴、分块渲染、崩溃重开
594
824
  - `references/frame-qa.md` — **(v0.3)帧级质检**:单帧闪烁判据、画面空窗、音画同步、报告怎么读
595
- - `references/quality-gates.md` — G0–G6 全部门禁、可复制代码、验收清单
825
+ - `references/quality-gates.md` — 方法论 G0–G8 全部门禁、可复制代码、验收清单(文内另有「两套编号别混用」的映射说明:账本是 `G0`/`G4–G12`)
826
+ - `references/word-timeline.md` — **(v0.8)词级时间轴**:词级契约、句时长为何仍是权威、词锚点写法与退化路径、四个真实坑
827
+ - `references/v08-quality.md` — **(v0.8)三道新门禁**:VMAF/SSIM、ASR 回读、渲前预检的判据、阈值来源、实测数据与关闭方式(词级 G10 见 `word-timeline.md`)
596
828
  - `references/change-impact.md` — **(v0.6)改动影响表**:改了 text/sub/hold/style/scenes.py 各要重跑什么
829
+ - `references/style-packs.md` — **(v0.5)风格包**:主题/配乐/声线字段、三级指纹规则、加第 9 套风格的步骤
597
830
  - `references/pitfalls.md` — 20 条最佳实践 + 16 条反模式 + 3 个非确定性 bug 复盘
598
831
 
599
832
  ## 10. 脚本资产
@@ -601,19 +834,23 @@ v0.4 给了两条真正能用的路:
601
834
  | 文件 | 作用 |
602
835
  |---|---|
603
836
  | `scripts/filmkit.py` | 生产内核:字体/字形自查/缓存排版/字幕安全区/缓动/确定性随机/音频总线/**CueBus 提示点**/**立体声混音 mix_film**/TTS/时间轴/**hold 停留窗口**/FrameSink/合成/质检 |
604
- | `scripts/render.py` | 编排器:**13 个 stage**(含 `accept` / `explain`)、两种引擎(`--engine pil\|web`)、分块并行(`--jobs`)、断点续跑(`--resume`)、母版预览(`stills`) |
837
+ | `scripts/render.py` | 编排器:**13 个 stage**(含 `accept` / `explain`)、两种引擎(`--engine pil\|web`)、分块并行(`--jobs`)、断点续跑(`--resume`)、母版预览(`stills`)。v0.8 增:`--batch` 批量、`--preview N` 前 N 秒试片、`--run-id` 运行标签(`work/runs/<runId>/`)、`--vmaf full\|sample\|off`、`--no-preflight` |
605
838
  | `scripts/gates.py` | **(v0.6)门禁账本**:pass / fail / skipped 三档、`out/QC-REPORT.md` 渲染、人工签核(绑内容指纹)、原子写 |
606
839
  | `scripts/stagewatch.py` | **(v0.6)阶段新鲜度**:每 stage 记录输入/输出指纹到 `work/manifest.json`;上游一改,下游报过时并给出最小重跑命令 |
607
840
  | `scripts/filmcheck.py` | **(v0.6)film.json 契约校验**:未知键提示"是不是想写 X"(以前静默忽略)、枚举与范围、跨字段(cue 引用的 id 必须存在) |
608
- | `scripts/selfcheck.py` | 回归自检:**32 条**"曾真的坏过"的断言,不联网 20 秒出结论(可选模块缺失标 SKIP) |
841
+ | `scripts/selfcheck.py` | 回归自检:**51 条**"曾真的坏过"的断言,不联网 20 秒内出结论(可选模块缺失标 SKIP)。v0.8 新增 8 条:44 词级时间戳 / 45 画幅规划 / 46 金帧确定性 / 47 依赖漂移体检 / 48 批量清单解析 / 49 TTS 后端可替换 / 50 web 风格载荷(legacy 必须回落)/ 51 金帧回归(调 `tools/golden_frames.py`) |
842
+ | `scripts/preflight.py` | **(v0.8)渲染前资源闸门**:剧本结构 + 全部上屏文本逐字过字形检测(按字段选字重)+ 已有 TTS 音频可解析 + 字体可加载 + web 入口页存在。`render.py` 在 `frames` 之前调用,记门禁 `G0`;也可单独跑 `python preflight.py --script film.json [--json] [--strict]` |
843
+ | `scripts/qavmaf.py` | **(v0.8)全片 VMAF/SSIM 门禁**:参考帧由场景函数**实时重渲**(不是成片抽帧),rawvideo 喂 ffmpeg 与成片逐帧比;一次调用同时算 VMAF 与 SSIM;门禁 `G11` |
844
+ | `scripts/qaasr.py` | **(v0.8)ASR 回读门禁**:成片音轨转文字与剧本对账(字符级多重集召回 + 每句偏移);引擎优先 faster-whisper,其次 ffmpeg whisper 滤镜,都不可用记 skipped;门禁 `G12` |
609
845
  | `scripts/sfxkit.py` | **(v0.3)物理建模音效库**:模态合成/纹理/空间/动力学/母带 + 24 个配方 + `CUE_ALIASES` |
610
846
  | `scripts/scorekit.py` | **(v0.3)程序化配乐**:和弦表/分段能量/律动层/吸气/自动电平,输出 `music.wav` |
611
847
  | `scripts/webengine.py` | **(v0.3)浏览器逐帧渲染**:Edge/Chromium + CDP 截图、分块并行、崩溃重开、cue 导出、文字质检 |
612
848
  | `scripts/qa_frames.py` | **(v0.3)帧级质检**:单帧闪烁/画面空窗/音画同步/亮度与响度曲线(JSON + PNG) |
613
849
  | `assets/scenes.py` | 场景模板(开场/正文卡片/收尾 + **`cues()` 提示点钩子示范**),含缓存排版与预烘焙背景的正确写法 |
614
850
  | `assets/film.json` | 剧本模板(含 `hold`/`bpm`/`music`/`sfx.cues`/`engine` 等新字段) |
615
- | `../../schemas/film.schema.json` | **(v0.6)film.json 的契约**(JSON Schema draft-07 子集)。校验器见 `scripts/filmcheck.py`,模板由 `tools/ci_check.py` 第 12 项守着 |
616
- | `../../tools/estimate.py` | **(v0.6)开渲前预演**:帧数 / 各阶段耗时 / 磁盘峰值 / 分块建议,`--json` 可机读。基准常量集中在文件顶部,换机请重测后回填 |
851
+ | `<REPO>/schemas/film.schema.json` | **(v0.6)film.json 的契约**(JSON Schema draft-07 子集)。校验器见 `scripts/filmcheck.py`,模板由 `tools/ci_check.py` 第 12 项守着 |
852
+ | `<REPO>/tools/estimate.py` | **(v0.6)开渲前预演**:帧数 / 各阶段耗时 / 磁盘峰值 / 分块建议,`--json` 可机读。基准常量集中在文件顶部,换机请重测后回填 |
853
+ | `<REPO>/tools/golden_frames.py` | **(v0.8)金帧像素回归**:渲 3 帧算 SHA-256 与 `assets/golden_frames.json` 比对(`--write` 重录 / `--check` 校验)。同环境像素变了是 fail,换了机器(字体指纹不同)是 skipped——跨机不误报 |
617
854
  | `assets/web/` | **(v0.3)浏览器页面模板**:原生 Canvas2D 确定性时间轴、场景示范、文字质检脚本 |
618
855
  | `requirements.txt` | **(v0.4)Python 依赖清单**:必需(numpy/Pillow/edge-tts)/ 强烈建议(cv2)/ 可选(scipy、playwright)三档,带注释说明"缺了会怎样"。`pip install -r requirements.txt` 即可 |
619
856
 
@@ -642,6 +879,9 @@ v0.4 给了两条真正能用的路:
642
879
  6. `python selfcheck.py` 的 PASS 计数
643
880
  7. **(v0.3)帧级深检的三个数**:单帧闪烁 N 条 / 画面空窗 N 段 / 音画同步超差 N 条
644
881
  (有 web 引擎时再加页面层的 文字重叠 / 出画 / 一闪而过 / 从没露面 四项)
882
+ 8. **(v0.8)新门禁的数**:G10 词级(N 句 / M 词、末词偏差)、G11 的 **VMAF 与 SSIM**(外加用了多少帧)、
883
+ G12 的**字符召回**。这三项若在报告里是 `skipped`,要说明为什么没跑(没拷模块 / web 引擎 / 显式关闭),
884
+ **不能只字不提**。
645
885
 
646
886
  > **v0.6 起不要口头复述这些数字**:`out/QC-REPORT.md` 是机器生成的交付凭据,
647
887
  > 照抄报告里的"实测值"即可;报告里的 `skipped` 项必须逐项说明为什么没执行。
@@ -652,7 +892,11 @@ v0.4 给了两条真正能用的路:
652
892
  > 容器:1920x1080 / 30 fps / h264+aac / 48 kHz / 时长 30.10 s / 1.00 Mbps
653
893
  > 信号:-16.0 LUFS / 真峰 -1.2 dBTP / 锐度比 1.01 / 黑帧 0 / 字幕 4 条 = 剧本 4 句
654
894
  > 时间:单帧闪烁 0 / 画面空窗 0 / 音画同步超差 0(本条为 web 引擎或写了 visual_at 时才有意义)
655
- > 自检:selfcheck PASS 32 / FAIL 0 / SKIP 0;tools/ci_check.py PASS 12 / FAIL 0
895
+ > 词级:4 句 / 64 词 / 末词偏差最大 0.09 s(G10)
896
+ > 画质:VMAF 97.22 / SSIM 0.9928 / 870 帧(G11,参考帧由场景函数重渲)
897
+ > 回读:字符召回 0.803(G12,faster-whisper small;中英混排专名会掉分,< 0.72 才 fail)
898
+ > 自检:selfcheck PASS 51 / FAIL 0 / SKIP 0;tools/ci_check.py PASS 12 / FAIL 0
899
+ > 音频:work/score.json 的 variation 非 0(指纹参与了结构变奏)、audio_similarity --strict 全 PASS
656
900
  > 目检:work/qa/sheet.png,四帧分别为开场星射线、五步成片卡片、画质三件事卡片、收尾居中大字;
657
901
  > 字幕黑底白字不压正文,大字边距均匀,无空洞帧。
658
902
  > ```
@@ -694,7 +938,7 @@ v0.4 给了两条真正能用的路:
694
938
  | `scorekit` | 389 s 全片 **4–8 s**(v0.3 首测 6.7–8.2 s,v0.4 复测 3.9–5.8 s,随负载);60 s 实测 2–4 s;输出 -18.5 LUFS / -1.44 dBTP(母带交给 loudnorm)。**selftest 会打印当下实测**,以那个为准 |
695
939
  | `webengine` | **80–130 ms/帧**(1080p30,Edge 无头,PNG 截图);12 s / 360 帧 **28–30 s**(2 workers,含启动);**同一页面会话内**同帧两次截图 byte 完全一致(跨会话默认 GPU 光栅有 ≤4 像素、±1 级抗锯齿差异,要跨会话字节级一致请开 `--deterministic`,代价约 +22%/帧) |
696
940
  | `qa_frames` | 30 s 片全量深检 **约 1 s**(160×90 流式解码 903 帧);闪烁/空窗/静止/同步 |
697
- | 端到端 | 30.10 s 模板片:TTS 4 句 → cues 13 条 → 配乐 3.3 s → 音效 13 条落位 → 混音 → PIL 75 fps 渲染 12 s → mux → 深检 PASS |
941
+ | 端到端 | 30.10 s 模板片:TTS 4 句 → cues 由 scenes.py 按 kind 生成(v0.7,模板片 17 条)→ 配乐 3.3 s → 音效逐条落位 → 混音 → PIL 75 fps 渲染 12 s → mux → 深检 PASS |
698
942
  | Python 依赖 | **playwright 已装**(chromium + msedge 均可用);scipy 1.18 已装(sfxkit 可选,缺了走 FFT 兜底);`soundfile` **没装**(本技能不依赖它) |
699
943
 
700
944
  ### 排期公式(保守)
@@ -713,7 +957,35 @@ Pillow 路线: 墙钟秒数 ≈ 帧数 / 40 / 并行度 + 30% 余量
713
957
 
714
958
  ## 13. 升级须知(从旧版迁移要动的地方)
715
959
 
716
- ### 13.0 v0.3.0 → v0.4.0
960
+ ### 13.0 v0.4 → 当前版本(v0.5 / v0.6 / v0.7 / v0.7.1 / v0.7.2 / v0.8.0 / v0.8.1 要做什么)
961
+
962
+ **一句话**:v0.4 的老项目**不写新字段就能继续跑**(不写 `style` = legacy 行为,配乐逐样本不变),
963
+ 但**工作目录要多拷文件**,且有两处默认值变了要重新听。按版本逐条过:
964
+
965
+ | 从 → 到 | 你要做的动作 | 依据 |
966
+ |---|---|---|
967
+ | v0.4 → **v0.5** | 新工作目录多拷 `scripts/stylekit.py`(**必需**,render 直接 import)、`scripts/voicekit.py`(可选,缺了单声线降级);`--stage` 多出 `style`,`--stage all` 顺序变为 `style → tts → …`;改用 `style: "auto"` 才会自动选风格,不写仍是 legacy | CHANGELOG 0.5.0「升级须知(v0.4 -> v0.5)」 |
968
+ | v0.5 → **v0.6** | **必需**多拷 `scripts/gates.py`、`scripts/stagewatch.py`(少一个 render.py 直接 `ModuleNotFoundError`);首次跑会多出 `work/manifest.json` / `work/gates.json` / `out/QC-REPORT.md`,属新增文件 | CHANGELOG 0.6.0「升级须知(v0.5 → v0.6)」 |
969
+ | v0.6 → **v0.7** | 想吃到「配乐/音效随内容变化」就保持 `style: "auto"`,**不要**写死 `music.seed` 或关掉 `seed_from_content`;音效想更丰富就把提示点写进 `scenes.py` 的 `cues()` | CHANGELOG 0.7.0「升级须知(v0.6 → v0.7)」 |
970
+ | v0.7 → **v0.7.1** | 配乐基线默认值从 `music.gain_db=-1.5` 变成 **-12**(模板也已改),有人声处再压配乐 6 dB / 音效 3 dB;**之前手调过 `gain_db`/`duck_db` 的片子要重新听一遍**(现在背景整体更低)。显式写 0 可关掉对应侧链 | CHANGELOG 0.7.1「修了什么」+ `assets/film.json` 的 `music` 默认值 |
971
+ | v0.7.1 → **v0.7.2** | `QC-REPORT.md` 现在**只落在 `--out` 的目录**(以前 `accept` 分支按 `work` 的兄弟目录算,`--out deliver/film.mp4` 时签核会更新到另一份没人交付的文件)。如果你用 `--out` 指到了非 `out/` 的目录,交付时请看新位置;旧的 `out/QC-REPORT.md` 不会被再写 | `render._report_path()` 的 docstring + `selfcheck` 第 40 条(本轮新增) |
972
+ | v0.7.2 → **v0.8.0** | ① 新工作目录**多拷三个文件**:`scripts/preflight.py`、`scripts/qavmaf.py`、`scripts/qaasr.py`(都是**可选**:不拷对应门禁记 `skipped` 并写明原因,不会让渲染失败——但 `skipped != 通过`)。② **默认行为变了**:`film.json` 的 `words` 默认 **true**,老项目第一次跑 `--stage tts` 会**把每句重新联网合成一次**(老缓存没有词级记录);不想付这次网络成本就写 `"words": false`。③ **qa 变慢**:全片 VMAF 每片多渲一遍参考帧(29 s 片实测 14.2 s),ASR 8.7 s——全链从约 28 s 涨到 **47.3 s**,qa(23.9 s)已超过渲染本身(12.5 s)。④ **输出仍默认 1080p@30**:不写 `video` 块行为与 v0.7 完全一致。⑤ 自检 43 → **51** 条,三档门禁多了 `G0/G10/G11/G12` 四行 | CHANGELOG 0.8.0「升级须知(v0.7.2 -> v0.8.0)」+ 本节下方逐条说明 |
973
+ | v0.8.0 → **v0.8.1** | ① **删一次 `work/segments`**:`--resume` 的分块指纹新增 timeline 内容,旧计划会判"已变化"。② **退出码变严**:`--stage style/stills/frames/mux` 的门禁 fail(或 `--strict-gates` 下有 skipped)现在真的返回 1——靠退出码判定的交付脚本会开始"失败"。③ **`ci_check` 汇总变三档**:`PASS n / FAIL n / SKIP n`,用 `grep 'PASS 12'` 的脚本要改;要"没执行也算失败"加 `--strict`。④ **`filmcheck` 变严**:`video` 块走 `filmkit.video_plan`(1600x900 之类报 error),`qa.vmaf.enabled`/`qa.require_vmaf`/`qa.ssim.enabled` 三个惰性键报 WARN。⑤ **`webengine` 两处改成 fail-closed**:`style=None` + 720p 不再静默裁切、页面 FPS 与注入值不一致会报错。⑥ 金帧基线**不用重录**(像素逐位一致)。 | CHANGELOG 0.8.1「升级须知(v0.8.0 → v0.8.1)」+「行为变化」表 |
974
+
975
+ > 查当前版本:`python <REPO>/tools/ci_check.py -v` 里的 version 断言、或 `cat package.json` 的 `version`。
976
+ > 升级后建议按 §14 重跑一次自检(51 条),再按 §7 重新签核(改了代码/默认值,旧签核会自动失效,这是设计)。
977
+ >
978
+ > **v0.7.2 → v0.8.0 的具体迁移动作**(表里那一行的展开):
979
+ > 1. 从 `<SKILL_DIR>/scripts/` 拷 `preflight.py` / `qavmaf.py` / `qaasr.py` 过来(不拷也能出片,只是三项门禁 `skipped`)。
980
+ > 2. 决定要不要词级:默认 `words=true` 会让老项目**首次**重合成全部旁白(每句多一次词事件采集)。
981
+ > 不想重合成、或线上环境不允许额外网络请求,就在 `film.json` 写 `"words": false`。
982
+ > 3. 交付前预留时间:全片 VMAF 的参考帧要按场景函数重渲一遍(模板片 14.2 s),ASR 8.7 s/片;
983
+ > 29 s 片全链 47.3 s,**qa 比渲染还贵**。
984
+ > 着急出片用 `--vmaf off`,但请把 `skipped` 项在报告里说明白。
985
+ > 4. 想要别的画幅/帧率才写 `video` 块;**默认输出仍是 1920x1080@30**,老项目不写就完全不变。
986
+ > 5. 有历史交付脚本在 grep 输出的话:`PASS 43` → `PASS 51`,`webengine --selftest 24 项` → `35 项`。
987
+
988
+ ### 13.1 v0.3.0 → v0.4.0
717
989
 
718
990
  **多数项目不用改任何东西**(默认行为向后兼容),但下面几处会**改变你看到的输出或报错**:
719
991
 
@@ -727,7 +999,7 @@ Pillow 路线: 墙钟秒数 ≈ 帧数 / 40 / 并行度 + 30% 余量
727
999
  | `film.json` 校验变严 | 缺 `text` / `lines` 空 / id 重复 / bpm 非法 会直接报错 | 按提示修剧本 |
728
1000
  | 可选 peer 改成按需 import | 缺 `@deepseek-ai/dsh-skill-filesystem` 时插件不再整体加载失败,只是不挂载技能并打日志 | 想用技能就按日志里的命令装依赖 |
729
1001
 
730
- ### 13.1 v0.2 → v0.3.0
1002
+ ### 13.2 v0.2 → v0.3.0
731
1003
 
732
1004
  **兼容性优先:老项目不做任何改动也能跑**(`engine` 默认 `pil`、`music.mode` 缺省时按老的和弦床走、`whoosh` 行为保留)。
733
1005
 
@@ -744,7 +1016,7 @@ Pillow 路线: 墙钟秒数 ≈ 帧数 / 40 / 并行度 + 30% 余量
744
1016
 
745
1017
  **新增文件**:`sfxkit.py` / `scorekit.py` / `webengine.py` / `qa_frames.py` / `assets/web/`(都是可选,缺失时对应能力自动降级并打印 WARN)。
746
1018
 
747
- ### 13.2 v0.1 → v0.2(历史)
1019
+ ### 13.3 v0.1 → v0.2(历史)
748
1020
 
749
1021
  | 变更 | v0.1 | v0.2 | 迁移动作 |
750
1022
  |---|---|---|---|
@@ -762,10 +1034,10 @@ Pillow 路线: 墙钟秒数 ≈ 帧数 / 40 / 并行度 + 30% 余量
762
1034
 
763
1035
  ```bash
764
1036
  cd <SKILL_DIR>/scripts
765
- python selfcheck.py # 32 条断言;FAIL 就照提示修环境
1037
+ python selfcheck.py # 51 条断言;FAIL 就照提示修环境
766
1038
  python sfxkit.py --selftest # 音效库(可选模块,装了才跑)
767
1039
  python scorekit.py --selftest # 配乐
768
- python webengine.py --selftest # 浏览器引擎(24 项,会真起无头浏览器,约 15 秒)
1040
+ python webengine.py --selftest # 浏览器引擎(35 项,会真起无头浏览器,约 60 秒)
769
1041
  python qa_frames.py --selftest # 帧级质检(自己造测试视频)
770
1042
  ```
771
1043
 
@@ -775,7 +1047,7 @@ selfcheck 自己造测试信号、现场调 ffmpeg 编码,**不联网、不依
775
1047
  **别把"没装可选件"和"坏了"混为一谈**。
776
1048
 
777
1049
  > **仓库级体检**(v0.4 起)是另一层,别和 selfcheck 搞混:
778
- > `python tools/ci_check.py -v` 只查"这个仓库作为可分发包是否完整"(语法/JSON/依赖白名单/
1050
+ > `python <REPO>/tools/ci_check.py -v`(在包根执行)只查"这个仓库作为可分发包是否完整"(语法/JSON/依赖白名单/
779
1051
  > 打包卫生/文档路径引用),**不需要 ffmpeg、字体或网络**,任何机器都能跑;
780
1052
  > selfcheck 查的是"技能还能不能真的出片"。两层都在 CI 里跑。
781
1053
  >
@@ -24,8 +24,11 @@
24
24
  "key": "auto",
25
25
  "seed": 7,
26
26
  "seed_from_content": true,
27
- "gain_db": -1.5,
28
- "duck_db": 3,
27
+ "gain_db": -12,
28
+ "duck_db": 4,
29
+ "voice_duck_db": 6,
30
+ "voice_duck_sfx_db": 3,
31
+ "guard_snr_db": 12,
29
32
  "seg": 4,
30
33
  "amp": 0.05,
31
34
  "gain": 0.9,
@@ -51,24 +54,14 @@
51
54
  "sfx": {
52
55
  "enabled": true,
53
56
  "gain_db": 0,
57
+ "content_seed": true,
58
+ "_comment": "v0.7 去固定化(references/audio-variation.md §3.5):模板只留 1 条转场 whoosh 作示例,具体提示点建议交给 assets/scenes.py 的 cues() 钩子按画面内容生成(hook=impact+riser;body=ui_blip 按条目文本定音高 + answer_chime + 段尾 whoosh;quote=reverse_swell+impact;outro=answer_chime+sub_drop)。content_seed=true(默认)= 每条 cue 的 seed 由 style/content_fp 派生(不同片子同一位置也不同),显式 false 回到 v0.6 的 seed_base+i 旧行为。",
54
59
  "cues": [
55
60
  {
56
61
  "at": "l02-0.15",
57
62
  "type": "whoosh",
58
63
  "dur": 0.9,
59
64
  "gain": -2
60
- },
61
- {
62
- "at": "l03",
63
- "type": "riser",
64
- "dur": 2,
65
- "gain": -4
66
- },
67
- {
68
- "at": "l03+0.05",
69
- "type": "impact",
70
- "size": 1,
71
- "gain": -3
72
65
  }
73
66
  ]
74
67
  },
@@ -0,0 +1,65 @@
1
+ {
2
+ "version": 1,
3
+ "generator": "golden_frames.py v0.8.0",
4
+ "comment": "金帧基线:字体指纹+环境指纹一致时,每一帧像素 sha256 必须相同;换机/字体/Pillow 版本不同判 skipped(不误报),同环境像素变了才是 fail。改动模板(film.json / scenes.py / stylekit 主题)后请 --write 重录并提交本文件。",
5
+ "canvas": [
6
+ 1920,
7
+ 1080
8
+ ],
9
+ "fps": 30,
10
+ "script": "skills/video-studio/assets/film.json",
11
+ "scenes": "skills/video-studio/assets/scenes.py",
12
+ "font_fp": "05c853d0eb28bcca3125d81c1ce27c9436f66a55",
13
+ "fonts": [
14
+ {
15
+ "name": "msyh.ttc",
16
+ "sha1": "e5a993b85dd99da4930bb8d7b9da53ae89fef3c6"
17
+ },
18
+ {
19
+ "name": "msyhbd.ttc",
20
+ "sha1": "e142fd01e96fea9365b5c6e053a0a1e0d309e218"
21
+ },
22
+ {
23
+ "name": "msyhl.ttc",
24
+ "sha1": "5444a8bab4b57604f6420ea4e60194f41459f9f8"
25
+ }
26
+ ],
27
+ "env_fp": "eb5f0faf631f1f133c7798a5cc77bf8422bf550a",
28
+ "env": {
29
+ "python": "3.12.16",
30
+ "pillow": "13.0.0",
31
+ "numpy": "2.5.4",
32
+ "os": "Windows",
33
+ "platform": "Windows 11"
34
+ },
35
+ "inputs": {
36
+ "script_sha1": "2f375a7298ea861684a2d553cb6617ed4351d3e5",
37
+ "scenes_sha1": "7bd09e095a95a064db60fc1fdca7d54b1466d6aa",
38
+ "filmkit_sha1": "57b2e4a2645bf365148762e260f6c3c20ea22a58",
39
+ "stylekit_sha1": "39adafe99f2b56d23091a7d6c8a863a5cc43b7f4",
40
+ "golden_tool_sha1": "d0e28091f2b9ed3edc5a2a33aad225c859e2fab0"
41
+ },
42
+ "frames": [
43
+ {
44
+ "frame": 36,
45
+ "scene": "l01",
46
+ "t_local": 1.2,
47
+ "label": "hook",
48
+ "sha256": "5552f4ec1d0059b026496ebfb43465339c07a5f679ef835ec71f7e8dff3acc08"
49
+ },
50
+ {
51
+ "frame": 270,
52
+ "scene": "l03",
53
+ "t_local": 1.6,
54
+ "label": "body",
55
+ "sha256": "ffbf24c62b860298a10e9b64c5cc23d8e44fb11decaa02ca6975a94af95917d7"
56
+ },
57
+ {
58
+ "frame": 369,
59
+ "scene": "l04",
60
+ "t_local": 1.5,
61
+ "label": "outro",
62
+ "sha256": "7b20783984d34bc21fde31e50ebd7c6977690b1807a1280a0608ed310eb3e683"
63
+ }
64
+ ]
65
+ }