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

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.
@@ -0,0 +1,339 @@
1
+ # -*- coding: utf-8 -*-
2
+ """ci_check.py — 仓库级体检(不需要 ffmpeg / 字体 / 网络,可在任何机器和 CI 上跑)。
3
+
4
+ 跑法:python tools/ci_check.py [-v]
5
+
6
+ 它检查的是**这个仓库作为"可分发的技能插件"的完整性**,而不是视频能不能出片
7
+ (出片要靠 selfcheck.py + 真跑一遍 render.py,见 SKILL.md §14):
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
13
+ (技能是"整目录拷走就能跑"的,偷偷引入一个三方依赖会在用户机器上炸)
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(技能要能塞进上下文与包里)
20
+
21
+ 退出码:0 全过;1 有 FAIL。每一项都打印证据,不打"可能有问题"。
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import ast
27
+ import json
28
+ import os
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ ROOT = Path(__file__).resolve().parent.parent
35
+ SKILL = ROOT / "skills" / "video-studio"
36
+ SCRIPTS = SKILL / "scripts"
37
+ ASSETS = SKILL / "assets"
38
+ REFS = SKILL / "references"
39
+
40
+ # 技能允许的三方依赖(与 README「环境前提」一一对应;加东西必须先改文档)
41
+ ALLOWED_THIRD_PARTY = {"numpy", "PIL", "cv2", "scipy", "edge_tts", "playwright"}
42
+ # 同目录同级模块(拷走整个目录后仍能互相 import)
43
+ LOCAL_MODULES = {"filmkit", "sfxkit", "scorekit", "webengine", "qa_frames", "render", "selfcheck",
44
+ "stylekit", "voicekit", "scenes", "scenes_demo",
45
+ # v0.6.0:门禁账本 / 交付报告 / 签核、阶段新鲜度守卫、film.json 契约校验器
46
+ "gates", "stagewatch", "filmcheck"}
47
+ MAX_FILE_BYTES = 400 * 1024
48
+
49
+ _results: list[tuple[bool, str, str]] = []
50
+
51
+
52
+ def check(name: str, fn):
53
+ """跑一项检查;返回 (ok, name, detail)。任何异常都算 FAIL 并保留消息。"""
54
+ try:
55
+ detail = fn() or ""
56
+ _results.append((True, name, str(detail)))
57
+ return True
58
+ except AssertionError as e:
59
+ _results.append((False, name, str(e)))
60
+ return False
61
+ except Exception as e: # noqa: BLE001
62
+ _results.append((False, name, "%s: %s" % (type(e).__name__, e)))
63
+ return False
64
+
65
+
66
+ # ---------------------------------------------------------------- 1 语法
67
+ def _py_files() -> list[Path]:
68
+ out = sorted(SCRIPTS.glob("*.py")) + sorted(ASSETS.glob("*.py"))
69
+ return [p for p in out if p.name != "__init__.py"]
70
+
71
+
72
+ def t_syntax_py():
73
+ bad = []
74
+ for p in _py_files():
75
+ src = p.read_text(encoding="utf-8")
76
+ try:
77
+ compile(src, str(p), "exec")
78
+ except SyntaxError as e:
79
+ bad.append("%s:%s %s" % (p.name, e.lineno, e.msg))
80
+ assert not bad, ";".join(bad)
81
+ return "%d 个 .py 全部可编译" % len(_py_files())
82
+
83
+
84
+ def t_syntax_js():
85
+ node = _which("node")
86
+ if node is None:
87
+ return "跳过(没装 node)"
88
+ js = [p for p in ROOT.glob("*.js")] + sorted((ASSETS / "web").glob("*.js"))
89
+ for p in js:
90
+ r = subprocess.run([node, "--check", str(p)], capture_output=True, text=True)
91
+ assert r.returncode == 0, "%s: %s" % (p.name, (r.stderr or r.stdout).strip()[:300])
92
+ return "%d 个 .js 全部通过 node --check" % len(js)
93
+
94
+
95
+ def _which(exe: str):
96
+ from shutil import which
97
+ return which(exe)
98
+
99
+
100
+ # ---------------------------------------------------------------- 2 JSON
101
+ def t_json():
102
+ files = [ROOT / "package.json", ROOT / "marketplace-entry.json"]
103
+ files += sorted((ROOT / "locale").glob("*.json"))
104
+ files += sorted(ASSETS.glob("*.json"))
105
+ for p in files:
106
+ try:
107
+ json.loads(p.read_text(encoding="utf-8"))
108
+ except Exception as e: # noqa: BLE001
109
+ raise AssertionError("%s: %s" % (p.relative_to(ROOT), e))
110
+ return "%d 个 .json 全部可解析" % len(files)
111
+
112
+
113
+ # ---------------------------------------------------------------- 3 技能清单
114
+ def t_manifest():
115
+ md = SKILL / "SKILL.md"
116
+ assert md.is_file(), "缺 skills/video-studio/SKILL.md"
117
+ txt = md.read_text(encoding="utf-8")
118
+ assert txt.startswith("---"), "SKILL.md 必须以 YAML frontmatter 开头"
119
+ end = txt.find("\n---", 3)
120
+ assert end > 0, "SKILL.md frontmatter 没有闭合的 ---"
121
+ fm = txt[3:end]
122
+ name = re.search(r"^name:\s*(\S+)\s*$", fm, re.M)
123
+ desc = re.search(r"^description:\s*(.+)$", fm, re.M)
124
+ assert name, "frontmatter 缺 name"
125
+ assert desc and len(desc.group(1).strip()) >= 20, "frontmatter 缺 description 或太短"
126
+ assert name.group(1) == "video-studio", "frontmatter name=%r,应为目录名 video-studio" % name.group(1)
127
+ return "name=video-studio / description %d 字" % len(desc.group(1).strip())
128
+
129
+
130
+ # ---------------------------------------------------------------- 4 依赖白名单
131
+ def t_deps():
132
+ stdlib = set(sys.stdlib_module_names)
133
+ bad = []
134
+ for p in _py_files():
135
+ tree = ast.parse(p.read_text(encoding="utf-8"))
136
+ for node in ast.walk(tree):
137
+ names = []
138
+ if isinstance(node, ast.Import):
139
+ names = [a.name for a in node.names]
140
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
141
+ names = [node.module]
142
+ for n in names:
143
+ top = n.split(".")[0]
144
+ if top in stdlib or top in ALLOWED_THIRD_PARTY or top in LOCAL_MODULES:
145
+ continue
146
+ bad.append("%s: import %s(不在白名单)" % (p.name, top))
147
+ assert not bad, ";".join(sorted(set(bad)))
148
+ return "白名单:stdlib + %s" % "/".join(sorted(ALLOWED_THIRD_PARTY))
149
+
150
+
151
+ # ---------------------------------------------------------------- 5 打包卫生
152
+ def t_pack():
153
+ npm = _which("npm")
154
+ if npm is None:
155
+ return "跳过(没装 npm)"
156
+ r = subprocess.run([npm, "pack", "--dry-run", "--json"], cwd=str(ROOT),
157
+ capture_output=True, text=True, shell=(os.name == "nt"))
158
+ assert r.returncode == 0, "npm pack --dry-run 失败:%s" % (r.stderr or r.stdout)[:300]
159
+ try:
160
+ meta = json.loads(r.stdout)[0]
161
+ except Exception as e: # noqa: BLE001
162
+ raise AssertionError("解析 npm pack 输出失败:%s" % e)
163
+ files = [f["path"] for f in meta.get("files", [])]
164
+ # 垃圾文件可能出现在**包内任意位置**(实测踩过:队友的临时目录建在 skills/ 下,
165
+ # 于是 skills/tmp_scratch_x/webtest/*.mp4 被一起打进包,体量翻倍)。
166
+ junk_suffix = (".pyc", ".pyo", ".log", ".tmp", ".orig", ".bak", ".mp4", ".wav", ".mkv", ".tgz")
167
+ junk_parts = ("__pycache__", "tmp_scratch_", "node_modules", ".pytest_cache", ".cordis")
168
+ junk = [f for f in files
169
+ if f.endswith(junk_suffix) or any(p in f for p in junk_parts)
170
+ or f.startswith(("work/", "out/", ".team/"))]
171
+ assert not junk, ("包内不该有这些文件(临时产物/日志/媒体,属于打包污染):%s"
172
+ % ", ".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"]
175
+ missing = [m for m in must if m not in files]
176
+ assert not missing, "包内缺文件:%s" % ", ".join(missing)
177
+ return "%d 个文件 / %.0f KB" % (len(files), meta.get("size", 0) / 1024.0)
178
+
179
+
180
+ # ---------------------------------------------------------------- 6 文档引用
181
+ def t_doc_refs():
182
+ md_files = [SKILL / "SKILL.md", ROOT / "README.md"] + sorted(REFS.glob("*.md"))
183
+ missing = []
184
+ for md in md_files:
185
+ if not md.is_file():
186
+ continue
187
+ txt = md.read_text(encoding="utf-8")
188
+ for m in re.finditer(r"(?:scripts|assets|references)/[A-Za-z0-9_.\-/]+", txt):
189
+ rel = m.group(0).rstrip(".,;:))")
190
+ # 文档里的相对路径有多种写法(<SKILL_DIR>/scripts/x.py、references/y.md),两种都试
191
+ cands = [SKILL / rel, SKILL / "references" / rel, ROOT / rel]
192
+ if not any(c.exists() for c in cands):
193
+ missing.append("%s 里引用 %s(不存在)" % (md.name, rel))
194
+ assert not missing, ";".join(sorted(set(missing))[:8])
195
+ return "%d 篇文档的路径引用全部存在" % sum(1 for m in md_files if m.is_file())
196
+
197
+
198
+ # ---------------------------------------------------------------- 7 体积
199
+ def t_size():
200
+ big = []
201
+ for p in [p for p in ROOT.rglob("*") if p.is_file()
202
+ and ".git" not in p.parts and "node_modules" not in p.parts
203
+ and not str(p.relative_to(ROOT)).startswith("tmp_scratch_")
204
+ and p.suffix != ".png"]:
205
+ n = p.stat().st_size
206
+ if n > MAX_FILE_BYTES:
207
+ big.append("%s %.0f KB" % (p.relative_to(ROOT), n / 1024.0))
208
+ assert not big, ";".join(big)
209
+ return "所有文本文件 < %.0f KB" % (MAX_FILE_BYTES / 1024.0)
210
+
211
+
212
+
213
+ # ---------------------------------------------------------------- 8 兼容 Python 3.9(README/THIRD_PARTY 的承诺)
214
+ def t_py39():
215
+ bad = []
216
+ for p in _py_files():
217
+ src = p.read_text(encoding="utf-8")
218
+ try:
219
+ ast.parse(src, filename=str(p), feature_version=(3, 9))
220
+ except SyntaxError as e:
221
+ bad.append("%s:%s %s" % (p.name, e.lineno, e.msg))
222
+ assert not bad, ";".join(bad)
223
+ return "%d 个 .py 通过 3.9 语法解析(运行期兼容仍需真 3.9 实测)" % len(_py_files())
224
+
225
+
226
+
227
+ # ---------------------------------------------------------------- 9 Markdown 围栏平衡
228
+ def t_md_fences():
229
+ """每篇 .md 的 \`\`\` 必须成对:围栏不平衡会让后面整篇渲染成代码块,人眼未必看得出。"""
230
+ bad = []
231
+ files = [ROOT / "README.md", ROOT / "CHANGELOG.md", ROOT / "THIRD_PARTY_NOTICES.md",
232
+ SKILL / "SKILL.md"] + sorted(REFS.glob("*.md"))
233
+ for p in files:
234
+ if not p.is_file():
235
+ 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))
240
+ assert not bad, ";".join(bad)
241
+ return "%d 篇 Markdown 围栏都成对" % len([p for p in files if p.is_file()])
242
+
243
+
244
+ # ---------------------------------------------------------------- 10 CI workflow 基本语法
245
+ def t_ci_workflow():
246
+ """GitHub Actions workflow 的「裸冒号」陷阱。
247
+
248
+ 「- name: selftest: xxx」里第二个冒号会让整份 YAML 解析失败,而 GitHub 上的表现是
249
+ 「workflow 根本没跑」——没有任何报错提示,最容易被忽略(本项目真踩过)。
250
+ 不引第三方 YAML 库(CI 里不一定装了 pyyaml),只查这一类高频错误。
251
+ """
252
+ wf = ROOT / ".github" / "workflows"
253
+ if not wf.is_dir():
254
+ return "跳过(没有 .github/workflows)"
255
+ bad = []
256
+ files = sorted(wf.glob("*.yml")) + sorted(wf.glob("*.yaml"))
257
+ for f in files:
258
+ for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
259
+ m = re.match(r"^(\s*)-\s+name:\s*(.+)$", line)
260
+ if not m:
261
+ continue
262
+ val = m.group(2).strip()
263
+ if val[:1] in ("'", '"'):
264
+ continue
265
+ if ": " in val:
266
+ bad.append("%s:%d 的 step 名含未加引号的冒号(YAML 会解析失败,整份 workflow 不跑)"
267
+ % (f.name, i))
268
+ assert not bad, ";".join(bad[:4])
269
+ return "%d 个 workflow 的 step 名没有裸冒号" % len(files)
270
+
271
+ # ---------------------------------------------------------------- 12 film.json 契约
272
+ def t_film_contract():
273
+ """用技能自带的 filmcheck.py 校验模板,并把 schema 与脚本对齐成回归。
274
+
275
+ 为什么要进 CI:film.json 是唯一内容真源,而未知键以前是**静默忽略**的
276
+ (hold 写成 holds、kind 拼错、cue 的 at 指向不存在的句子,全都"跑得通但效果没了")。
277
+ 这里不重写校验逻辑,直接调技能里的 filmcheck.py——校验规则只允许有一份。
278
+ """
279
+ import subprocess
280
+ scripts = ROOT / "skills" / "video-studio" / "scripts"
281
+ film = ROOT / "skills" / "video-studio" / "assets" / "film.json"
282
+ schema = ROOT / "schemas" / "film.schema.json"
283
+ assert schema.exists(), "缺 schemas/film.schema.json(film.json 的契约)"
284
+ try:
285
+ json.loads(schema.read_text(encoding="utf-8"))
286
+ except json.JSONDecodeError as e:
287
+ raise AssertionError("schemas/film.schema.json 不是合法 JSON:%s" % e) from e
288
+ checker = scripts / "filmcheck.py"
289
+ if not checker.exists():
290
+ raise AssertionError("缺 skills/video-studio/scripts/filmcheck.py")
291
+ p = subprocess.run([sys.executable, str(checker), str(film)],
292
+ cwd=str(scripts), capture_output=True, text=True,
293
+ encoding="utf-8", errors="replace", timeout=120)
294
+ out = (p.stdout or "") + (p.stderr or "")
295
+ assert p.returncode == 0, ("模板 film.json 没过契约校验(exit=%d):\n%s"
296
+ % (p.returncode, out.strip()[-600:]))
297
+ return "模板 film.json 通过契约校验;schema %d 字节" % len(schema.read_text(encoding="utf-8"))
298
+
299
+ def main(argv=None) -> int:
300
+ ap = argparse.ArgumentParser(description="仓库级体检(不需要 ffmpeg / 字体 / 网络)")
301
+ ap.add_argument("-v", "--verbose", action="store_true", help="打印每一节的详细证据")
302
+ a = ap.parse_args(argv)
303
+ try:
304
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
305
+ except Exception: # noqa: BLE001
306
+ pass
307
+
308
+ checks = [
309
+ ("1 Python 语法", t_syntax_py),
310
+ ("2 JS 语法", t_syntax_js),
311
+ ("3 JSON 可解析", t_json),
312
+ ("4 技能清单 frontmatter", t_manifest),
313
+ ("5 三方依赖白名单", t_deps),
314
+ ("6 打包卫生(npm pack)", t_pack),
315
+ ("7 文档路径引用", t_doc_refs),
316
+ ("8 Python 3.9 语法兼容", t_py39),
317
+ ("9 Markdown 围栏平衡", t_md_fences),
318
+ ("10 CI workflow 语法", t_ci_workflow),
319
+ ("11 单文件体积", t_size),
320
+ ("12 film.json 契约", t_film_contract),
321
+ ]
322
+ print("ci_check %s" % ROOT)
323
+ print("-" * 66)
324
+ for name, fn in checks:
325
+ ok = check(name, fn)
326
+ for ok, name, detail in _results:
327
+ print(" %s %-24s %s" % ("PASS" if ok else "FAIL", name, detail if (ok or a.verbose) else ""))
328
+ fails = [r for r in _results if not r[0]]
329
+ print("-" * 66)
330
+ print("PASS %d / FAIL %d" % (len(_results) - len(fails), len(fails)))
331
+ if fails:
332
+ print("\n失败项:")
333
+ for ok, name, detail in fails:
334
+ print(" - %s:%s" % (name, detail))
335
+ return 1 if fails else 0
336
+
337
+
338
+ if __name__ == "__main__":
339
+ sys.exit(main())