@dsh-gis/dsh-video-studio-skill 0.4.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.
- package/README.md +93 -18
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/index.js +20 -7
- package/package.json +13 -3
- package/schemas/film.schema.json +599 -0
- package/skills/video-studio/SKILL.md +252 -28
- package/skills/video-studio/assets/film.json +78 -24
- package/skills/video-studio/assets/scenes.py +1005 -42
- package/skills/video-studio/assets/web/engine.js +18 -1
- package/skills/video-studio/assets/web/film.js +29 -4
- package/skills/video-studio/assets/web/index.html +5 -0
- package/skills/video-studio/references/audio-variation.md +158 -0
- package/skills/video-studio/references/audio.md +96 -6
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/requirements.txt +13 -1
- package/skills/video-studio/scripts/filmcheck.py +725 -0
- package/skills/video-studio/scripts/filmkit.py +953 -76
- package/skills/video-studio/scripts/gates.py +509 -0
- package/skills/video-studio/scripts/qa_frames.py +109 -19
- package/skills/video-studio/scripts/render.py +942 -45
- package/skills/video-studio/scripts/scorekit.py +1887 -27
- package/skills/video-studio/scripts/selfcheck.py +648 -2
- package/skills/video-studio/scripts/sfxkit.py +132 -30
- package/skills/video-studio/scripts/stagewatch.py +405 -0
- package/skills/video-studio/scripts/stylekit.py +1012 -0
- package/skills/video-studio/scripts/voicekit.py +880 -0
- package/skills/video-studio/scripts/webengine.py +68 -14
- package/tools/audio_similarity.py +279 -0
- package/tools/ci_check.py +450 -0
- package/tools/estimate.py +657 -0
- package/tools/selftest_noscipy.py +70 -0
|
@@ -0,0 +1,450 @@
|
|
|
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 语法: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
|
|
15
|
+
(技能是"整目录拷走就能跑"的,偷偷引入一个三方依赖会在用户机器上炸)
|
|
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 校验模板(规则只留一份)
|
|
25
|
+
|
|
26
|
+
退出码:0 全过;1 有 FAIL。每一项都打印证据,不打"可能有问题"。
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import ast
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
40
|
+
SKILL = ROOT / "skills" / "video-studio"
|
|
41
|
+
SCRIPTS = SKILL / "scripts"
|
|
42
|
+
ASSETS = SKILL / "assets"
|
|
43
|
+
TOOLS = ROOT / "tools"
|
|
44
|
+
REFS = SKILL / "references"
|
|
45
|
+
|
|
46
|
+
# 技能允许的三方依赖(与 README「环境前提」一一对应;加东西必须先改文档)
|
|
47
|
+
ALLOWED_THIRD_PARTY = {"numpy", "PIL", "cv2", "scipy", "edge_tts", "playwright"}
|
|
48
|
+
# 同目录同级模块(拷走整个目录后仍能互相 import)
|
|
49
|
+
LOCAL_MODULES = {"filmkit", "sfxkit", "scorekit", "webengine", "qa_frames", "render", "selfcheck",
|
|
50
|
+
"stylekit", "voicekit", "scenes", "scenes_demo",
|
|
51
|
+
# v0.6.0:门禁账本 / 交付报告 / 签核、阶段新鲜度守卫、film.json 契约校验器
|
|
52
|
+
"gates", "stagewatch", "filmcheck"}
|
|
53
|
+
MAX_FILE_BYTES = 400 * 1024
|
|
54
|
+
|
|
55
|
+
_results: list[tuple[bool, str, str]] = []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check(name: str, fn):
|
|
59
|
+
"""跑一项检查;返回 (ok, name, detail)。任何异常都算 FAIL 并保留消息。"""
|
|
60
|
+
try:
|
|
61
|
+
detail = fn() or ""
|
|
62
|
+
_results.append((True, name, str(detail)))
|
|
63
|
+
return True
|
|
64
|
+
except AssertionError as e:
|
|
65
|
+
_results.append((False, name, str(e)))
|
|
66
|
+
return False
|
|
67
|
+
except Exception as e: # noqa: BLE001
|
|
68
|
+
_results.append((False, name, "%s: %s" % (type(e).__name__, e)))
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- 1 语法
|
|
73
|
+
def _py_files() -> list[Path]:
|
|
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"))
|
|
80
|
+
return [p for p in out if p.name != "__init__.py"]
|
|
81
|
+
|
|
82
|
+
|
|
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
|
+
|
|
91
|
+
bad = []
|
|
92
|
+
for p in _py_files():
|
|
93
|
+
src = p.read_text(encoding="utf-8")
|
|
94
|
+
try:
|
|
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))
|
|
102
|
+
except SyntaxError as e:
|
|
103
|
+
bad.append("%s:%s %s" % (p.name, e.lineno, e.msg))
|
|
104
|
+
assert not bad, ";".join(bad)
|
|
105
|
+
return "%d 个 .py 全部可编译(含 tools/,SyntaxWarning 记失败)" % len(_py_files())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def t_syntax_js():
|
|
109
|
+
node = _which("node")
|
|
110
|
+
if node is None:
|
|
111
|
+
return "跳过(没装 node)"
|
|
112
|
+
js = [p for p in ROOT.glob("*.js")] + sorted((ASSETS / "web").glob("*.js"))
|
|
113
|
+
for p in js:
|
|
114
|
+
r = subprocess.run([node, "--check", str(p)], capture_output=True, text=True)
|
|
115
|
+
assert r.returncode == 0, "%s: %s" % (p.name, (r.stderr or r.stdout).strip()[:300])
|
|
116
|
+
return "%d 个 .js 全部通过 node --check" % len(js)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _which(exe: str):
|
|
120
|
+
from shutil import which
|
|
121
|
+
return which(exe)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------- 2 JSON
|
|
125
|
+
def t_json():
|
|
126
|
+
files = [ROOT / "package.json", ROOT / "marketplace-entry.json"]
|
|
127
|
+
files += sorted((ROOT / "locale").glob("*.json"))
|
|
128
|
+
files += sorted(ASSETS.glob("*.json"))
|
|
129
|
+
for p in files:
|
|
130
|
+
try:
|
|
131
|
+
json.loads(p.read_text(encoding="utf-8"))
|
|
132
|
+
except Exception as e: # noqa: BLE001
|
|
133
|
+
raise AssertionError("%s: %s" % (p.relative_to(ROOT), e))
|
|
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)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------- 3 技能清单
|
|
148
|
+
def t_manifest():
|
|
149
|
+
md = SKILL / "SKILL.md"
|
|
150
|
+
assert md.is_file(), "缺 skills/video-studio/SKILL.md"
|
|
151
|
+
txt = md.read_text(encoding="utf-8")
|
|
152
|
+
assert txt.startswith("---"), "SKILL.md 必须以 YAML frontmatter 开头"
|
|
153
|
+
end = txt.find("\n---", 3)
|
|
154
|
+
assert end > 0, "SKILL.md frontmatter 没有闭合的 ---"
|
|
155
|
+
fm = txt[3:end]
|
|
156
|
+
name = re.search(r"^name:\s*(\S+)\s*$", fm, re.M)
|
|
157
|
+
desc = re.search(r"^description:\s*(.+)$", fm, re.M)
|
|
158
|
+
assert name, "frontmatter 缺 name"
|
|
159
|
+
assert desc and len(desc.group(1).strip()) >= 20, "frontmatter 缺 description 或太短"
|
|
160
|
+
assert name.group(1) == "video-studio", "frontmatter name=%r,应为目录名 video-studio" % name.group(1)
|
|
161
|
+
return "name=video-studio / description %d 字" % len(desc.group(1).strip())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------- 4 依赖白名单
|
|
165
|
+
def t_deps():
|
|
166
|
+
stdlib = set(sys.stdlib_module_names)
|
|
167
|
+
bad = []
|
|
168
|
+
for p in _py_files():
|
|
169
|
+
tree = ast.parse(p.read_text(encoding="utf-8"))
|
|
170
|
+
for node in ast.walk(tree):
|
|
171
|
+
names = []
|
|
172
|
+
if isinstance(node, ast.Import):
|
|
173
|
+
names = [a.name for a in node.names]
|
|
174
|
+
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
|
175
|
+
names = [node.module]
|
|
176
|
+
for n in names:
|
|
177
|
+
top = n.split(".")[0]
|
|
178
|
+
if top in stdlib or top in ALLOWED_THIRD_PARTY or top in LOCAL_MODULES:
|
|
179
|
+
continue
|
|
180
|
+
bad.append("%s: import %s(不在白名单)" % (p.name, top))
|
|
181
|
+
assert not bad, ";".join(sorted(set(bad)))
|
|
182
|
+
return "白名单:stdlib + %s" % "/".join(sorted(ALLOWED_THIRD_PARTY))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------- 5 打包卫生
|
|
186
|
+
def t_pack():
|
|
187
|
+
npm = _which("npm")
|
|
188
|
+
if npm is None:
|
|
189
|
+
return "跳过(没装 npm)"
|
|
190
|
+
r = subprocess.run([npm, "pack", "--dry-run", "--json"], cwd=str(ROOT),
|
|
191
|
+
capture_output=True, text=True, shell=(os.name == "nt"))
|
|
192
|
+
assert r.returncode == 0, "npm pack --dry-run 失败:%s" % (r.stderr or r.stdout)[:300]
|
|
193
|
+
try:
|
|
194
|
+
meta = json.loads(r.stdout)[0]
|
|
195
|
+
except Exception as e: # noqa: BLE001
|
|
196
|
+
raise AssertionError("解析 npm pack 输出失败:%s" % e)
|
|
197
|
+
files = [f["path"] for f in meta.get("files", [])]
|
|
198
|
+
# 垃圾文件可能出现在**包内任意位置**(实测踩过:队友的临时目录建在 skills/ 下,
|
|
199
|
+
# 于是 skills/tmp_scratch_x/webtest/*.mp4 被一起打进包,体量翻倍)。
|
|
200
|
+
junk_suffix = (".pyc", ".pyo", ".log", ".tmp", ".orig", ".bak", ".mp4", ".wav", ".mkv", ".tgz")
|
|
201
|
+
junk_parts = ("__pycache__", "tmp_scratch_", "node_modules", ".pytest_cache", ".cordis")
|
|
202
|
+
junk = [f for f in files
|
|
203
|
+
if f.endswith(junk_suffix) or any(p in f for p in junk_parts)
|
|
204
|
+
or f.startswith(("work/", "out/", ".team/"))]
|
|
205
|
+
assert not junk, ("包内不该有这些文件(临时产物/日志/媒体,属于打包污染):%s"
|
|
206
|
+
% ", ".join(junk[:6]))
|
|
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"]
|
|
213
|
+
missing = [m for m in must if m not in files]
|
|
214
|
+
assert not missing, "包内缺文件:%s" % ", ".join(missing)
|
|
215
|
+
return "%d 个文件 / %.0f KB" % (len(files), meta.get("size", 0) / 1024.0)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------- 6 文档引用
|
|
219
|
+
def t_doc_refs():
|
|
220
|
+
md_files = [SKILL / "SKILL.md", ROOT / "README.md"] + sorted(REFS.glob("*.md"))
|
|
221
|
+
missing = []
|
|
222
|
+
for md in md_files:
|
|
223
|
+
if not md.is_file():
|
|
224
|
+
continue
|
|
225
|
+
txt = md.read_text(encoding="utf-8")
|
|
226
|
+
for m in re.finditer(r"(?:scripts|assets|references)/[A-Za-z0-9_.\-/]+", txt):
|
|
227
|
+
rel = m.group(0).rstrip(".,;:))")
|
|
228
|
+
# 文档里的相对路径有多种写法(<SKILL_DIR>/scripts/x.py、references/y.md),两种都试
|
|
229
|
+
cands = [SKILL / rel, SKILL / "references" / rel, ROOT / rel]
|
|
230
|
+
if not any(c.exists() for c in cands):
|
|
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])
|
|
278
|
+
assert not missing, ";".join(sorted(set(missing))[:8])
|
|
279
|
+
return "%d 篇文档的路径引用 + 正文相对链接全部存在" % sum(1 for m in md_files if m.is_file())
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# ---------------------------------------------------------------- 7 体积
|
|
283
|
+
def t_size():
|
|
284
|
+
big = []
|
|
285
|
+
for p in [p for p in ROOT.rglob("*") if p.is_file()
|
|
286
|
+
and ".git" not in p.parts and "node_modules" not in p.parts
|
|
287
|
+
and not str(p.relative_to(ROOT)).startswith("tmp_scratch_")
|
|
288
|
+
and p.suffix != ".png"]:
|
|
289
|
+
n = p.stat().st_size
|
|
290
|
+
if n > MAX_FILE_BYTES:
|
|
291
|
+
big.append("%s %.0f KB" % (p.relative_to(ROOT), n / 1024.0))
|
|
292
|
+
assert not big, ";".join(big)
|
|
293
|
+
return "所有文本文件 < %.0f KB" % (MAX_FILE_BYTES / 1024.0)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------- 8 兼容 Python 3.9(README/THIRD_PARTY 的承诺)
|
|
298
|
+
def t_py39():
|
|
299
|
+
bad = []
|
|
300
|
+
for p in _py_files():
|
|
301
|
+
src = p.read_text(encoding="utf-8")
|
|
302
|
+
try:
|
|
303
|
+
ast.parse(src, filename=str(p), feature_version=(3, 9))
|
|
304
|
+
except SyntaxError as e:
|
|
305
|
+
bad.append("%s:%s %s" % (p.name, e.lineno, e.msg))
|
|
306
|
+
assert not bad, ";".join(bad)
|
|
307
|
+
return "%d 个 .py 通过 3.9 语法解析(运行期兼容仍需真 3.9 实测)" % len(_py_files())
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ---------------------------------------------------------------- 9 Markdown 围栏平衡
|
|
312
|
+
def t_md_fences():
|
|
313
|
+
"""每篇 .md 的代码围栏必须成对(按围栏状态机判,不是数出现次数)。
|
|
314
|
+
|
|
315
|
+
v0.7.1 之前这里写的是 startswith("\\`\\`\\`")——那是一个无效转义,Python 把反斜杠原样保留,
|
|
316
|
+
于是永远匹配不到真实的 ``` 行,检查恒过("看着在查,其实查不出",正是本仓库最怕的形态)。
|
|
317
|
+
现在改成状态机:开围栏记下字符与长度,闭合围栏必须同字符、长度不小于开口、且不带信息串。
|
|
318
|
+
"""
|
|
319
|
+
bad = []
|
|
320
|
+
files = [ROOT / "README.md", ROOT / "CHANGELOG.md", ROOT / "THIRD_PARTY_NOTICES.md",
|
|
321
|
+
SKILL / "SKILL.md"] + sorted(REFS.glob("*.md"))
|
|
322
|
+
fence_re = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
323
|
+
for p in files:
|
|
324
|
+
if not p.is_file():
|
|
325
|
+
continue
|
|
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))
|
|
339
|
+
assert not bad, ";".join(bad)
|
|
340
|
+
return "%d 篇 Markdown 围栏都成对(状态机判定)" % len([p for p in files if p.is_file()])
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ---------------------------------------------------------------- 10 CI workflow 基本语法
|
|
344
|
+
def t_ci_workflow():
|
|
345
|
+
"""GitHub Actions workflow 的两个高频错误。
|
|
346
|
+
|
|
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),只查这两类高频错误。
|
|
352
|
+
"""
|
|
353
|
+
wf = ROOT / ".github" / "workflows"
|
|
354
|
+
if not wf.is_dir():
|
|
355
|
+
return "跳过(没有 .github/workflows)"
|
|
356
|
+
bad = []
|
|
357
|
+
files = sorted(wf.glob("*.yml")) + sorted(wf.glob("*.yaml"))
|
|
358
|
+
for f in files:
|
|
359
|
+
for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
|
|
360
|
+
m = re.match(r"^(\s*)-\s+name:\s*(.+)$", line)
|
|
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))
|
|
366
|
+
continue
|
|
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):
|
|
371
|
+
continue
|
|
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))
|
|
379
|
+
assert not bad, ";".join(bad[:4])
|
|
380
|
+
return "%d 个 workflow:step 名无裸冒号、run 里的 python 脚本都存在" % len(files)
|
|
381
|
+
|
|
382
|
+
# ---------------------------------------------------------------- 12 film.json 契约
|
|
383
|
+
def t_film_contract():
|
|
384
|
+
"""用技能自带的 filmcheck.py 校验模板,并把 schema 与脚本对齐成回归。
|
|
385
|
+
|
|
386
|
+
为什么要进 CI:film.json 是唯一内容真源,而未知键以前是**静默忽略**的
|
|
387
|
+
(hold 写成 holds、kind 拼错、cue 的 at 指向不存在的句子,全都"跑得通但效果没了")。
|
|
388
|
+
这里不重写校验逻辑,直接调技能里的 filmcheck.py——校验规则只允许有一份。
|
|
389
|
+
"""
|
|
390
|
+
import subprocess
|
|
391
|
+
scripts = ROOT / "skills" / "video-studio" / "scripts"
|
|
392
|
+
film = ROOT / "skills" / "video-studio" / "assets" / "film.json"
|
|
393
|
+
schema = ROOT / "schemas" / "film.schema.json"
|
|
394
|
+
assert schema.exists(), "缺 schemas/film.schema.json(film.json 的契约)"
|
|
395
|
+
try:
|
|
396
|
+
json.loads(schema.read_text(encoding="utf-8"))
|
|
397
|
+
except json.JSONDecodeError as e:
|
|
398
|
+
raise AssertionError("schemas/film.schema.json 不是合法 JSON:%s" % e) from e
|
|
399
|
+
checker = scripts / "filmcheck.py"
|
|
400
|
+
if not checker.exists():
|
|
401
|
+
raise AssertionError("缺 skills/video-studio/scripts/filmcheck.py")
|
|
402
|
+
p = subprocess.run([sys.executable, str(checker), str(film)],
|
|
403
|
+
cwd=str(scripts), capture_output=True, text=True,
|
|
404
|
+
encoding="utf-8", errors="replace", timeout=120)
|
|
405
|
+
out = (p.stdout or "") + (p.stderr or "")
|
|
406
|
+
assert p.returncode == 0, ("模板 film.json 没过契约校验(exit=%d):\n%s"
|
|
407
|
+
% (p.returncode, out.strip()[-600:]))
|
|
408
|
+
return "模板 film.json 通过契约校验;schema %d 字节" % len(schema.read_text(encoding="utf-8"))
|
|
409
|
+
|
|
410
|
+
def main(argv=None) -> int:
|
|
411
|
+
ap = argparse.ArgumentParser(description="仓库级体检(不需要 ffmpeg / 字体 / 网络)")
|
|
412
|
+
ap.add_argument("-v", "--verbose", action="store_true", help="打印每一节的详细证据")
|
|
413
|
+
a = ap.parse_args(argv)
|
|
414
|
+
try:
|
|
415
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
416
|
+
except Exception: # noqa: BLE001
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
checks = [
|
|
420
|
+
("1 Python 语法", t_syntax_py),
|
|
421
|
+
("2 JS 语法", t_syntax_js),
|
|
422
|
+
("3 JSON 可解析", t_json),
|
|
423
|
+
("4 技能清单 frontmatter", t_manifest),
|
|
424
|
+
("5 三方依赖白名单", t_deps),
|
|
425
|
+
("6 打包卫生(npm pack)", t_pack),
|
|
426
|
+
("7 文档路径引用", t_doc_refs),
|
|
427
|
+
("8 Python 3.9 语法兼容", t_py39),
|
|
428
|
+
("9 Markdown 围栏平衡", t_md_fences),
|
|
429
|
+
("10 CI workflow 语法", t_ci_workflow),
|
|
430
|
+
("11 单文件体积", t_size),
|
|
431
|
+
("12 film.json 契约", t_film_contract),
|
|
432
|
+
]
|
|
433
|
+
print("ci_check %s" % ROOT)
|
|
434
|
+
print("-" * 66)
|
|
435
|
+
for name, fn in checks:
|
|
436
|
+
ok = check(name, fn)
|
|
437
|
+
for ok, name, detail in _results:
|
|
438
|
+
print(" %s %-24s %s" % ("PASS" if ok else "FAIL", name, detail if (ok or a.verbose) else ""))
|
|
439
|
+
fails = [r for r in _results if not r[0]]
|
|
440
|
+
print("-" * 66)
|
|
441
|
+
print("PASS %d / FAIL %d" % (len(_results) - len(fails), len(fails)))
|
|
442
|
+
if fails:
|
|
443
|
+
print("\n失败项:")
|
|
444
|
+
for ok, name, detail in fails:
|
|
445
|
+
print(" - %s:%s" % (name, detail))
|
|
446
|
+
return 1 if fails else 0
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
sys.exit(main())
|