@dsh-gis/dsh-video-studio-skill 1.0.2 → 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 (51) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +84 -108
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/package.json +1 -1
  5. package/schemas/design.schema.json +5 -5
  6. package/schemas/film.schema.json +31 -31
  7. package/skills/video-studio/SKILL.md +168 -268
  8. package/skills/video-studio/assets/film.json +2 -3
  9. package/skills/video-studio/assets/golden_frames.json +11 -11
  10. package/skills/video-studio/assets/scenes.py +92 -49
  11. package/skills/video-studio/references/{style-packs.md → appearance.md} +17 -15
  12. package/skills/video-studio/references/audio-variation.md +73 -96
  13. package/skills/video-studio/references/audio.md +40 -44
  14. package/skills/video-studio/references/change-impact.md +2 -2
  15. package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +9 -11
  16. package/skills/video-studio/references/frame-qa.md +47 -15
  17. package/skills/video-studio/references/pipeline.md +4 -4
  18. package/skills/video-studio/references/pitfalls.md +22 -3
  19. package/skills/video-studio/references/proposal.md +19 -36
  20. package/skills/video-studio/references/quality-gates.md +11 -11
  21. package/skills/video-studio/references/scene-authoring.md +2 -2
  22. package/skills/video-studio/references/scene-grammar.md +17 -24
  23. package/skills/video-studio/references/sfx-design.md +7 -5
  24. package/skills/video-studio/references/web-engine.md +7 -7
  25. package/skills/video-studio/references/word-timeline.md +7 -7
  26. package/skills/video-studio/requirements.txt +9 -13
  27. package/skills/video-studio/scripts/designkit.py +84 -89
  28. package/skills/video-studio/scripts/filmcheck.py +19 -19
  29. package/skills/video-studio/scripts/filmkit.py +2949 -2948
  30. package/skills/video-studio/scripts/gates.py +4 -4
  31. package/skills/video-studio/scripts/grammar.py +10 -10
  32. package/skills/video-studio/scripts/musicgen.py +3 -4
  33. package/skills/video-studio/scripts/preflight.py +5 -5
  34. package/skills/video-studio/scripts/previewkit.py +11 -12
  35. package/skills/video-studio/scripts/propose.py +11 -11
  36. package/skills/video-studio/scripts/qa_frames.py +34 -35
  37. package/skills/video-studio/scripts/qaasr.py +26 -7
  38. package/skills/video-studio/scripts/qavmaf.py +19 -19
  39. package/skills/video-studio/scripts/render.py +137 -141
  40. package/skills/video-studio/scripts/scorekit.py +21 -23
  41. package/skills/video-studio/scripts/selfcheck.py +208 -135
  42. package/skills/video-studio/scripts/sfxkit.py +27 -26
  43. package/skills/video-studio/scripts/stagewatch.py +4 -4
  44. package/skills/video-studio/scripts/stylekit.py +77 -282
  45. package/skills/video-studio/scripts/voicekit.py +43 -44
  46. package/skills/video-studio/scripts/webengine.py +62 -64
  47. package/tools/audio_similarity.py +12 -12
  48. package/tools/ci_check.py +16 -16
  49. package/tools/estimate.py +24 -25
  50. package/tools/golden_frames.py +17 -18
  51. package/tools/silhouette_qa.py +13 -14
@@ -3,12 +3,12 @@
3
3
 
4
4
  跑法:python selfcheck.py [-v]
5
5
 
6
- 检查的全是"曾经真的坏过"的点,不是泛泛的健康检查:
7
- 1 TTS 缓存必须校验文本(v0.1 只判文件存在 -> 改台词不生效)
8
- 2 sub_window 不能把长句字幕截断在 5.2s(v0.1 的硬钳)
9
- 3 loudness 必须能测到真峰(v0.1 的 ebur128 没开 peak -> 真峰门禁永不触发)
10
- 4 mux 默认不重编视频流(v0.1 二次编码)
11
- 5 FrameSink 尺寸不符必须报错(v0.1 静默 resize)
6
+ 检查的全是真实缺陷的回归点,不是泛泛的健康检查:
7
+ 1 TTS 缓存必须校验文本(只判文件存在 -> 改台词不生效)
8
+ 2 sub_window 不能把长句字幕截断在 5.2s(硬钳会让长句丢字)
9
+ 3 loudness 必须能测到真峰(ebur128 没开 peak -> 真峰门禁永不触发)
10
+ 4 mux 默认不重编视频流(二次编码会再压一遍画面)
11
+ 5 FrameSink 尺寸不符必须报错(静默 resize 会让成片与构图不符)
12
12
  6 字形自查能识别缺字(msyhbd 缺 ✓ 等)
13
13
  7 ASS 头必须声明 PlayRes,且 Format/Dialogue 字段数一致
14
14
  8 时间轴第一段 start 严格为 0
@@ -16,63 +16,62 @@
16
16
  10 qa_report 能查出字幕条数不匹配
17
17
  11 尾静音裁剪
18
18
  12 mux 音轨 48kHz
19
- 13 v0.1 调用签名兼容
19
+ 13 旧调用签名兼容
20
20
  14 qa 帧率校验
21
- 15 (v0.3)立体声总线:channels=2 形状正确、声像能量守恒
22
- 16 (v0.3)cue_time 三种写法都解析成绝对秒
23
- 17 (v0.3)CueBus 落盘 -> 读回 -> schema 正确(含未知类型不崩)
24
- 18 (v0.3)duck 真的压低了配乐(且不改变无音效处的电平)
25
- 19 (v0.3)mix_film 峰值不越界、时长精确、旁白优先
26
- 20 (v0.3)sfxkit 可用时:配方确定性 + 别名表能从 sfxkit 反查
27
- 21 (v0.3)scorekit 可用时:同 seed bit-identical + 时长精确
28
- 22 (v0.3)webengine 可用时:页面契约齐全(window.renderFrame/getCues/ready)
29
- 23 (v0.3)qa_frames 可用时:能检出注入的单帧闪烁
30
- 24 (v0.4.0)配乐来源:music.mode=off 不读上一轮 work/music.wav(模式判定回归)
31
- 25 (v0.5.0)风格层:8 套主题全部过对比度门禁,8 类题材能分出 >=6 种风格
32
- 26 (v1.0.0)legacy 已移除:不写 style 必须**报错并给迁移指引**(v0.5 时代这条断言
33
- 的是"精确落回 Dm/120/seed7/单声线",D3 把那条路径删了,断言随之反转)
34
- 27 (v0.5.0)内容指纹:同剧本稳定、改标点不换风格、换题材才换
35
- 28 (v0.5.0)声线计划:4 句样片 >=2 把声音,legacy 仍是单声线
36
- 29 (v0.5.0)字体族与主题字幕:换族换缓存键,浅色主题不画黑背板
37
- 30 (v0.6.0)门禁账本与签核:pass/fail/skipped 三档不许混,签核必须绑内容指纹
38
- 31 (v0.6.0)阶段新鲜度:改了输入必须报"过时",不许用旧产物静默出片
39
- 32 (v0.6.0)并行渲染主题序列化:JSON 往返后重解风格必须回到 tuple
40
- 33 (v0.7.0)音效跨片可区分:内容派生 seed 让跨片不同、legacy 逐样本不变
41
- 34 (v0.7.0)配乐/音效片内分离:duck 默认加深,配乐给音效让位
42
- 35 (v0.7.1)旁白优先:人声出现时背景必须再压、间隙恢复、人声电平不动
43
- 36 (v0.7.1)旁白优先默认值:配乐 -12 dB 基线 + 旁白侧链 6 dB(读 st_audio 源码)
44
- 37 (v0.7.1)旁白清晰度 SNR 兜底:背景天然太响时自动补压到 >=12 dB
45
- 38 (v0.7.1)无旁白时混音不受影响(voice_env/guard 不得空转改电平)
46
- 39 (v0.7.2)音频边界:极短旁白不崩、NaN/Inf 拒绝写盘、负起点与 seg=0 有守卫
47
- 40 (v0.7.2)QC 报告落点只有一个来源(--out 的目录,不许 accept 另算一套)
48
- 41 (v0.7.2)空门禁账本不许写成"通过"(没记录 != 通过)
49
- 42 (v0.7.2)单阶段产物归属:不许把别的 stage 写的文件算成自己的(假 stale)
50
- 43 (v0.7.2)theme_overrides 必须参与主题体检(否则能造出"看不清"的成片)
51
- 44 (v0.8.0)词级时间戳:规整(单调/裁剪/偏移)与 cue 词锚点解析
52
- 45 (v0.8.0)画幅规划:只允许 16:9 横屏下采样,竖屏/上采样/非法 fps 必须拒绝
53
- 46 (v0.8.0)金帧确定性:同一帧号两次渲染必须逐像素一致
54
- 47 (v0.8.0)依赖漂移体检:numpy/Pillow 主版本 + edge-tts 仍暴露 boundary 参数
55
- 48 (v0.8.0)批量清单解析:两种写法都认、上限卡死、坏条目在开跑前报错
56
- 49 (v0.8.0)TTS 后端可替换:注册/注销/未知后端的接口契约
57
- 50 (v0.8.0)web 风格载荷:style=None 时不得注入 __STYLE__(legacy 必须回落)
58
- 51 (v0.8.0)金帧回归:与 assets/golden_frames.json 的像素基线比对
59
- 52 (v0.9.0)场景语法:10 类构图分流、选型确定性、段长降级门
60
- 53 (v0.9.0)视觉语言包:8 套结构签名齐全、过体检、彼此不是同一套
61
- 54 (v0.9.0)场景类型单真值:grammar / filmcheck / scenes / schema 四处集合相等
62
- 55 (v0.9.0)剪影可区分(G13):8 套风格两两之间构图骨架必须能区分
63
- 56 (v1.0.0)设计 DSL:采样->校验->转计划全通;缺必填块必须报错(fail-closed);
21
+ 15 立体声总线:channels=2 形状正确、声像能量守恒
22
+ 16 cue_time 三种写法都解析成绝对秒
23
+ 17 CueBus 落盘 -> 读回 -> schema 正确(含未知类型不崩)
24
+ 18 duck 真的压低了配乐(且不改变无音效处的电平)
25
+ 19 mix_film 峰值不越界、时长精确、旁白优先
26
+ 20 sfxkit 可用时:配方确定性 + 别名表能从 sfxkit 反查
27
+ 21 scorekit 可用时:同 seed bit-identical + 时长精确
28
+ 22 webengine 可用时:页面契约齐全(window.renderFrame/getCues/ready)
29
+ 23 qa_frames 可用时:能检出注入的单帧闪烁
30
+ 24 配乐来源:music.mode=off 不读上一轮 work/music.wav(模式判定回归)
31
+ 25 风格层:每套采样主题全部过对比度门禁,8 类题材能分出 >=6 种风格
32
+ 26 legacy 已移除:不写 style 必须**报错并给迁移指引**(守的是"旧外观路径真的
33
+ 没了",而不是"精确落回某套老默认值")
34
+ 27 内容指纹:同剧本稳定、改标点不换风格、换题材才换
35
+ 28 声线计划:4 句样片 >=2 把声音,legacy 仍是单声线
36
+ 29 字体族与主题字幕:换族换缓存键,浅色主题不画黑背板
37
+ 30 门禁账本与签核:pass/fail/skipped 三档不许混,签核必须绑内容指纹
38
+ 31 阶段新鲜度:改了输入必须报"过时",不许用旧产物静默出片
39
+ 32 并行渲染主题序列化:JSON 往返后重解风格必须回到 tuple
40
+ 33 音效跨片可区分:内容派生 seed 让跨片不同、legacy 逐样本不变
41
+ 34 配乐/音效片内分离:duck 默认加深,配乐给音效让位
42
+ 35 旁白优先:人声出现时背景必须再压、间隙恢复、人声电平不动
43
+ 36 旁白优先默认值:配乐 -12 dB 基线 + 旁白侧链 6 dB(读 st_audio 源码)
44
+ 37 旁白清晰度 SNR 兜底:背景天然太响时自动补压到 >=12 dB
45
+ 38 无旁白时混音不受影响(voice_env/guard 不得空转改电平)
46
+ 39 音频边界:极短旁白不崩、NaN/Inf 拒绝写盘、负起点与 seg=0 有守卫
47
+ 40 QC 报告落点只有一个来源(--out 的目录,不许 accept 另算一套)
48
+ 41 空门禁账本不许写成"通过"(没记录 != 通过)
49
+ 42 单阶段产物归属:不许把别的 stage 写的文件算成自己的(假 stale)
50
+ 43 theme_overrides 必须参与主题体检(否则能造出"看不清"的成片)
51
+ 44 词级时间戳:规整(单调/裁剪/偏移)与 cue 词锚点解析
52
+ 45 画幅规划:只允许 16:9 横屏下采样,竖屏/上采样/非法 fps 必须拒绝
53
+ 46 金帧确定性:同一帧号两次渲染必须逐像素一致
54
+ 47 依赖漂移体检:numpy/Pillow 主版本 + edge-tts 仍暴露 boundary 参数
55
+ 48 批量清单解析:两种写法都认、上限卡死、坏条目在开跑前报错
56
+ 49 TTS 后端可替换:注册/注销/未知后端的接口契约
57
+ 50 web 风格载荷:style=None 时不得注入 __STYLE__(legacy 必须回落)
58
+ 51 金帧回归:与 assets/golden_frames.json 的像素基线比对
59
+ 52 场景语法:10 类构图分流、选型确定性、段长降级门
60
+ 53 视觉语言包:8 套结构签名齐全、过体检、彼此不是同一套
61
+ 54 场景类型单真值:grammar / filmcheck / scenes / schema 四处集合相等
62
+ 55 剪影可区分(G13):8 套风格两两之间构图骨架必须能区分
63
+ 56 设计 DSL:采样->校验->转计划全通;缺必填块必须报错(fail-closed);
64
64
  同 brief 两次采样一致;两两至少 2 维不同("伪多方案"门禁)
65
- 57 (v1.0.1)零模板:仓库里不许存在 presets/ 与 presetkit.py;现场采样必须确定性、
65
+ 57 零模板:仓库里不许存在 presets/ 与 presetkit.py;现场采样必须确定性、
66
66
  且换 brief 必须换结果(否则就是还在套模板)
67
- 58 (v1.0.0)提案闸:absent / draft / broken 三档判定正确(confirmed 才放行出片)
68
- 59 (v1.0.1)逐 stage 可单独跑:render 里每处 resolve_style 都必须拿得到 work
67
+ 58 提案闸:absent / draft / broken 三档判定正确(confirmed 才放行出片)
68
+ 59 逐 stage 可单独跑:render 里每处 resolve_style 都必须拿得到 work
69
69
  (只跑 --stage frames / --stage sfx 时缺参数会报"找不到设计",all 被缓存掩盖)
70
- 60 (v1.0.1)brief 里的要求真的生效:自然语言要解析成 must_have/avoid,
70
+ 60 brief 里的要求真的生效:自然语言要解析成 must_have/avoid,
71
71
  配色约束按色相窗口生效(早先整句被塞进 topic,"深蓝"采出来是橙色)
72
72
 
73
73
  说明:本文件里 CASES 的**实际条数**是唯一真理,上面的编号表要与它同步。
74
- 这张表已经被"自己没被自检"坑过两次:v0.8.0 之前停在 43 条而 CASES 已有 51 条;
75
- v1.0.0 之前停在 51 条而 CASES 已有 58 条(52–58 一条都没列)。
74
+ 这张表落后过两次:停在 43 条而 CASES 已有 51 条、停在 51 条而 CASES 已有 58 条。
76
75
  改 CASES 时请同批改这张表,别让它再次落后。
77
76
  """
78
77
  import argparse
@@ -132,7 +131,7 @@ def run(name, fn):
132
131
 
133
132
 
134
133
  # 自检专用:造一份 design 计划。
135
- # v1.0.1 删掉内置风格表后,"要一份 plan"这件事只能靠现场采样 + 转计划;
134
+ # 删掉内置风格表后,"要一份 plan"这件事只能靠现场采样 + 转计划;
136
135
  # 每个用例各写一遍既啰嗦又容易漂移,于是收到这里。
137
136
  # 固定 seed 保证自检自身可复现(自检必须可复现,否则 CI 抖起来没法查)。
138
137
  _PLAN_CACHE = {}
@@ -339,7 +338,7 @@ def t_mux_48k():
339
338
 
340
339
 
341
340
  def t_compat():
342
- """v0.1 的调用签名必须还能用(升级不能把老项目直接炸掉)。"""
341
+ """旧调用签名必须还能用(升级不能把老项目直接炸掉)。"""
343
342
  a = K.tone(440, 0.2, kind="sine", harmonics=(1.0, 0.5))
344
343
  b = K.noise_burst(0.2, amp=0.1, lo=0.02, hi=0.35, decay=6.0, seed=7)
345
344
  assert len(a) > 0 and len(b) > 0, "兼容签名返回空"
@@ -351,7 +350,7 @@ def t_compat():
351
350
 
352
351
 
353
352
  def t_fps_check():
354
- """qa_report 必须能查出帧率不符(v0.2 之前只查宽高)。"""
353
+ """qa_report 必须能查出帧率不符(只查宽高会漏掉帧率写错)。"""
355
354
  with tempfile.TemporaryDirectory() as d:
356
355
  v = os.path.join(d, "v25.mp4")
357
356
  r = subprocess.run([K.FFMPEG, "-y", "-v", "error", "-f", "lavfi",
@@ -364,7 +363,7 @@ def t_fps_check():
364
363
  return "25fps 被检出"
365
364
 
366
365
 
367
- # ---------------------------------------------------------------- v0.3.0 新增
366
+ # ---------------------------------------------------------------- 立体声 / 配乐 / 音效
368
367
  def t_stereo_bus():
369
368
  """立体声总线:形状、声像、单声道来源自动展开。"""
370
369
  import numpy as np
@@ -453,9 +452,9 @@ def t_mix_film():
453
452
 
454
453
 
455
454
  def t_music_plan():
456
- """配乐来源判定:music.mode=off 不得读上一轮的 work/music.wav(v0.3.0 的回归)。
455
+ """配乐来源判定:music.mode=off 不得读上一轮的 work/music.wav。
457
456
 
458
- 背景:v0.3.0 的 st_audio 无条件读 work/music.wav,于是用户把 music.mode 改成 off
457
+ 背景:st_audio 若无条件读 work/music.wav,用户把 music.mode 改成 off
459
458
  之后,上一轮跑出来的旧配乐仍被混进成片(文档写的是 off = 不要配乐)。
460
459
  这里既测判定函数,也真的跑一次 st_audio 看输出电平。
461
460
  """
@@ -470,7 +469,7 @@ def t_music_plan():
470
469
  assert K.music_plan({"music": {"mode": "weird"}})[0] == "unknown-mode", "未知 mode 没报错(会静默当 auto)"
471
470
  assert K.music_plan({}, True)[0] == "file", "缺省时外部 music.wav 兼容链断了"
472
471
  assert K.music_plan({}, False)[0] == "none", "缺省且什么都没有时应判 none"
473
- assert K.music_plan({"music": {"chords": [[220.0, 261.6]]}}, False)[0] == "bed", "v0.2 和弦床兼容链断了"
472
+ assert K.music_plan({"music": {"chords": [[220.0, 261.6]]}}, False)[0] == "bed", "和弦床兼容链断了"
474
473
 
475
474
  # 2) 端到端:st_audio 在 mode=off 下不得混入 work/music.wav
476
475
  try:
@@ -505,7 +504,7 @@ def t_music_plan():
505
504
  file_peak = float(np.max(np.abs(K.wav_read(file_out)[0])))
506
505
  assert "music.mode=off" in buf.getvalue(), "off 没有打印说明(静默跳过也不行)"
507
506
  assert off_peak < 0.01, "music.mode=off 仍把旧 music.wav 混进来了(峰值 %.3f)" % off_peak
508
- # v0.7.1:断言改成"相对 off 明显变大"而不是绝对 0.3——
507
+ # 断言用"相对 off 明显变大"而不是绝对 0.3——
509
508
  # 配乐基线降到 -12 dB(旁白优先)后,0.8 幅度的外部配乐混出来约 0.2;
510
509
  # 绝对阈值会把"旁白优先调好了"判成"没读到外部配乐",这类断言会被调参牵着走。
511
510
  assert file_peak > 0.1 and file_peak > off_peak * 3.0, (
@@ -600,7 +599,7 @@ def t_qa_frames():
600
599
 
601
600
 
602
601
  def t_style_kits():
603
- """25 连续配色空间(v1.0.1 替代"8 套主题"):现场采样的每一套都必须过对比度门禁,
602
+ """25 连续配色空间:现场采样的每一套都必须过对比度门禁,
604
603
  且色相真的分散——这条是"没有模板"的硬证据。
605
604
 
606
605
  旧断言是"8 套内置主题全部过 lint + 自动选风格能分出 >=6 种"。模板删掉后那个断言
@@ -639,12 +638,12 @@ def t_style_kits():
639
638
 
640
639
 
641
640
  def t_style_legacy():
642
- """26 legacy 已移除(v1.0 / D3):不写 style 必须报错并给出迁移指引,而不是静默落老外观。
641
+ """26 legacy 已移除:不写 style 必须报错并给出迁移指引,而不是静默落老外观。
643
642
 
644
- 这条以前盯的是"老项目无痛升级";D3 之后那句话不再成立——legacy 路径整个删掉了,
643
+ 这条盯的是"不写外观配置照样能出片"这条老思路真的没了:legacy 路径整个删掉,
645
644
  因为它是同质化的另一半根因(不写 style = 全片一套固定外观 + 120BPM/Dm)。
646
- 新断言分成两半:① 不给设计时必须**报错**且错误文本指得出下一步;
647
- ② 显式给 style 时旧路径仍可用(P3 只停了 auto 与 legacy,没删显式选风格)。
645
+ 断言分成两半:① 不给设计时必须**报错**且错误文本指得出下一步;
646
+ ② 显式给 style 也不可用(内置风格表已删,任何 style id 都必须落空报错)。
648
647
  """
649
648
  import stylekit as ST
650
649
  s = {"title": "旧项目", "lines": [{"id": "a", "kind": "body", "text": "x"}]}
@@ -657,8 +656,7 @@ def t_style_legacy():
657
656
  assert "design" in msg.lower(), "报错要说清主路径是 design.json,实际:%r" % msg[:120]
658
657
  else:
659
658
  raise AssertionError("没有 style/design 却没报错:legacy 路径没有真的移除")
660
- # ② 显式 style 也不再可用 —— v1.0.1 把内置风格表整个删了。
661
- # 和 v1.0 不同:那时"显式选风格"是兼容面;现在连 STYLES 都没了,
659
+ # ② 显式 style 也不可用——内置风格表整个删了,连带 STYLES 一起没了,
662
660
  # 任何 style id 都必须落空并报错(否则就是"模板还在")。
663
661
  s3 = dict(s, style="cinema")
664
662
  try:
@@ -676,7 +674,7 @@ def t_style_legacy():
676
674
  def t_style_determinism():
677
675
  """27 指纹确定性:同剧本同结果;改错别字不换风格;改题材才换。"""
678
676
  import designkit as DK
679
- # v1.0.1:没有"剧本 -> 自动选风格"了,改为验证"设计采样"的确定性与区分度。
677
+ # 没有"剧本 -> 自动选风格"了,这里验证"设计采样"的确定性与区分度。
680
678
  # 固定 seed 才谈确定性:默认不给 seed 时每次都要不同(那是 56/57 条在守的事)。
681
679
  a = DK.sample_designs({"topic": "AI 大模型的三年"}, n=1, seed=11)[0]
682
680
  b = DK.sample_designs({"topic": "AI 大模型的三年"}, n=1, seed=11)[0]
@@ -698,14 +696,14 @@ def t_voice_plan():
698
696
  {"id": "l02", "kind": "body", "text": "算法和数据的规模一起上去了"},
699
697
  {"id": "l03", "kind": "quote", "text": "他说:这不可能"},
700
698
  {"id": "l04", "kind": "outro", "text": "它已经在我们身边了"}]}
701
- # v1.0.1:声线计划同样只能从设计来(模板表已删)
699
+ # 声线计划同样只能从设计来(模板表已删)
702
700
  plan = DK.design_to_plan(DK.sample_designs({"topic": "AI 大模型"}, n=1, seed=7)[0])
703
701
  vp = VK.voice_plan(s, plan)
704
702
  assert len(vp) == 4, "计划行数应为 4,收到 %d" % len(vp)
705
703
  assert json.dumps(vp, sort_keys=True, default=str) == json.dumps(
706
704
  VK.voice_plan(s, plan), sort_keys=True, default=str), "voice_plan 不确定"
707
- # v1.0 / D3:persona 身份表已删,声音身份的唯一表示是 edge voice id。
708
- # 断言从"至少 2 个 persona"改成"至少 2 个不同的 voice id"——语义不变,字段换了。
705
+ # persona 身份表已删,声音身份的唯一表示是 edge voice id。
706
+ # 这里断言"至少 2 个不同的 voice id"——语义与旧 persona 表一致,字段换了。
709
707
  voices = {r["voice"] for r in vp}
710
708
  assert len(voices) >= 2, "4 句样片只用了一个声线: %s" % voices
711
709
  tones = {r["tone"] for r in vp}
@@ -727,13 +725,13 @@ def t_font_family():
727
725
  """29 字体族 + 字幕主题:换族必须换缓存键,主题字幕不能是黑底白字。
728
726
 
729
727
  **别把"系统装了几族字体"当成产品缺陷**:CI 的 ubuntu 只装 fonts-noto-cjk,
730
- 四个族全回退到同一个 NotoSansCJK,宽度必然一样。v0.5.0 的第一版就在这里误报——
731
- 断言写成"宽度必须不同",于是 Linux CI 红、Windows 绿。
728
+ 四个族全回退到同一个 NotoSansCJK,宽度必然一样。断言若写成"宽度必须不同"就会误报——
729
+ Linux CI 红、Windows 绿。
732
730
  真正要保证的是**机制**:缓存按 family 分键(换族不会串味);
733
731
  宽度差异只在"系统确实解析出不同字体文件"时才有意义,单族系统跳过并说明。
734
732
  """
735
733
  fams = list(getattr(K, "FAMILY_IDS", ()))
736
- assert fams, "filmkit 没有 FONT_FAMILIES(v0.5 字体族没生效)"
734
+ assert fams, "filmkit 没有 FONT_FAMILIES(字体族没生效)"
737
735
  resolved = {}
738
736
  for fam in fams:
739
737
  f = K.font(40, "bold", fam)
@@ -767,7 +765,7 @@ def t_font_family():
767
765
  assert K.wrap_cn_cached("旧调用", 40, 500) is not None
768
766
  # 字幕主题:浅色主题不该画黑背板
769
767
  from PIL import Image
770
- # v1.0.1:没有内置主题可借(THEMES 已清空),改用手写一份浅色主题。
768
+ # 没有内置主题可借(THEMES 已清空),手写一份浅色主题。
771
769
  # 这条断言测的是 draw_subtitle 的"浅底不画黑背板"逻辑,与主题从哪来无关。
772
770
  th = {"bg": (247, 244, 238), "bg2": (233, 228, 218), "surface": (255, 255, 255),
773
771
  "ink": (26, 24, 22), "muted": (110, 104, 96), "dim": (150, 146, 140),
@@ -787,7 +785,7 @@ def t_font_family():
787
785
  def t_gates():
788
786
  """30 门禁账本 / 签核 / QC 报告:三档状态必须能区分"没查"和"通过"。
789
787
 
790
- 这一条防的是 v0.6.0 自己最容易犯的错——把 skipped 混进 pass。
788
+ 这一条防的是最容易犯的错——把 skipped 混进 pass。
791
789
  用户看到的"质检通过"必须能区分"我查了 9 项"和"我只查了 3 项":
792
790
  前者是真知情,后者是假安全感。顺带验签核绑内容指纹:改了剧本旧签核必须失效。
793
791
  """
@@ -835,7 +833,7 @@ def t_gates():
835
833
  def t_stagewatch():
836
834
  """31 阶段新鲜度:改上游 -> 下游必须报过时;首跑不刷噪声;产物丢失要报。
837
835
 
838
- 这一条防的是 v0.6.0 前的老问题:frames 有分块指纹,其余 stage 全是裸奔
836
+ 这一条防的是:frames 有分块指纹,其余 stage 全是裸奔
839
837
  (改 style 后 audio 仍读旧 music.wav、改 hold 后 frames 仍用旧 cues.json,都不报错)。
840
838
  """
841
839
  import tempfile
@@ -871,7 +869,7 @@ def t_stagewatch():
871
869
  errs_strict, _ = SW.report(w, ["tts"], strict=True, write=lambda lv, m: None)
872
870
  assert errs_strict, "--strict-stale 没有让过时变成失败"
873
871
  # **源头输入**(剧本 / scenes.py)改了但没重跑 -> 必须报 source-changed。
874
- # 这条是 v0.6.0 补的洞:早期只跟踪 stage 之间的产物,"改了台词没重跑 tts" 查不出来,
872
+ # 这条守的是:只跟踪 stage 之间的产物时,"改了台词没重跑 tts" 查不出来,
875
873
  # 而那正是最需要报警的场景(旧 tts 配新剧本 = 配音字幕全错位)。
876
874
  w2 = tempfile.mkdtemp(prefix="sc_sw2_")
877
875
  film = os.path.join(w2, "film.json")
@@ -887,7 +885,7 @@ def t_stagewatch():
887
885
  open(film, "w", encoding="utf-8").write("{\"title\":\"b\"}") # 改剧本
888
886
  issues = [i for r in SW.explain(w2, ["tts"], src) for i in r["issues"]]
889
887
  assert any(i["kind"] == "source-changed" for i in issues), (
890
- "改了剧本却没报过时(这正是 v0.6.0 要堵的洞):%s" % issues)
888
+ "改了剧本却没报过时(这正是阶段新鲜度要堵的洞):%s" % issues)
891
889
  assert any("film.json" in i["reason"] for i in issues), issues
892
890
  # 坏 manifest 不许崩(用户手改坏一个 json 不该让整片跑不动)
893
891
  open(os.path.join(w, "manifest.json"), "w", encoding="utf-8").write("{broken")
@@ -896,7 +894,7 @@ def t_stagewatch():
896
894
 
897
895
 
898
896
  def t_parallel_frames():
899
- """32 --jobs >1 的并行路径能真的跑(v0.6.0 修的 pre-existing bug)。
897
+ """32 --jobs >1 的并行路径能真的跑(pre-existing bug 的回归)。
900
898
 
901
899
  bug 形状:worker 从 _script_snapshot.json 读剧本,JSON 把主题色元组变成 list,
902
900
  scenes._bg() 拿它当缓存键 -> TypeError: unhashable type: "list",
@@ -906,7 +904,7 @@ def t_parallel_frames():
906
904
  """
907
905
  import json as _json
908
906
  import tempfile
909
- # v1.0.1:不再有内置主题可借,用现场设计的主题(颜色同样是 (r,g,b) 元组)
907
+ # 没有内置主题可借,用现场设计的主题(颜色同样是 (r,g,b) 元组)
910
908
  _d, _plan0 = _design_plan("并行渲染主题序列化")
911
909
  th = dict(_plan0["theme"])
912
910
  # ① 防御层:scenes._hashable 把 list 冻结成 tuple
@@ -926,7 +924,7 @@ def t_parallel_frames():
926
924
  _json.dump(script, fh, ensure_ascii=False)
927
925
  back = _json.load(open(snap, encoding="utf-8"))
928
926
  assert isinstance(back["_style"]["theme"]["bg"], list), "前提变了"
929
- # 子进程路径(v1.0.1 修:worker 必须从 work/design.json 重解设计,而不是回落 stylekit)。
927
+ # 子进程路径(worker 必须从 work/design.json 重解设计,而不是回落 stylekit)。
930
928
  # 现在直接验那条路径:把 design.json 放进 work/,resolve_style 必须解出**同一个**设计,
931
929
  # 且颜色回到 tuple(list 会让 _bg() 的缓存键不可哈希)。
932
930
  import designkit as DK
@@ -961,9 +959,9 @@ def _load_scenes_mod():
961
959
  return None
962
960
 
963
961
  def t_sfx_content_seed():
964
- """v0.7:音效 seed 必须随内容变化,但 legacy 路径必须逐样本不变。
962
+ """音效 seed 必须随内容变化,但 legacy 路径必须逐样本不变。
965
963
 
966
- - 不传 style/content_fp -> 两次渲染逐样本相同(v0.4 兼容,不破老片);
964
+ - 不传 style/content_fp -> 两次渲染逐样本相同(legacy 兼容,不破老片);
967
965
  - 传了不同 content_fp -> 必须不同(否则跨片音效还是"都一样");
968
966
  - 同一 content_fp -> 逐样本相同(可复现,是程序化视频的底线)。
969
967
  """
@@ -981,7 +979,7 @@ def t_sfx_content_seed():
981
979
  legacy_a, legacy_b = build(), build()
982
980
  except Exception: # noqa: BLE001
983
981
  return "skip" # sfxkit 不可用
984
- assert np.array_equal(legacy_a, legacy_b), "legacy 两次渲染不一致(v0.4 兼容被破坏)"
982
+ assert np.array_equal(legacy_a, legacy_b), "legacy 两次渲染不一致(逐样本兼容被破坏)"
985
983
  a = build(style="tech-deep", content_fp="aaaa1111", key="Dm")
986
984
  b = build(style="tech-deep", content_fp="bbbb2222", key="Dm")
987
985
  assert not np.array_equal(a, b), "不同内容指纹得到相同音效轨:跨片音效仍一模一样"
@@ -991,10 +989,10 @@ def t_sfx_content_seed():
991
989
 
992
990
 
993
991
  def t_audio_separation():
994
- """v0.7:混音侧 duck 默认必须加深(片内配乐给音效让位)。
992
+ """混音侧 duck 默认必须加深(片内配乐给音效让位)。
995
993
 
996
994
  直接读 render.py 的常量文本而不是跑全链:这条断言盯的是"默认值没被改回去"
997
- (v0.4 的 3.0 dB 在段落过门时配乐几乎和音效等高,是"听起来像同一套"的成因之一)。
995
+ (3.0 dB 这类浅值在段落过门时配乐几乎和音效等高,是"听起来像同一套"的成因之一)。
998
996
  """
999
997
  import re
1000
998
  path = os.path.join(HERE, "render.py")
@@ -1002,7 +1000,7 @@ def t_audio_separation():
1002
1000
  m = re.search(r"duck_default\s*=\s*([0-9.]+)", src)
1003
1001
  assert m, "render.py 里找不到 duck_default"
1004
1002
  base = float(m.group(1))
1005
- assert base >= 3.5, "duck_default=%.1f dB 太浅(v0.7 要求 >= 3.5)" % base
1003
+ assert base >= 3.5, "duck_default=%.1f dB 太浅(要求 >= 3.5)" % base
1006
1004
  for sid, want in (("mono", 3.0), ("cinema", 5.5), ("epic", 5.5)):
1007
1005
  mm = re.search(r'"%s"\s*:\s*([0-9.]+)' % sid, src)
1008
1006
  assert mm, "render.py 里找不到风格 %s 的 duck 值" % sid
@@ -1011,7 +1009,7 @@ def t_audio_separation():
1011
1009
 
1012
1010
 
1013
1011
  def t_voice_priority():
1014
- """v0.7.1 旁白优先:人声出现时背景必须再压,人声间隙背景恢复。
1012
+ """旁白优先:人声出现时背景必须再压,人声间隙背景恢复。
1015
1013
 
1016
1014
  以前配乐基线只比旁白低 1.5 dB,视频以旁白为主时观众要费力分辨字。
1017
1015
  修法:`mix_film` 用旁白包络做侧链——人声处配乐 -6 dB、音效 -3 dB,间隙回原电平。
@@ -1049,7 +1047,7 @@ def t_voice_priority():
1049
1047
 
1050
1048
 
1051
1049
  def t_music_gain_default():
1052
- """v0.7.1:配乐默认增益必须是 -12 dB(beside 人声),不是旧版的 -1.5。
1050
+ """配乐默认增益必须是 -12 dB(beside 人声),不是 -1.5。
1053
1051
 
1054
1052
  这条盯的是 `render.st_audio` 的默认值:`mus.get("gain_db", -12.0)`。
1055
1053
  直接读源码文本,避免"跑全链才知道默认值"。
@@ -1061,7 +1059,7 @@ def t_music_gain_default():
1061
1059
 
1062
1060
 
1063
1061
  def t_snr_guard():
1064
- """v0.7.1 SNR 兜底:背景天然太响时,混音后必须自己把它压到"人声听得清"。
1062
+ """SNR 兜底:背景天然太响时,混音后必须自己把它压到"人声听得清"。
1065
1063
 
1066
1064
  构造一段人声 + 一段刻意很响的配乐(比人声高 10 dB),不设侧链、只靠 guard:
1067
1065
  `mix_film(guard_snr_db=12)` 后实测人声活跃区的 人声/背景 必须 >= 12 dB,
@@ -1098,11 +1096,11 @@ def t_mix_no_voice():
1098
1096
  assert abs(float(np.max(np.abs(out))) - 0.2) < 1e-5, "无旁白时配乐被改动了(峰值 %.3f)" % float(np.max(np.abs(out)))
1099
1097
  return "无旁白时背景不被压"
1100
1098
 
1101
- # ------------------------------------------------------------------ v0.7.2 新增(全项目深审)
1099
+ # ------------------------------------------------------------------ 音频入口边界
1102
1100
  def t_audio_edge_inputs():
1103
1101
  """39 音频入口的边界:极短旁白不能崩、NaN/Inf 必须拒绝、seg=0 不许死循环。
1104
1102
 
1105
- 这几条都是"真跑一次就会遇到"的输入。v0.7.1 及以前的行为:
1103
+ 这几条都是"真跑一次就会遇到"的输入。没有守卫时的行为:
1106
1104
  * voice_env 在极短音频上 np.convolve(mode="same") 返回核长 -> np.maximum 广播失败;
1107
1105
  * duck_voice 在"旁白比背景短"时包络与背景长度不同 -> 广播失败;
1108
1106
  * wav_write 对 NaN 做 clip 再 astype(int16),把 NaN 静默写成 0(写出损坏 WAV 却不报错);
@@ -1143,7 +1141,7 @@ def t_audio_edge_inputs():
1143
1141
  def t_report_single_source():
1144
1142
  """40 QC 报告落点只能有一个来源:--out 的目录。
1145
1143
 
1146
- v0.7.2 修的真 bug:all/mux/qa 用 dirname(abspath(out)),accept 用
1144
+ 真 bug:all/mux/qa 用 dirname(abspath(out)),accept 用
1147
1145
  dirname(abspath(work))/out。--out 指到 deliver/ 时,签核更新的是另一份没人
1148
1146
  交付的文件,交付出去的那份仍是"未签核"——正好打穿"报告是交付凭据"这条承诺。
1149
1147
  """
@@ -1185,7 +1183,7 @@ def t_empty_ledger_not_pass():
1185
1183
  def t_stage_io_no_phantom_output():
1186
1184
  """42 单阶段连跑不许报"自己的产物不见了"(假 stale)。
1187
1185
 
1188
- v0.7.2 修:stagewatch 的 STAGE_IO[style].outputs 里把 voice_plan.json 也算成
1186
+ stagewatch 的 STAGE_IO[style].outputs 里把 voice_plan.json 也算成
1189
1187
  style 的产物,而它其实是 tts 阶段写的(render.py 的 st_tts)。于是 --stage style
1190
1188
  连跑两次,第二次必然报"产物 voice_plan.json 不见了";打开 --strict-stale 就变成
1191
1189
  硬失败——SKILL.md 第 3 步(全新项目第一条生产命令)自己走不通。
@@ -1209,13 +1207,13 @@ def t_stage_io_no_phantom_output():
1209
1207
  def t_theme_overrides_linted():
1210
1208
  """43 theme_overrides 必须参与主题体检(否则用户能造出"看不清"的成片)。
1211
1209
 
1212
- v0.7.2 修的真 bug:render.resolve_style 以前只体检**基础主题**
1210
+ 真 bug:render.resolve_style 以前只体检**基础主题**
1213
1211
  (ST.THEMES[theme_id]),而 scenes 实际用的是 plan[theme](基础主题 + film.json 的
1214
1212
  visual.theme_overrides)。于是把 ink/bg 调成低对比度时体检照样 PASS,渲完才发现字幕看不清。
1215
1213
  实测 overrides ink=[30,30,30]/bg=[10,10,10]:基础主题 lint 为空,最终主题 lint 报对比度 1.09。
1216
1214
  """
1217
1215
  import stylekit as ST
1218
- # v1.0.1:没有"基础主题 vs 最终主题"的区分了(内置主题表已删),
1216
+ # 没有"基础主题 vs 最终主题"的区分了(内置主题表已删),
1219
1217
  # 但"overrides 之后必须重新 lint"这条规则仍然要守:
1220
1218
  # 直接把设计主题改成低对比度,theme_lint 必须抓到。
1221
1219
  _d, _plan43 = _design_plan("theme_overrides 体检")
@@ -1225,7 +1223,7 @@ def t_theme_overrides_linted():
1225
1223
  dirty = ST.apply_theme_overrides(base_th, {"ink": [30, 30, 30], "bg": [10, 10, 10]})
1226
1224
  bad = ST.theme_lint(dirty)
1227
1225
  assert bad, '低对比度的 theme_overrides 没被 lint 抓到(用户能造出看不清的字幕)'
1228
- # v1.0.1:体检的位置从 render 挪到了 designkit(design_to_plan 里就 lint,不过直接抛)。
1226
+ # 体检的位置在 designkit(design_to_plan 里就 lint,不过直接抛)。
1229
1227
  # 这里改查**真正干活的地方**——早先查 render.py 的源码文本,那只是"哪个文件里写了这行",
1230
1228
  # 一旦重构搬家就会误报(本次就是这样:lint 逻辑搬进 designkit 后断言还在查 render)。
1231
1229
  # 更硬的检查:直接把一份低对比度主题喂给 design_to_plan,必须抛。
@@ -1244,7 +1242,7 @@ def t_theme_overrides_linted():
1244
1242
  return "最终主题参与体检(%s)" % ";".join(bad)[:60]
1245
1243
 
1246
1244
 
1247
- # ------------------------------------------------------------------ v0.8.0 用例
1245
+ # ------------------------------------------------------------------ 词级时间戳 / 画幅 / 依赖 / 批量
1248
1246
  def t_word_events():
1249
1247
  """44 词级时间戳:规整/裁剪平移/单调兜底/词锚点解析(不联网)。
1250
1248
 
@@ -1319,7 +1317,7 @@ def t_golden_determinism():
1319
1317
  import hashlib
1320
1318
  sc = dict(id="l01", kind="hook", start=0.0, dur=4.0, chapter="01 · 测试",
1321
1319
  text="金帧确定性自检", sub="金帧确定性", big="金帧确定性")
1322
- # v1.0.1:剧本必须显式给设计(内置风格表已删,没有"style": "mono" 这种写法了)。
1320
+ # 剧本必须显式给设计(内置风格表已删,没有"style": "mono" 这种写法了)。
1323
1321
  script = {"title": "selfcheck", "_total": 8.0}
1324
1322
  script["_style"] = _design_plan("金帧确定性")[1]
1325
1323
  hs = []
@@ -1357,7 +1355,7 @@ def t_dependency_drift():
1357
1355
  np.__version__, pil_ver, getattr(edge_tts, "__version__", "?"))
1358
1356
 
1359
1357
 
1360
- # ------------------------------------------------------------------ v0.8.0 用例(续)
1358
+ # ------------------------------------------------------------------ web 载荷 / 金帧
1361
1359
  def t_batch_manifest():
1362
1360
  """48 批量清单解析:两种写法都认、上限卡死、坏条目在**开跑前**报错。
1363
1361
 
@@ -1431,15 +1429,15 @@ def t_tts_backend():
1431
1429
  def t_web_style_payload():
1432
1430
  """50 web 引擎风格载荷:非 legacy 才注入、legacy 必须回落 None(逐字节兼容的前提)。
1433
1431
 
1434
- v0.8 给 web 引擎加了"跟随风格包",但兼容性要求是 style=None 时页面输出**逐字节**
1435
- 等于 v0.7。这条守住"什么时候该注入、什么时候必须不注入"这个开关。
1432
+ web 引擎会"跟随风格包",但兼容性要求是 style=None 时页面输出**逐字节**
1433
+ 等于不带载荷的旧输出。这条守住"什么时候该注入、什么时候必须不注入"这个开关。
1436
1434
  """
1437
1435
  import render as R
1438
1436
  try:
1439
1437
  import webengine as we
1440
1438
  except Exception as e: # noqa: BLE001
1441
1439
  return "skip(没有 webengine.py:%s)" % e
1442
- # v1.0.1:没有 legacy 概念了(legacy 入口已被拒绝),所以这里只验"设计项目必须注入载荷"。
1440
+ # 没有 legacy 概念了(legacy 入口已被拒绝),所以这里只验"设计项目必须注入载荷"。
1443
1441
  script = {"title": "x", "lines": [{"id": "l01", "kind": "hook", "text": "y"}]}
1444
1442
  script["_style"] = _design_plan("web 载荷")[1]
1445
1443
  tl = {"fps": 30, "video": {"out_w": 1280, "out_h": 720}}
@@ -1502,7 +1500,7 @@ def t_golden_frames():
1502
1500
 
1503
1501
 
1504
1502
  # ============================================================================
1505
- # v0.9:场景语法 + 视觉语言
1503
+ # 场景语法 + 视觉语言
1506
1504
  # ============================================================================
1507
1505
 
1508
1506
  def _load_grammar():
@@ -1534,7 +1532,7 @@ def _fake_scenes(G, lines, per=6.0):
1534
1532
  def t_scene_grammar():
1535
1533
  """52 场景语法:10 类都能被选中、选型确定、段长门会降级。
1536
1534
 
1537
- 为什么要有这条:v0.9 之前 quote/emphasis/calm 三种 kind 全部落到 body 绘制器,
1535
+ 为什么要有这条:quote/emphasis/calm 三种 kind 全都落到 body 绘制器的话,
1538
1536
  画面永远只有一套骨架。这条断言盯的是"内容 -> 构图"这条链路真的在分流,
1539
1537
  而不是"代码里写了 10 个函数但没人调用"。
1540
1538
  """
@@ -1590,13 +1588,13 @@ def t_scene_grammar():
1590
1588
  def t_visual_languages():
1591
1589
  """53 视觉语言包:8 套结构签名齐全、过体检、且彼此不是同一套。
1592
1590
 
1593
- 为什么要有这条:v0.5 的"风格"只改配色,8 套风格骨架完全一样(用户抱怨的根因)。
1591
+ 为什么要有这条:只改配色的"风格"会让 8 套风格骨架完全一样(用户抱怨的根因)。
1594
1592
  这条盯的是"结构签名真的存在且互不相同"——母题/转场/重心偏移任一维度相同不算问题,
1595
1593
  但**五组签名全同**就说明这套语言只是复制粘贴。
1596
1594
  """
1597
1595
  import designkit as DK
1598
1596
  import stylekit as ST
1599
- # v1.0.1:没有 8 套内置语言了。改成验"现场生成的语言",而且断言更强:
1597
+ # 没有 8 套内置语言了。验"现场生成的语言",而且断言更强:
1600
1598
  # 12 个设计的两两结构签名必须互不相同(以前只要求 8 套里 >=6 种母题)。
1601
1599
  sigs, motifs, transes = {}, set(), set()
1602
1600
  designs = DK.sample_designs({"topic": "视觉语言多样性"}, n=12, seed=20260202)
@@ -1664,7 +1662,7 @@ def t_scene_types_single_truth():
1664
1662
  def t_silhouette_gate():
1665
1663
  """55 剪影可区分(G13):8 套风格两两之间构图骨架必须能区分。
1666
1664
 
1667
- 这是 v0.9 的核心验收:如果这条挂了,说明"换风格只是换色"的毛病回来了。
1665
+ 这是核心验收:如果这条挂了,说明"换风格只是换色"的毛病回来了。
1668
1666
  工具在 tools/silhouette_qa.py(可选):缺了 skip,不阻塞 CI。
1669
1667
  """
1670
1668
  root = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
@@ -1731,7 +1729,7 @@ def t_design_dsl():
1731
1729
  broken = json.loads(json.dumps(ds[0]))
1732
1730
  broken["visual"].pop("motif", None)
1733
1731
  assert DK.validate_design(broken), "缺 motif 竟然通过校验(fail-closed 失效)"
1734
- # v1.0.1 起语义反转:**不给 seed = 每次都不一样**(需求是"每条视频都不同"),
1732
+ # 语义是**不给 seed = 每次都不一样**(需求是"每条视频都不同"),
1735
1733
  # 给 seed 才可复现。两条都要测,否则改错了任一边都发现不了。
1736
1734
  x1 = [d["id"] for d in DK.sample_designs({"topic": "selfcheck 设计 DSL"}, n=3)]
1737
1735
  x2 = [d["id"] for d in DK.sample_designs({"topic": "selfcheck 设计 DSL"}, n=3)]
@@ -1743,9 +1741,9 @@ def t_design_dsl():
1743
1741
 
1744
1742
 
1745
1743
  def t_no_templates():
1746
- """57 零模板:仓库里**不许再有**预设/模板文件(v1.0.1 起本技能只提供技术,不提供模板)。
1744
+ """57 零模板:仓库里**不许再有**预设/模板文件(本技能只提供技术,不提供模板)。
1747
1745
 
1748
- 反过来断言:早先这条是"presets/*.json 可读可转",v1.0.1 把预设整个删掉后,
1746
+ 这条是反向断言:预设整个删掉后,
1749
1747
  如果只是删掉这条检查,就再也拦不住"哪天有人又加一套预设进去"。
1750
1748
  所以改成**禁止存在**——删掉模板是一次性动作,守住它是长期契约。
1751
1749
  """
@@ -1759,7 +1757,7 @@ def t_no_templates():
1759
1757
  for name in ("presetkit.py",):
1760
1758
  if os.path.exists(os.path.join(skill, "scripts", name)):
1761
1759
  banned.append("scripts/%s 还在" % name)
1762
- assert not banned, ("v1.0.1 起本技能不提供模板,这些必须删掉:%s;"
1760
+ assert not banned, ("本技能不提供模板,这些必须删掉:%s;"
1763
1761
  "要自定义请写 design.json(技术原语仍在,只是不再有现成目录)"
1764
1762
  % ";".join(banned))
1765
1763
  # 同时确认"现场采样"是唯一方式,且**每次都新**:
@@ -1783,7 +1781,7 @@ def t_no_templates():
1783
1781
  def t_brief_constraints():
1784
1782
  """60 brief 里的要求必须真的生效(自然语言 -> 约束 -> 采样结果)。
1785
1783
 
1786
- 这条防的是**需求被静默吃掉**:v1.0.1 之前 propose._brief_of 把整句 --brief 塞进 topic,
1784
+ 这条防的是**需求被静默吃掉**:propose._brief_of 若把整句 --brief 塞进 topic,
1787
1785
  must_have/avoid 恒为空数组,于是"深蓝配色的科技解说"采出来是 h087 / h316(橙 / 紫)。
1788
1786
  用户看到的需求和结果无关,而且没有任何提示——比报错更糟。
1789
1787
 
@@ -1831,7 +1829,7 @@ def t_stage_standalone():
1831
1829
  这条守住一类**只在"单跑某个 stage"时才发作**的 bug:
1832
1830
  `--stage all` 时前面的 stage 已经把 script["_style"] 塞好了,后面全都命中缓存,
1833
1831
  所以缺参数也看不出来;一旦用户只跑 `--stage frames` / `--stage sfx`,
1834
- resolve_style 读不到 work/design.json 就报"找不到设计"(v1.0.1 实测踩到:
1832
+ resolve_style 读不到 work/design.json 就报"找不到设计"(实测踩到:
1835
1833
  frames / sfx 两个 stage 单跑必挂,sfx 还是 NameError——函数里压根没有 work 这个名字)。
1836
1834
 
1837
1835
  为什么用 AST 而不是真的跑每个 stage:跑一遍 = 要 TTS 联网 + 全片渲染,几十秒起步,
@@ -1898,17 +1896,17 @@ def t_proposal_gate():
1898
1896
 
1899
1897
 
1900
1898
  def t_subtitle_style():
1901
- """61 字幕样式可定制(v1.0.2):design.visual.subtitle 必须真的改变像素,缺省逐像素不变。
1899
+ """61 字幕样式可定制:design.visual.subtitle 必须真的改变像素,缺省逐像素不变。
1902
1900
 
1903
1901
  为什么必须有这条:字幕字号/位置/背板过去是 filmkit 的**模块常量**,任何两条片子
1904
1902
  的字幕带都一模一样 —— 这正是用户说的"跨片大面积重复"。改成设计字段后,最容易
1905
1903
  出现的退化是"字段读进来了但没传给 draw_subtitle"(写完不生效,实测踩过)。
1906
1904
  所以这里验行为:同一个 theme、同一段文字,换字幕参数必须像素不同;
1907
- 不传参数时必须与 v1.0.1 的调用逐像素一致(老项目不漂)。
1905
+ 不传参数时必须与不传该字段的调用逐像素一致(老项目不漂)。
1908
1906
  """
1909
1907
  import numpy as _np
1910
1908
  import designkit as DK
1911
- # 用一套现场采样设计里的主题,避免依赖内置主题表(v1.0.1 已删)
1909
+ # 用一套现场采样设计里的主题,避免依赖内置主题表(已删)
1912
1910
  d = DK.sample_designs({"topic": "字幕样式自检"}, n=1, seed=3)[0]
1913
1911
  plan = DK.design_to_plan(d)
1914
1912
  theme = plan["theme"]
@@ -1929,9 +1927,9 @@ def t_subtitle_style():
1929
1927
 
1930
1928
 
1931
1929
  def t_layout_cycle():
1932
- """62 片内版式轮换(v1.0.2):layout_cycle 真的让不同章节换骨架,同章必须稳定。
1930
+ """62 片内版式轮换:layout_cycle 真的让不同章节换骨架,同章必须稳定。
1933
1931
 
1934
- 用户原则 5:同一部片不能"长时间一张版式"。v1.0.1 的 design.visual.layout 是单值,
1932
+ 用户原则 5:同一部片不能"长时间一张版式"。design.visual.layout 是单值,
1935
1933
  grammar 把它当全片默认 → 8 段片子 8 屏同骨架。这条验三件事:
1936
1934
  ① 有 layout_cycle 时,不同 chapter 拿到不同版式;
1937
1935
  ② 同一个 chapter 内所有段版式一致(不能每句都跳);
@@ -1961,7 +1959,7 @@ def t_layout_cycle():
1961
1959
 
1962
1960
 
1963
1961
  def t_brief_constraint_robustness():
1964
- """63 带约束的 brief 不许高概率直接失败(v1.0.2)。
1962
+ """63 带约束的 brief 不许高概率直接失败。
1965
1963
 
1966
1964
  实测过的真缺陷:配色候选去重按 24° 分桶,而 must_have「深蓝」把色相窗口压到 52°,
1967
1965
  n=3 时约一半概率抛 ValueError;「暖纸色 + 女声」20/20 全失败。用户第一步就撞墙。
@@ -1983,9 +1981,9 @@ def t_brief_constraint_robustness():
1983
1981
  def t_removed_apis_fail_closed():
1984
1982
  """64 已删除的 API 必须"明确报错/非零退出",不许 NameError、假绿或静默 fallback。
1985
1983
 
1986
- v1.0.1 删内置风格表时留下了一批**死路径**:stylekit 的 --list/--show NameError、
1984
+ 删内置风格表时留下了一批**死路径**:stylekit 的 --list/--show NameError、
1987
1985
  --lint 空表 exit 0(假绿)、filmkit 的 resolve_theme/style_plan 吞异常回落到写死的
1988
- v0.4 主题(静默降级 = 用模板出片)。这三种都必须绝迹。
1986
+ 写死的主题(静默降级 = 用模板出片)。这三种都必须绝迹。
1989
1987
  """
1990
1988
  import subprocess as _sp
1991
1989
  import stylekit as ST
@@ -2021,7 +2019,7 @@ def t_removed_apis_fail_closed():
2021
2019
 
2022
2020
 
2023
2021
  def t_audio_overrides():
2024
- """65 design.audio 的音量/音效参数必须真的落进 script(v1.0.2)。
2022
+ """65 design.audio 的音量/音效参数必须真的落进 script。
2025
2023
 
2026
2024
  实测过的真缺陷:design.json 的 audio.music.gain_db / duck_db 与 audio.sfx.enabled
2027
2025
  / density / gain_db 一直被 validate_design 接受、却从来没有渲染代码读它们 ——
@@ -2054,9 +2052,9 @@ def t_audio_overrides():
2054
2052
 
2055
2053
 
2056
2054
  def t_design_ledger():
2057
- """66 片间去重台账(v1.0.2):确认过的设计要记住,下次提案主动避开;可关可查。
2055
+ """66 片间去重台账:确认过的设计要记住,下次提案主动避开;可关可查。
2058
2056
 
2059
- 用户原则 5:不同视频的整体风格不能大面积重复。v1.0.1 只保证"同一 brief 两次采样不同"
2057
+ 用户原则 5:不同视频的整体风格不能大面积重复。只保证"同一 brief 两次采样不同"
2060
2058
  (nonce),但没有记忆——连拍同题材仍可能撞到相近设计。台账补上这一环。
2061
2059
  这条验四件事:① confirm 后写台账;② 提案会读台账并报告距离;
2062
2060
  ③ 台账路径可被环境变量重定向(绝不污染用户真实台账);④ --no-ledger 可关闭。
@@ -2101,7 +2099,7 @@ def t_design_ledger():
2101
2099
 
2102
2100
 
2103
2101
  def t_duration_target():
2104
- """67 时长要求真的被接住(v1.0.2):brief 里的「2 分钟」要影响设计,渲染时对账。
2102
+ """67 时长要求真的被接住:brief 里的「2 分钟」要影响设计,渲染时对账。
2105
2103
 
2106
2104
  过去 `_brief_of` 把 duration_target 恒填 None —— 用户写「做个 2 分钟的片」等于没写,
2107
2105
  成片永远按台词长度出(实测 23s / 30s)。这条守三件事:
@@ -2127,6 +2125,79 @@ def t_duration_target():
2127
2125
  plan = DK.design_to_plan(d)
2128
2126
  assert plan.get("target_sec") == 120.0, "目标时长没进计划:%r" % (plan.get("target_sec"),)
2129
2127
  return "6 种时长说法解析正确;进 design/plan;超范围拒绝"
2128
+ def t_zoom_stability():
2129
+ """68 缩放取景的帧间稳定性(抖动回归)。
2130
+
2131
+ 为什么要有这条:底图把颗粒烘进去后用 NEAREST 逐帧直采,采样网格随 s 整体跳变,
2132
+ 颗粒图案跟着错位 1px —— 用户实测的原话是"生成的视频有明显的抖动感",
2133
+ 而单帧闪烁门禁(8x8 分块比较前后帧平均)对这类亚像素沸腾**完全不敏感**。
2134
+ 断言:同一主题从 s=1.0 缓推到 s=1+push 的过程中,相邻两帧最大差 <= 2 灰阶。
2135
+ 端点直采实测抖动 4~6,改成端点预烘 + blend 后 0~1。
2136
+ """
2137
+ mod = _load_scenes_mod()
2138
+ if mod is None:
2139
+ return "skip(没有 assets/scenes.py)"
2140
+ import numpy as np
2141
+ push = 0.12
2142
+ th = {"id": "zoom-stab", "bg": (28, 30, 36), "bg2": (18, 19, 24), "texture": "paper",
2143
+ "grain": 5.0, "grain_freq": 0.31, "vignette": 0.2, "vignette_tint": (0, 0, 0),
2144
+ "texture_alpha": 0.03, "push": push}
2145
+ frames = [np.asarray(mod._bg_plate(th, 1.0 + push * i / 19.0), dtype=np.int16)
2146
+ for i in range(20)]
2147
+ worst_max = 0
2148
+ worst_mean = 0.0
2149
+ for i in range(1, len(frames)):
2150
+ d = np.abs(frames[i] - frames[i - 1])
2151
+ worst_max = max(worst_max, int(d.max()))
2152
+ worst_mean = max(worst_mean, float(d.mean()))
2153
+ assert worst_max <= 2, ("缩放取景帧间跳变 %d 灰阶(>2):底图高频纹理被 NEAREST 重排,"
2154
+ "观感就是满屏噪点沸腾" % worst_max)
2155
+ # 均值守住 0.6:8-bit 输出下慢速缓推必然出现"整片 1 灰阶"的量化台阶,
2156
+ # 首帧端点与 s≈1.006 之间实测 mean≈0.44 —— 这是量化噪声,不是图案错位。
2157
+ # 真正能区分"错位沸腾"的是 max:旧实现 4~6,新实现 <=1。
2158
+ assert worst_mean <= 0.6, "缩放取景帧间平均差 %.3f(>0.6,疑似整屏跳变)" % worst_mean
2159
+ return ("20 帧缓推:最大帧间差 %d 灰阶 / 均值 %.3f(旧 NEAREST 直采为 4~6 灰阶)"
2160
+ % (worst_max, worst_mean))
2161
+
2162
+
2163
+ def t_asr_lost_diagnostics():
2164
+ """69 ASR 掉分可定位(低召回必须说清"掉在哪几个字")。
2165
+
2166
+ 为什么要有这条:实测 ASR 回读 0.813~0.844 是常态(中文专名 + 同音字),只报一个
2167
+ 百分比,agent 只能盲目放宽阈值。这条断言盯的是"低于 pass 线时,报告必须给出
2168
+ 没对上的字符(全局 + 最差段落)",且判分本身仍然是纯函数、不加载模型。
2169
+ """
2170
+ cand = os.path.join(HERE, "qaasr.py")
2171
+ if not os.path.exists(cand):
2172
+ return "skip(没有 qaasr.py)"
2173
+ import importlib.util
2174
+ spec = importlib.util.spec_from_file_location("sc_qaasr", cand)
2175
+ mod = importlib.util.module_from_spec(spec)
2176
+ spec.loader.exec_module(mod)
2177
+ scenes = [
2178
+ {"id": "a", "start": 0.0, "end": 3.0, "voice_start": 0.4,
2179
+ "text": "这是用 DeepSeek 生成的声音与画面。"},
2180
+ {"id": "b", "start": 3.0, "end": 6.0, "voice_start": 3.4,
2181
+ "text": "先定脚本,再定声音,最后才是画面。"},
2182
+ ]
2183
+ # ASR 把 DeepSeek 听成"迪普西克"、把"再定"听成"在订"——低召回但不丢句
2184
+ segments = [
2185
+ {"start": 0.4, "end": 3.0, "text": "这是用 迪普西克 生成的声音与画面"},
2186
+ {"start": 3.4, "end": 6.0, "text": "先定脚本,在订声音,最后才是画面"},
2187
+ ]
2188
+ rep = mod._judge(scenes, segments, recall_pass=0.99, recall_warn=0.5)
2189
+ assert rep["recall"] < 0.99, "构造样例没有触发低召回,断言前提不成立"
2190
+ assert rep.get("lost_top"), "低召回时没有输出 lost_top(掉分字符清单)"
2191
+ assert all("ch" in x and "n" in x for x in rep["lost_top"]), "lost_top 结构不对"
2192
+ lost_lines = [p for p in rep["per_line"] if p.get("lost")]
2193
+ assert lost_lines, "没有段落级 lost(最差段落无法定位)"
2194
+ warns = " ".join(rep.get("warnings") or [])
2195
+ assert "没对上的字" in warns, "低召回时警告里没有掉分字符清单"
2196
+ assert "最差段落" in warns, "低召回时警告里没有最差段落定位"
2197
+ return "lost_top %d 项 / 段落级 %d 段 / 警告 %d 条" % (
2198
+ len(rep["lost_top"]), len(lost_lines), len(rep.get("warnings") or []))
2199
+
2200
+
2130
2201
  CASES = [
2131
2202
  ("1 TTS 缓存校验文本", t_cache_key),
2132
2203
  ("2 sub_window 覆盖长句", t_sub_window),
@@ -2140,7 +2211,7 @@ CASES = [
2140
2211
  ("10 qa 字幕条数校验", t_qa_subs),
2141
2212
  ("11 尾静音裁剪", t_trim),
2142
2213
  ("12 mux 音轨 48kHz", t_mux_48k),
2143
- ("13 v0.1 调用签名兼容", t_compat),
2214
+ ("13 调用签名兼容", t_compat),
2144
2215
  ("14 qa 帧率校验", t_fps_check),
2145
2216
  ("15 立体声总线", t_stereo_bus),
2146
2217
  ("16 cue_time 三种写法", t_cue_time),
@@ -2160,9 +2231,9 @@ CASES = [
2160
2231
  ("30 门禁账本与签核", t_gates),
2161
2232
  ("31 阶段新鲜度守卫", t_stagewatch),
2162
2233
  ("32 并行渲染主题序列化", t_parallel_frames),
2163
- ("33 音效跨片可区分(v0.7 内容派生 seed)", t_sfx_content_seed),
2164
- ("34 配乐/音效片内分离(v0.7 duck 加深)", t_audio_separation),
2165
- ("35 旁白优先(v0.7.1 背景让位与恢复)", t_voice_priority),
2234
+ ("33 音效跨片可区分(内容派生 seed)", t_sfx_content_seed),
2235
+ ("34 配乐/音效片内分离(duck 加深)", t_audio_separation),
2236
+ ("35 旁白优先(背景让位与恢复)", t_voice_priority),
2166
2237
  ("36 旁白优先默认值(配乐 -12 dB + 侧链)", t_music_gain_default),
2167
2238
  ("37 旁白清晰度 SNR 兜底(背景太响时自动补压)", t_snr_guard),
2168
2239
  ("38 无旁白时混音不受影响", t_mix_no_voice),
@@ -2195,6 +2266,8 @@ CASES = [
2195
2266
  ("65 design.audio 的音量/音效参数真的落进 script", t_audio_overrides),
2196
2267
  ("66 片间去重台账(确认记账 / 提案避开 / 可关可查)", t_design_ledger),
2197
2268
  ("67 时长要求真的被接住(brief -> design -> 渲染对账)", t_duration_target),
2269
+ ("68 缩放取景的帧间稳定性(抖动回归)", t_zoom_stability),
2270
+ ("69 ASR 掉分可定位(lost_top / 最差段落)", t_asr_lost_diagnostics),
2198
2271
  ]
2199
2272
 
2200
2273