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

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 (32) hide show
  1. package/README.md +41 -16
  2. package/index.js +20 -7
  3. package/package.json +4 -2
  4. package/schemas/film.schema.json +75 -4
  5. package/skills/video-studio/SKILL.md +95 -23
  6. package/skills/video-studio/assets/film.json +7 -14
  7. package/skills/video-studio/assets/scenes.py +115 -10
  8. package/skills/video-studio/assets/web/engine.js +18 -1
  9. package/skills/video-studio/assets/web/film.js +29 -4
  10. package/skills/video-studio/assets/web/index.html +5 -0
  11. package/skills/video-studio/references/audio-variation.md +158 -0
  12. package/skills/video-studio/references/audio.md +67 -5
  13. package/skills/video-studio/references/change-impact.md +1 -1
  14. package/skills/video-studio/references/quality-gates.md +1 -1
  15. package/skills/video-studio/references/web-engine.md +1 -1
  16. package/skills/video-studio/requirements.txt +2 -1
  17. package/skills/video-studio/scripts/filmcheck.py +81 -12
  18. package/skills/video-studio/scripts/filmkit.py +271 -18
  19. package/skills/video-studio/scripts/gates.py +75 -12
  20. package/skills/video-studio/scripts/qa_frames.py +109 -19
  21. package/skills/video-studio/scripts/render.py +420 -51
  22. package/skills/video-studio/scripts/scorekit.py +289 -46
  23. package/skills/video-studio/scripts/selfcheck.py +302 -2
  24. package/skills/video-studio/scripts/sfxkit.py +132 -30
  25. package/skills/video-studio/scripts/stagewatch.py +141 -31
  26. package/skills/video-studio/scripts/stylekit.py +94 -6
  27. package/skills/video-studio/scripts/voicekit.py +8 -0
  28. package/skills/video-studio/scripts/webengine.py +68 -14
  29. package/tools/audio_similarity.py +279 -0
  30. package/tools/ci_check.py +145 -34
  31. package/tools/estimate.py +133 -54
  32. package/tools/selftest_noscipy.py +70 -0
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """audio_similarity.py — 配乐/音效同质化诊断 + 音频改造验收(只读,不产片)。
4
+
5
+ 用法:
6
+ python tools/audio_similarity.py # 打印 4 组对照
7
+ python tools/audio_similarity.py --strict # 额外按契约阈值判定,失败 exit 1
8
+ python tools/audio_similarity.py --out dir/ # 把对照样本写成 wav 便于人耳试听
9
+
10
+ 四组对照(阈值来自 skills/video-studio/references/audio-variation.md §5):
11
+
12
+ A 跨片配乐(v0.7):同风格/同 bpm/同调、只换内容指纹 -> 相关性(目标 <= 0.85);
13
+ 换风格 -> 相关性(目标 <= 0.52,且不能比改造前更接近)
14
+ B 跨片音效(v0.7):同 cue 序列,legacy(不传 style/content_fp)必须逐样本相同;
15
+ 不同 content_fp 必须不同(否则"跨片音效都一样"没被修掉)
16
+ C 片内分离(v0.7):duck 默认深度(目标 >= 3.5 dB)与配乐/音效的频谱质心对照
17
+ D 旁白优先(v0.7.1):人声段 旁白/背景 响度差(目标 >= 12 dB)+ 侧链让位 >= 4 dB
18
+
19
+ 本脚本在改造前是"诊断工具",改造后是"验收工具":--strict 会逐条对阈值判定。
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+
30
+ HERE = Path(__file__).resolve().parent
31
+ SCRIPTS = HERE.parent / "skills" / "video-studio" / "scripts"
32
+ sys.path.insert(0, str(SCRIPTS))
33
+
34
+ # 契约 §5 的阈值(改契约时两边一起改)
35
+ THRESH_A_SAME_STYLE_MAX = 0.85 # 同风格、只换内容指纹的相关性上限
36
+ THRESH_A_CROSS_STYLE_MAX = 0.52 # 换风格的相关性上限(改造前 0.534)
37
+ # 实测余量提示(v0.7.2):本机稳定测到 0.514(连跑 3 次逐位相同),离阈值只剩 0.006。
38
+ # 这个生成链是确定性的,所以换平台/换 numpy 若把 0.514 推到 0.52 以上,
39
+ # 更可能是「风格差异化真的缩水了」而不是浮点噪声 —— 请先查 scorekit 的变奏参数,
40
+ # 别第一反应去抬阈值(抬到 0.53 就离改造前 0.534 只剩 0.004,等于这条门禁白设)。
41
+ THRESH_C_DUCK_MIN = 3.5 # duck 默认深度下限(dB)
42
+ THRESH_D_SNR_MIN = 12.0 # 人声段 旁白/背景 响度差下限(dB)
43
+
44
+ FAILS = []
45
+
46
+
47
+ def _ok(name, cond, detail):
48
+ print(" [%s] %s %s" % ("PASS" if cond else "FAIL", name, detail))
49
+ if not cond:
50
+ FAILS.append("%s: %s" % (name, detail))
51
+
52
+
53
+ def _fmt_db(v):
54
+ """dB 值格式化:NaN(无可判定区间)打印成 'n/a' 而不是让 %f 抛 TypeError。"""
55
+ try:
56
+ v = float(v)
57
+ except (TypeError, ValueError):
58
+ return "n/a"
59
+ return "n/a" if v != v else ("%.1f" % v)
60
+
61
+
62
+ def _rms(x):
63
+ a = np.asarray(x, dtype=np.float64)
64
+ return float(np.sqrt(np.mean(np.square(a)))) if a.size else 0.0
65
+
66
+
67
+ def _corr(x, y):
68
+ x = np.asarray(x, dtype=np.float64)
69
+ y = np.asarray(y, dtype=np.float64)
70
+ if x.ndim == 2:
71
+ x = x.mean(axis=1)
72
+ if y.ndim == 2:
73
+ y = y.mean(axis=1)
74
+ n = min(len(x), len(y))
75
+ x, y = x[:n], y[:n]
76
+ if n == 0:
77
+ return float("nan")
78
+ sx, sy = float(np.std(x)), float(np.std(y))
79
+ if sx <= 1e-12 or sy <= 1e-12:
80
+ # 常量/全静音信号:相关系数没有定义。旧实现返回 0,于是"配乐渲染成静音"这种
81
+ # 最严重的回归会让 A1/A2 反而判 PASS(0 <= 0.85)。
82
+ return float("nan")
83
+ return float(np.mean((x - x.mean()) * (y - y.mean())) / (sx * sy))
84
+
85
+
86
+ def _centroid_hz(x, sr=48000):
87
+ x = np.asarray(x, dtype=np.float64)
88
+ if x.ndim == 2:
89
+ x = x.mean(axis=1)
90
+ spec = np.abs(np.fft.rfft(x))
91
+ freqs = np.fft.rfftfreq(len(x), 1.0 / sr)
92
+ return float((spec * freqs).sum() / (spec.sum() + 1e-12))
93
+
94
+
95
+ def _utf8_stdio():
96
+ for st in (sys.stdout, sys.stderr):
97
+ try:
98
+ if st is not None and not st.isatty():
99
+ st.reconfigure(encoding="utf-8", errors="replace")
100
+ except Exception: # noqa: BLE001
101
+ pass
102
+
103
+
104
+ def _music(dur, style, fp):
105
+ import scorekit as SK
106
+ samples, _rep = SK.render_styled(dur, style=style, bpm=110, key="Dm",
107
+ mood="steady", seed=7, fingerprint=fp)
108
+ return samples
109
+
110
+
111
+ def _cue_track(content_fp=None, style=None, key=None):
112
+ import filmkit as K
113
+ scenes = [{"id": "l01", "kind": "hook", "start": 0.0, "end": 2.0},
114
+ {"id": "l02", "kind": "body", "start": 2.0, "end": 6.0}]
115
+ rows = [{"at": "l02", "type": "ui_click"}, {"at": "l02+0.5", "type": "whoosh"}]
116
+ bus = K.CueBus(scenes=scenes, total=6.0)
117
+ bus.extend(rows)
118
+ return bus.build_audio(total=6.0, verbose=False, style=style,
119
+ content_fp=content_fp, key=key)
120
+
121
+
122
+ def _voice_priority_snr(sr=48000, guard_snr_db=12.0):
123
+ """量"人声比背景响多少":用真实 scorekit 配乐 + 类人声,按 st_audio 的默认参数混音。
124
+
125
+ 返回 (人声活跃区 SNR dB, 间隙背景 RMS, 人声处背景额外让位 dB)。
126
+ 口径与 `filmkit.voice_snr` 一致:只统计人声活跃区(含间隙会稀释问题)。
127
+
128
+ guard_snr_db 默认 12(= st_audio 默认,量"最终交付"的 SNR);测"侧链本身让位多少"
129
+ 时传 0 关掉兜底——否则兜底的整体补压会被算成侧链效果,D2 在侧链被删掉时也能过。
130
+ """
131
+ import filmkit as K
132
+ import numpy as np
133
+ import scorekit as SK
134
+ n = int(6.0 * sr)
135
+ t = np.arange(n) / float(sr)
136
+ voice = np.zeros(n, dtype=np.float64)
137
+ for a, b in ((0.2, 2.6), (3.4, 5.8)): # 两段"说话",中间留间隙
138
+ i, j = int(a * sr), int(b * sr)
139
+ voice[i:j] = 0.12 * np.sin(2 * np.pi * 220 * t[i:j])
140
+ # 真实配乐(scorekit 输出 RMS 约 0.15,比"类人声"响 —— 正是需要侧链/兜底的场景)
141
+ music, _ = SK.render_styled(6.0, style="tech-deep", bpm=110, key="Am",
142
+ mood="steady", seed=7, fingerprint="d-group")
143
+ music = np.asarray(music, dtype=np.float32)
144
+ out = K.mix_film(voice.astype(np.float32), music, None, total=6.0, fade=0.0,
145
+ music_gain_db=-12.0, voice_duck_db=6.0,
146
+ guard_snr_db=float(guard_snr_db), verbose=False)
147
+ v2 = np.stack([voice, voice], axis=1)
148
+ bg = out - v2
149
+ snr = K.voice_snr(voice.astype(np.float32), bg)
150
+ gap = slice(int(2.8 * sr), int(3.2 * sr))
151
+ gap_rms = float(np.sqrt(np.mean(bg[gap] ** 2)))
152
+ # 人声处背景相对"未压背景"的额外让位:未压背景 = music * 10^(-12/20)
153
+ base_bg = music * 10 ** (-12.0 / 20.0)
154
+ b_talk = slice(int(0.5 * sr), int(2.2 * sr))
155
+ duck_eff = 20 * np.log10((float(np.sqrt(np.mean(base_bg[b_talk] ** 2))) + 1e-12) /
156
+ (float(np.sqrt(np.mean(bg[b_talk] ** 2))) + 1e-12))
157
+ return snr, gap_rms, duck_eff
158
+
159
+
160
+ def _duck_default():
161
+ src = (SCRIPTS / "render.py").read_text(encoding="utf-8")
162
+ m = re.search(r"duck_default\s*=\s*([0-9.]+)", src)
163
+ return float(m.group(1)) if m else float("nan")
164
+
165
+
166
+ def main(argv=None):
167
+ _utf8_stdio()
168
+ ap = argparse.ArgumentParser(description="配乐/音效相似度诊断(v0.7 验收)")
169
+ ap.add_argument("--out", default="", help="把对照样本写成 wav 到这个目录")
170
+ ap.add_argument("--dur", type=float, default=20.0, help="配乐对照时长(秒)")
171
+ ap.add_argument("--strict", action="store_true", help="按契约 §5 阈值判定,失败 exit 1")
172
+ args = ap.parse_args(argv)
173
+
174
+ import sfxkit as SX
175
+
176
+ print("=" * 72)
177
+ print("A. 跨片配乐(差异来源 = 风格 / bpm / 调 / 内容指纹 / seed)")
178
+ print("=" * 72)
179
+ a1 = _music(args.dur, "tech-deep", "content-a")
180
+ a2 = _music(args.dur, "tech-deep", "content-b")
181
+ a3 = _music(args.dur, "magazine", "content-a")
182
+ a4 = _music(args.dur, "tech-deep", "content-a") # 同指纹复渲染,验证确定性
183
+ same_style = _corr(a1, a2)
184
+ cross_style = _corr(a1, a3)
185
+ rms_a1 = _rms(a1)
186
+ print(" 配乐非静音(RMS > -60 dBFS) : %s (rms=%.4f,静音时相关系数无定义)"
187
+ % (rms_a1 > 0.001, rms_a1))
188
+ print(" 同风格·同bpm·同调,只换内容指纹 : corr = %.3f (目标 <= %.2f)"
189
+ % (same_style, THRESH_A_SAME_STYLE_MAX))
190
+ print(" 换风格(tech-deep -> magazine) : corr = %.3f (目标 <= %.2f)"
191
+ % (cross_style, THRESH_A_CROSS_STYLE_MAX))
192
+ print(" 同指纹两次渲染逐样本相同 : %s" % bool(np.array_equal(a1, a4)))
193
+ if args.strict:
194
+ # A0 是前置条件:若配乐渲染成静音/常量,A1/A2 的相关系数没有定义(NaN),
195
+ # 必须显式判 FAIL,而不是让 NaN 比较悄悄变成 False 后只剩一句 corr=nan。
196
+ _ok("A0 配乐非静音", rms_a1 > 0.001, "rms=%.4f" % rms_a1)
197
+ _ok("A1 同风格换指纹可区分", same_style <= THRESH_A_SAME_STYLE_MAX,
198
+ "corr=%.3f" % same_style)
199
+ _ok("A2 换风格更不像", cross_style <= THRESH_A_CROSS_STYLE_MAX,
200
+ "corr=%.3f" % cross_style)
201
+ _ok("A3 配乐可复现", bool(np.array_equal(a1, a4)), "同指纹逐样本一致")
202
+
203
+ print()
204
+ print("=" * 72)
205
+ print("B. 跨片音效(CueBus 内容派生 seed)")
206
+ print("=" * 72)
207
+ b_legacy1, b_legacy2 = _cue_track(), _cue_track()
208
+ b_new_a = _cue_track("aaaa1111", "tech-deep", "Dm")
209
+ b_new_b = _cue_track("bbbb2222", "tech-deep", "Dm")
210
+ b_new_a2 = _cue_track("aaaa1111", "tech-deep", "Dm")
211
+ legacy_same = bool(np.array_equal(b_legacy1, b_legacy2))
212
+ cross_diff = not bool(np.array_equal(b_new_a, b_new_b))
213
+ repeat_same = bool(np.array_equal(b_new_a, b_new_a2))
214
+ print(" legacy(不传 style/fp)两次相同 : %s (必须 True,v0.4 兼容)" % legacy_same)
215
+ print(" 不同内容指纹 -> 音效轨不同 : %s (必须 True)" % cross_diff)
216
+ print(" 同内容指纹两次相同 : %s (必须 True,可复现)" % repeat_same)
217
+ if args.strict:
218
+ _ok("B1 legacy 逐样本兼容", legacy_same, "两次渲染相同")
219
+ _ok("B2 跨片音效可区分", cross_diff, "不同 content_fp 不再逐样本相同")
220
+ _ok("B3 音效可复现", repeat_same, "同 content_fp 逐样本一致")
221
+
222
+ print()
223
+ print("=" * 72)
224
+ print("C. 片内分离:混音 duck 深度 + 频谱质心对照")
225
+ print("=" * 72)
226
+ duck = _duck_default()
227
+ print(" duck 默认深度 : %.1f dB (目标 >= %.1f)"
228
+ % (duck, THRESH_C_DUCK_MIN))
229
+ for name, x in (("impact", SX.render("impact", seed=1000)),
230
+ ("whoosh", SX.render("whoosh", seed=1000)),
231
+ ("riser", SX.render("riser", seed=1000)),
232
+ ("music(前4s)", a1[: 48000 * 4])):
233
+ print(" %-12s 频谱质心 ~ %6.0f Hz" % (name, _centroid_hz(x)))
234
+ print(" 提示音调性跟随:in_key 现在接受 key=(配乐调性),不再硬编码 Dm。")
235
+ if args.strict:
236
+ _ok("C1 duck 加深", duck >= THRESH_C_DUCK_MIN, "duck=%.1f dB" % duck)
237
+
238
+ print()
239
+ print("=" * 72)
240
+ print("D. 旁白优先:人声与背景的响度差(v0.7.1)")
241
+ print("=" * 72)
242
+ snr_db, base_db, _duck_with_guard = _voice_priority_snr()
243
+ # 侧链让位单独量一次(guard_snr_db=0):兜底的整体补压也是"背景变低",
244
+ # 混在一起算会把 D2 变成"guard 生效"的证据,侧链被删掉时 D2 照样 PASS。
245
+ _snr_no_guard, _gap_no_guard, duck_db_eff = _voice_priority_snr(guard_snr_db=0.0)
246
+ print(" 人声段 旁白/背景 响度差 : %s dB (目标 >= %.1f,含 guard 兜底)"
247
+ % (_fmt_db(snr_db), THRESH_D_SNR_MIN))
248
+ print(" 人声处背景额外让位 : %s dB (目标 >= 4,仅侧链,guard 关)"
249
+ % _fmt_db(duck_db_eff))
250
+ print(" 间隙里背景电平 : %.3f(应为恢复后的基线)" % base_db)
251
+ if args.strict:
252
+ _ok("D1 旁白听得清", snr_db is not None and snr_db == snr_db
253
+ and snr_db >= THRESH_D_SNR_MIN, "SNR=%s dB" % _fmt_db(snr_db))
254
+ _ok("D2 旁白侧链生效", duck_db_eff >= 4.0, "让位 %s dB" % _fmt_db(duck_db_eff))
255
+
256
+ if args.out:
257
+ out = Path(args.out)
258
+ out.mkdir(parents=True, exist_ok=True)
259
+ from scorekit import save as music_save
260
+ music_save(out / "music_tech_deep_a.wav", a1)
261
+ music_save(out / "music_tech_deep_b.wav", a2)
262
+ music_save(out / "music_magazine.wav", a3)
263
+ for t in ("whoosh", "riser", "impact"):
264
+ SX.save(out / ("sfx_%s.wav" % t), SX.render(t, seed=1000))
265
+ print("\n对照样本已写入: %s" % out)
266
+
267
+ if args.strict:
268
+ print()
269
+ if FAILS:
270
+ print("STRICT FAIL: %d 项未达标" % len(FAILS))
271
+ for f in FAILS:
272
+ print(" - %s" % f)
273
+ return 1
274
+ print("STRICT PASS: 全部阈值达标(A/B/C/D 四组)")
275
+ return 0
276
+
277
+
278
+ if __name__ == "__main__":
279
+ raise SystemExit(main())
package/tools/ci_check.py CHANGED
@@ -6,17 +6,22 @@
6
6
  它检查的是**这个仓库作为"可分发的技能插件"的完整性**,而不是视频能不能出片
7
7
  (出片要靠 selfcheck.py + 真跑一遍 render.py,见 SKILL.md §14):
8
8
 
9
- 1 语法:所有 .py 能编译、index.js 能通过 node --check
10
- 2 JSON:所有 .json 能被解析(locale / marketplace-entry / assets / package.json)
11
- 3 技能清单:SKILL.md 的 frontmatter 有 name/description,name 与目录名一致
12
- 4 依赖白名单:scripts/*.py 与 assets/*.py 只允许 stdlib + numpy/PIL/cv2/scipy/edge_tts/playwright
9
+ 1 语法:scripts/ + assets/ + tools/ 下的 .py 全部能编译(SyntaxWarning 也算失败)
10
+ 2 JS 语法:根目录与 assets/web/ 的 .js 通过 node --check
11
+ 3 JSON:所有 .json 能被解析(locale / marketplace-entry / assets / package.json),
12
+ 且 package.json 的 version 与 README 徽章一致
13
+ 4 技能清单:SKILL.md 的 frontmatter 有 name/description,name 与目录名一致
14
+ 5 依赖白名单:scripts/*.py 与 assets/*.py 只允许 stdlib + numpy/PIL/cv2/scipy/edge_tts/playwright
13
15
  (技能是"整目录拷走就能跑"的,偷偷引入一个三方依赖会在用户机器上炸)
14
- 5 打包卫生:npm pack 的内容里不能有 __pycache__ / *.pyc / 临时目录
15
- 6 文档引用:SKILL.md / references 里引用的 scripts/xxx.py、assets/xxx 必须真的存在
16
- 7 Python 3.9 语法兼容(THIRD_PARTY_NOTICES 写的是 3.9+;这里只验语法,运行期 API 仍要真机实测)
17
- 8 Markdown 围栏成对(不平衡会让整篇渲染成代码块)
18
- 9 GitHub Actions workflow 里 step 名的裸冒号(会让整份 workflow 静默不跑)
19
- 10 体积:单个文件不超过 400 KB(技能要能塞进上下文与包里)
16
+ 6 打包卫生:npm pack 的内容里不能有 __pycache__ / *.pyc / 临时目录;必需文件必须在包里
17
+ 7 文档路径:SKILL.md / references / README 里引用的 scripts/xxx.py、assets/xxx,
18
+ 以及正文里的相对 Markdown 链接与图片,必须真的存在
19
+ 8 Python 3.9 语法兼容(THIRD_PARTY_NOTICES 写的是 3.9+;这里只验语法,运行期 API 仍要真机实测)
20
+ 9 Markdown 围栏成对(不平衡会让整篇渲染成代码块;按围栏状态机判,不是数出现次数)
21
+ 10 CI workflow:step 名的裸冒号(会让整份 workflow 静默不跑),
22
+ 以及 run 里 python <路径>.py 指向的文件必须存在
23
+ 11 体积:单个文件不超过 400 KB(技能要能塞进上下文与包里)
24
+ 12 film.json 契约:schemas/film.schema.json + scripts/filmcheck.py 校验模板(规则只留一份)
20
25
 
21
26
  退出码:0 全过;1 有 FAIL。每一项都打印证据,不打"可能有问题"。
22
27
  """
@@ -35,6 +40,7 @@ ROOT = Path(__file__).resolve().parent.parent
35
40
  SKILL = ROOT / "skills" / "video-studio"
36
41
  SCRIPTS = SKILL / "scripts"
37
42
  ASSETS = SKILL / "assets"
43
+ TOOLS = ROOT / "tools"
38
44
  REFS = SKILL / "references"
39
45
 
40
46
  # 技能允许的三方依赖(与 README「环境前提」一一对应;加东西必须先改文档)
@@ -65,20 +71,38 @@ def check(name: str, fn):
65
71
 
66
72
  # ---------------------------------------------------------------- 1 语法
67
73
  def _py_files() -> list[Path]:
68
- out = sorted(SCRIPTS.glob("*.py")) + sorted(ASSETS.glob("*.py"))
74
+ """技能脚本 + 技能资产里的 .py + 仓库工具脚本。
75
+
76
+ tools/ 以前不在扫描范围里,于是 ci_check.py 自己的 invalid escape sequence
77
+ (Python 3.12 的 SyntaxWarning)一路带到发布,没人发现——"语法检查"只查了半边。
78
+ """
79
+ out = sorted(SCRIPTS.glob("*.py")) + sorted(ASSETS.glob("*.py")) + sorted(TOOLS.glob("*.py"))
69
80
  return [p for p in out if p.name != "__init__.py"]
70
81
 
71
82
 
72
83
  def t_syntax_py():
84
+ """编译所有 .py;SyntaxWarning 也算失败。
85
+
86
+ 为什么要警告转错误:invalid escape sequence(反斜杠 + 普通字母,比如 Python 里的"\\d")在 3.12 起是 SyntaxWarning,
87
+ 只打印不失败——脚本照跑,但 warning 会出现在每个用户的第一次导入里,而且掩盖真问题。
88
+ """
89
+ import warnings
90
+
73
91
  bad = []
74
92
  for p in _py_files():
75
93
  src = p.read_text(encoding="utf-8")
76
94
  try:
77
- compile(src, str(p), "exec")
95
+ with warnings.catch_warnings(record=True) as caught:
96
+ warnings.simplefilter("always", SyntaxWarning)
97
+ compile(src, str(p), "exec")
98
+ warns = [w for w in caught if issubclass(w.category, SyntaxWarning)]
99
+ if warns:
100
+ bad.append("%s:%s SyntaxWarning: %s"
101
+ % (p.name, warns[0].lineno, warns[0].message))
78
102
  except SyntaxError as e:
79
103
  bad.append("%s:%s %s" % (p.name, e.lineno, e.msg))
80
104
  assert not bad, ";".join(bad)
81
- return "%d 个 .py 全部可编译" % len(_py_files())
105
+ return "%d 个 .py 全部可编译(含 tools/,SyntaxWarning 记失败)" % len(_py_files())
82
106
 
83
107
 
84
108
  def t_syntax_js():
@@ -107,7 +131,17 @@ def t_json():
107
131
  json.loads(p.read_text(encoding="utf-8"))
108
132
  except Exception as e: # noqa: BLE001
109
133
  raise AssertionError("%s: %s" % (p.relative_to(ROOT), e))
110
- return "%d 个 .json 全部可解析" % len(files)
134
+ # 版本号三处必须一致:package.json / README 徽章 / marketplace-entry 描述里提到的 vX.Y。
135
+ # 这条以前没人查,于是 README 的徽章停在 0.6.0、包已经是 0.7.1 也没人发现(真发生过)。
136
+ ver = json.loads((ROOT / "package.json").read_text(encoding="utf-8")).get("version", "")
137
+ assert re.fullmatch(r"\d+\.\d+\.\d+", ver), "package.json version 不像 semver:%r" % ver
138
+ readme = (ROOT / "README.md").read_text(encoding="utf-8")
139
+ badges = re.findall(r"badge/version-(\d+\.\d+\.\d+)-", readme)
140
+ assert badges, "README 里找不到 version 徽章(badge/version-x.y.z)"
141
+ bad_badges = [b for b in badges if b != ver]
142
+ assert not bad_badges, ("README 徽章版本 %s 与 package.json version %s 不一致"
143
+ % ("/".join(bad_badges), ver))
144
+ return "%d 个 .json 全部可解析;version=%s 与 README 徽章一致" % (len(files), ver)
111
145
 
112
146
 
113
147
  # ---------------------------------------------------------------- 3 技能清单
@@ -170,8 +204,12 @@ def t_pack():
170
204
  or f.startswith(("work/", "out/", ".team/"))]
171
205
  assert not junk, ("包内不该有这些文件(临时产物/日志/媒体,属于打包污染):%s"
172
206
  % ", ".join(junk[:6]))
173
- must = ["index.js", "cordis.patch.yml", "icon.svg", "package.json",
174
- "skills/video-studio/SKILL.md", "skills/video-studio/scripts/filmkit.py"]
207
+ must = ["index.js", "cordis.patch.yml", "icon.svg", "package.json", "README.md",
208
+ "skills/video-studio/SKILL.md", "skills/video-studio/scripts/filmkit.py",
209
+ "skills/video-studio/scripts/render.py", "skills/video-studio/scripts/selfcheck.py",
210
+ "skills/video-studio/assets/film.json", "skills/video-studio/references/quality-gates.md",
211
+ "schemas/film.schema.json", "tools/ci_check.py", "tools/estimate.py",
212
+ "tools/audio_similarity.py", "tools/selftest_noscipy.py"]
175
213
  missing = [m for m in must if m not in files]
176
214
  assert not missing, "包内缺文件:%s" % ", ".join(missing)
177
215
  return "%d 个文件 / %.0f KB" % (len(files), meta.get("size", 0) / 1024.0)
@@ -191,8 +229,54 @@ def t_doc_refs():
191
229
  cands = [SKILL / rel, SKILL / "references" / rel, ROOT / rel]
192
230
  if not any(c.exists() for c in cands):
193
231
  missing.append("%s 里引用 %s(不存在)" % (md.name, rel))
232
+ # 正文里的相对 Markdown 链接 / 图片是另一类:上面的正则只覆盖 scripts|assets|references 前缀,
233
+ # [说明](references/foo.md) 之外的写法(比如 [x](../schemas/film.schema.json))会漏掉。
234
+ link_bad = []
235
+ for md in md_files:
236
+ if not md.is_file():
237
+ continue
238
+ txt = md.read_text(encoding="utf-8")
239
+ for m in re.finditer(r"!?\[([^\]]*)\]\(([^)\s]+)\)", txt):
240
+ target = m.group(2)
241
+ if re.match(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|#)", target):
242
+ continue # 外链 / 纯锚点
243
+ rel = target.split("#", 1)[0]
244
+ if not rel:
245
+ continue
246
+ cands = [md.parent / rel, ROOT / rel, SKILL / rel]
247
+ if not any(c.exists() for c in cands):
248
+ link_bad.append("%s 里链接 %s(不存在)" % (md.name, target))
249
+ # README 的效果图是 HTML <img src="docs/...">,不是 Markdown 语法,单独查一遍。
250
+ for m in re.finditer(r"<img[^>]*\ssrc=[\"']([^\"']+)[\"']", txt):
251
+ target = m.group(1)
252
+ if re.match(r"^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|//)", target):
253
+ continue
254
+ rel = target.split("?", 1)[0].split("#", 1)[0]
255
+ cands = [md.parent / rel, ROOT / rel]
256
+ if rel and not any(c.exists() for c in cands):
257
+ link_bad.append("%s 里 <img src> 指向 %s(不存在)" % (md.name, target))
258
+ # 第三种:带占位符的路径(<SKILL_DIR>/... 与 <REPO>/...)。ci_check 之前只认 scripts/|assets/|references/
259
+ # 前缀,SKILL.md 里 <SKILL_DIR>/../../../tools/estimate.py 这种写错两级相对层级的命令一直没人查——
260
+ # 实测它指向仓库上一级,是条**跑不通的命令**。语义:<SKILL_DIR> = skills/video-studio/,
261
+ # <REPO> = 包根(仓库根)。注意**不能**把它们当成"当前工作目录":quickstart 把技能拷进 myfilm/ 后,
262
+ # 那里的 skills 与 tools 都不存在,但文档里的占位符指的是包内位置,仍然应当成立。
263
+ ph_bad = []
264
+ ph_re = re.compile(r"(<SKILL_DIR>|<REPO>)/([A-Za-z0-9_.\-/{}*]+)")
265
+ for md in md_files:
266
+ if not md.is_file():
267
+ continue
268
+ for ln_no, line in enumerate(md.read_text(encoding="utf-8").splitlines(), 1):
269
+ for m in ph_re.finditer(line):
270
+ base = SKILL if m.group(1) == "<SKILL_DIR>" else ROOT
271
+ rel = m.group(2).rstrip(".,;:")
272
+ if any(ch in rel for ch in "{}*"):
273
+ rel = rel.split("{")[0].split("*")[0].rsplit("/", 1)[0] if ("{" in rel or "*" in rel) else rel
274
+ if not (base / rel).exists():
275
+ ph_bad.append("%s:%d 的 %s/%s 不存在" % (md.name, ln_no, m.group(1), rel))
276
+ assert not ph_bad, ";".join(sorted(set(ph_bad))[:8])
277
+ assert not link_bad, ";".join(sorted(set(link_bad))[:8])
194
278
  assert not missing, ";".join(sorted(set(missing))[:8])
195
- return "%d 篇文档的路径引用全部存在" % sum(1 for m in md_files if m.is_file())
279
+ return "%d 篇文档的路径引用 + 正文相对链接全部存在" % sum(1 for m in md_files if m.is_file())
196
280
 
197
281
 
198
282
  # ---------------------------------------------------------------- 7 体积
@@ -226,28 +310,45 @@ def t_py39():
226
310
 
227
311
  # ---------------------------------------------------------------- 9 Markdown 围栏平衡
228
312
  def t_md_fences():
229
- """每篇 .md 的 \`\`\` 必须成对:围栏不平衡会让后面整篇渲染成代码块,人眼未必看得出。"""
313
+ """每篇 .md 的代码围栏必须成对(按围栏状态机判,不是数出现次数)。
314
+
315
+ v0.7.1 之前这里写的是 startswith("\\`\\`\\`")——那是一个无效转义,Python 把反斜杠原样保留,
316
+ 于是永远匹配不到真实的 ``` 行,检查恒过("看着在查,其实查不出",正是本仓库最怕的形态)。
317
+ 现在改成状态机:开围栏记下字符与长度,闭合围栏必须同字符、长度不小于开口、且不带信息串。
318
+ """
230
319
  bad = []
231
320
  files = [ROOT / "README.md", ROOT / "CHANGELOG.md", ROOT / "THIRD_PARTY_NOTICES.md",
232
321
  SKILL / "SKILL.md"] + sorted(REFS.glob("*.md"))
322
+ fence_re = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
233
323
  for p in files:
234
324
  if not p.is_file():
235
325
  continue
236
- n = sum(1 for line in p.read_text(encoding="utf-8").splitlines()
237
- if line.lstrip().startswith("\`\`\`"))
238
- if n % 2:
239
- bad.append("%s: %d 个围栏(奇数)" % (p.name, n))
326
+ opened = None
327
+ close_line = None
328
+ for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1):
329
+ m = fence_re.match(line)
330
+ if not m:
331
+ continue
332
+ fence, info = m.group(1), m.group(2).strip()
333
+ if opened is None:
334
+ opened, close_line = (fence[0], len(fence)), i
335
+ elif fence[0] == opened[0] and len(fence) >= opened[1] and not info:
336
+ opened = None
337
+ if opened is not None:
338
+ bad.append("%s: 第 %d 行开的 %s 围栏没有闭合" % (p.name, close_line, opened[0] * 3))
240
339
  assert not bad, ";".join(bad)
241
- return "%d 篇 Markdown 围栏都成对" % len([p for p in files if p.is_file()])
340
+ return "%d 篇 Markdown 围栏都成对(状态机判定)" % len([p for p in files if p.is_file()])
242
341
 
243
342
 
244
343
  # ---------------------------------------------------------------- 10 CI workflow 基本语法
245
344
  def t_ci_workflow():
246
- """GitHub Actions workflow 的「裸冒号」陷阱。
345
+ """GitHub Actions workflow 的两个高频错误。
247
346
 
248
- 「- name: selftest: xxx」里第二个冒号会让整份 YAML 解析失败,而 GitHub 上的表现是
249
- 「workflow 根本没跑」——没有任何报错提示,最容易被忽略(本项目真踩过)。
250
- 不引第三方 YAML 库(CI 里不一定装了 pyyaml),只查这一类高频错误。
347
+ 1) 「裸冒号」:「- name: selftest: xxx」里第二个冒号会让整份 YAML 解析失败,而 GitHub 上的表现是
348
+ 「workflow 根本没跑」——没有任何报错提示,最容易被忽略(本项目真踩过)。
349
+ 2) 「run 里指向不存在的脚本」:`run: python tools/xxx.py` 写错路径时,本地 ci_check 看不出来,
350
+ 要等 CI 跑红——而那一步本来就该秒级失败,所以在这里先查一遍文件是否存在。
351
+ 不引第三方 YAML 库(CI 里不一定装了 pyyaml),只查这两类高频错误。
251
352
  """
252
353
  wf = ROOT / ".github" / "workflows"
253
354
  if not wf.is_dir():
@@ -257,16 +358,26 @@ def t_ci_workflow():
257
358
  for f in files:
258
359
  for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
259
360
  m = re.match(r"^(\s*)-\s+name:\s*(.+)$", line)
260
- if not m:
361
+ if m:
362
+ val = m.group(2).strip()
363
+ if val[:1] not in ("'", '"') and ": " in val:
364
+ bad.append("%s:%d 的 step 名含未加引号的冒号(YAML 会解析失败,整份 workflow 不跑)"
365
+ % (f.name, i))
261
366
  continue
262
- val = m.group(2).strip()
263
- if val[:1] in ("'", '"'):
367
+ # run 块里的 python <相对路径>.py:工作目录可能是仓库根,也可能是技能 scripts 目录。
368
+ # 只看「行首是 python」的行(含 `run: |` 块里的缩进行),不做 YAML 解析,避免把注释/名称
369
+ # 里的 python 字样当命令(误报比漏报更烦人)。
370
+ if not re.match(r"^\s*(?:-\s+)?(?:run:\s*)?python[0-9.]*\s+", line):
264
371
  continue
265
- if ": " in val:
266
- bad.append("%s:%d 的 step 名含未加引号的冒号(YAML 会解析失败,整份 workflow 不跑)"
267
- % (f.name, i))
372
+ for pm in re.finditer(r"python[0-9.]*\s+(?!-)([A-Za-z0-9_./-]+\.py)", line):
373
+ rel = pm.group(1)
374
+ if rel.startswith("/") or ":" in rel:
375
+ continue # 绝对路径 / URL,不归本检查管
376
+ cands = [ROOT / rel, SCRIPTS / rel, SKILL / rel]
377
+ if not any(c.exists() for c in cands):
378
+ bad.append("%s:%d 里 python 调用的 %s 不存在" % (f.name, i, rel))
268
379
  assert not bad, ";".join(bad[:4])
269
- return "%d 个 workflow 的 step 名没有裸冒号" % len(files)
380
+ return "%d 个 workflow:step 名无裸冒号、run 里的 python 脚本都存在" % len(files)
270
381
 
271
382
  # ---------------------------------------------------------------- 12 film.json 契约
272
383
  def t_film_contract():