@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.
- package/README.md +62 -12
- 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/package.json +11 -3
- package/schemas/film.schema.json +528 -0
- package/skills/video-studio/SKILL.md +166 -14
- package/skills/video-studio/assets/film.json +84 -23
- package/skills/video-studio/assets/scenes.py +892 -34
- package/skills/video-studio/references/audio.md +29 -1
- 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/requirements.txt +11 -0
- package/skills/video-studio/scripts/filmcheck.py +656 -0
- package/skills/video-studio/scripts/filmkit.py +682 -58
- package/skills/video-studio/scripts/gates.py +446 -0
- package/skills/video-studio/scripts/render.py +562 -34
- package/skills/video-studio/scripts/scorekit.py +1642 -25
- package/skills/video-studio/scripts/selfcheck.py +346 -0
- package/skills/video-studio/scripts/stagewatch.py +295 -0
- package/skills/video-studio/scripts/stylekit.py +924 -0
- package/skills/video-studio/scripts/voicekit.py +872 -0
- package/tools/ci_check.py +339 -0
- package/tools/estimate.py +578 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""filmcheck.py — film.json 契约校验器(手写递归,只用标准库)。
|
|
3
|
+
|
|
4
|
+
为什么需要它
|
|
5
|
+
------------
|
|
6
|
+
film.json 是唯一内容真源,但 render.py 的 load_script() 只拦四类硬错误
|
|
7
|
+
(缺 title/lines、lines 为空、某条没 text、id 重复、bpm 非法)。**未知键会被静默忽略**:
|
|
8
|
+
把 hold 写成 holds、kind 拼错、sfx.cues[].at 写成 time,全都"跑得通但效果没了"。
|
|
9
|
+
本脚本把这类问题变成"看得见的 WARN + 一句怎么改"。
|
|
10
|
+
|
|
11
|
+
用法
|
|
12
|
+
----
|
|
13
|
+
python filmcheck.py film.json # 不写路径:先找 cwd/film.json,再找脚本同目录
|
|
14
|
+
python filmcheck.py film.json --strict # warning 也算失败(CI / 交付门禁用)
|
|
15
|
+
python filmcheck.py film.json --json # 机读输出(stdout 只有 JSON)
|
|
16
|
+
|
|
17
|
+
退出码
|
|
18
|
+
------
|
|
19
|
+
0 没有 error(--strict 时也没有 warning)
|
|
20
|
+
1 有 error(或 --strict 下有 warning)
|
|
21
|
+
2 用法错误 / 文件不存在 / 不是合法 JSON
|
|
22
|
+
|
|
23
|
+
三件事 schema(schemas/film.schema.json)做不到,由本脚本补:
|
|
24
|
+
1. 未知键 → WARN + "是不是想写 X"(编辑距离 ≤2 / 前缀匹配 / 常见拼错别名)
|
|
25
|
+
2. 跨字段:sfx.cues[].at 引用的场景 id 必须存在于 lines;hold+语音预估与 timeline 对账
|
|
26
|
+
3. style 枚举**从 stylekit.STYLES 单向读取**(不复制一份),stylekit 不可用时降级为 WARN
|
|
27
|
+
|
|
28
|
+
注意:这里是"补强",不是替代。load_script() 仍是权威——两边都通过才说明剧本真的能跑。
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
HERE = Path(__file__).resolve().parent
|
|
39
|
+
|
|
40
|
+
# ============================================================================
|
|
41
|
+
# 与 render.py / stylekit.py / voicekit.py 对齐的硬事实(改了那边要同步这里)
|
|
42
|
+
# ============================================================================
|
|
43
|
+
|
|
44
|
+
KINDS = ("hook", "body", "quote", "outro", "emphasis", "calm") # 也是 voicekit 的语气 id
|
|
45
|
+
PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast",
|
|
46
|
+
"medium", "slow", "slower", "veryslow")
|
|
47
|
+
MUSIC_MODES = ("score", "bed", "file", "off")
|
|
48
|
+
ENGINES = ("pil", "web")
|
|
49
|
+
|
|
50
|
+
RATE_RE = re.compile(r"^\s*[+-]?\d+\s*%\s*$")
|
|
51
|
+
PITCH_RE = re.compile(r"^\s*[+-]?\d+\s*Hz\s*$")
|
|
52
|
+
ID_RE = re.compile(r"^[A-Za-z0-9_.\-]+$")
|
|
53
|
+
|
|
54
|
+
CPS = 4.2 # 每秒约 4.2 字(voicekit 实测中位口径)
|
|
55
|
+
DEFAULT_LEAD, DEFAULT_TAIL, DEFAULT_MIN_DUR = 0.62, 0.92, 3.0
|
|
56
|
+
TIMELINE_TOL = 0.5 # 估算与 timeline 差超过这个秒数就 WARN
|
|
57
|
+
|
|
58
|
+
# 常见拼错(编辑距离/前缀匹配对 time→at 无效,靠这张表兜底)
|
|
59
|
+
KEY_ALIASES = {
|
|
60
|
+
"holds": "hold", "kinds": "kind", "time": "at", "times": "at", "when": "at",
|
|
61
|
+
"start_time": "at", "at_time": "at", "duration": "dur", "length": "dur",
|
|
62
|
+
"delay": "dur", "vol": "gain", "volume": "gain", "db": "gain_db",
|
|
63
|
+
"captions": "sub", "caption": "sub",
|
|
64
|
+
"item": "items", "item_list": "items", "style_id": "style",
|
|
65
|
+
"voices": "voice", "styles": "style", "texts": "text", "lyric": "text",
|
|
66
|
+
"cue": "cues", "sfx_cues": "cues", "sound": "sfx", "bgm": "music",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# 不是 film.json 的其他产物(例如 assets/score_demo.json 是 scorekit 报告)——
|
|
70
|
+
# 交给本脚本前先认出来,直接 SKIP 而不是按剧本报"缺 title"的错误。
|
|
71
|
+
OTHER_ARTIFACT_KEYS = ("generator", "meas", "reproduce", "sections", "beats",
|
|
72
|
+
"transitions", "sr", "duration", "score")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ============================================================================
|
|
76
|
+
# 报告
|
|
77
|
+
# ============================================================================
|
|
78
|
+
|
|
79
|
+
class Report:
|
|
80
|
+
def __init__(self):
|
|
81
|
+
self.errors = []
|
|
82
|
+
self.warnings = []
|
|
83
|
+
|
|
84
|
+
def err(self, path, msg, code="invalid"):
|
|
85
|
+
self.errors.append(dict(path=path, code=code, message=msg))
|
|
86
|
+
|
|
87
|
+
def warn(self, path, msg, code="warning"):
|
|
88
|
+
self.warnings.append(dict(path=path, code=code, message=msg))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ============================================================================
|
|
92
|
+
# 小工具:类型名、编辑距离、拼错建议
|
|
93
|
+
# ============================================================================
|
|
94
|
+
|
|
95
|
+
_TYPE_TEST = {
|
|
96
|
+
"str": lambda v: isinstance(v, str),
|
|
97
|
+
"num": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
|
98
|
+
"int": lambda v: isinstance(v, int) and not isinstance(v, bool),
|
|
99
|
+
"bool": lambda v: isinstance(v, bool),
|
|
100
|
+
"list": lambda v: isinstance(v, list),
|
|
101
|
+
"dict": lambda v: isinstance(v, dict),
|
|
102
|
+
"null": lambda v: v is None,
|
|
103
|
+
}
|
|
104
|
+
_TYPE_CN = {"str": "字符串", "num": "数字", "int": "整数", "bool": "true/false",
|
|
105
|
+
"list": "数组", "dict": "对象", "null": "null"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _tname(v):
|
|
109
|
+
if v is None:
|
|
110
|
+
return "null"
|
|
111
|
+
if isinstance(v, bool):
|
|
112
|
+
return "true/false"
|
|
113
|
+
if isinstance(v, (int, float)):
|
|
114
|
+
return "数字"
|
|
115
|
+
if isinstance(v, str):
|
|
116
|
+
return "字符串"
|
|
117
|
+
if isinstance(v, list):
|
|
118
|
+
return "数组"
|
|
119
|
+
if isinstance(v, dict):
|
|
120
|
+
return "对象"
|
|
121
|
+
return type(v).__name__
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _lev(a, b):
|
|
125
|
+
"""编辑距离(键名都很短,O(nm) 足够)。"""
|
|
126
|
+
if a == b:
|
|
127
|
+
return 0
|
|
128
|
+
if len(a) < len(b):
|
|
129
|
+
a, b = b, a
|
|
130
|
+
prev = list(range(len(b) + 1))
|
|
131
|
+
for i, ca in enumerate(a, 1):
|
|
132
|
+
cur = [i]
|
|
133
|
+
for j, cb in enumerate(b, 1):
|
|
134
|
+
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
|
|
135
|
+
prev = cur
|
|
136
|
+
return prev[-1]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _suggest(key, known):
|
|
140
|
+
"""给未知键找一个"最像"的合法键:别名表 → 编辑距离 ≤2 → 前缀匹配。"""
|
|
141
|
+
low = str(key).lower()
|
|
142
|
+
for cand in known:
|
|
143
|
+
if cand.lower() == low:
|
|
144
|
+
return cand
|
|
145
|
+
alias = KEY_ALIASES.get(low)
|
|
146
|
+
if alias in known:
|
|
147
|
+
return alias
|
|
148
|
+
best, bestd = None, 99
|
|
149
|
+
for cand in known:
|
|
150
|
+
d = _lev(low, cand.lower())
|
|
151
|
+
if d < bestd:
|
|
152
|
+
best, bestd = cand, d
|
|
153
|
+
if best is not None and bestd <= 2:
|
|
154
|
+
return best
|
|
155
|
+
for cand in known:
|
|
156
|
+
cl = cand.lower()
|
|
157
|
+
if min(len(low), len(cl)) >= 2 and (low.startswith(cl) or cl.startswith(low)):
|
|
158
|
+
return cand
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _f(v, default=0.0):
|
|
163
|
+
"""宽容取数:剧本里写脏了(字符串/None/对象)也不让校验器自己崩掉。"""
|
|
164
|
+
try:
|
|
165
|
+
return float(v)
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
return float(default)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _weighted_chars(text):
|
|
171
|
+
"""等效字数:汉字 1 / 英文数字 0.6 / 标点 0.3(与工具 estimate.py 同口径)。"""
|
|
172
|
+
n = 0.0
|
|
173
|
+
for ch in str(text or ""):
|
|
174
|
+
o = ord(ch)
|
|
175
|
+
if 0x3400 <= o <= 0x4DBF or 0x4E00 <= o <= 0x9FFF or 0xF900 <= o <= 0xFAFF:
|
|
176
|
+
n += 1.0
|
|
177
|
+
elif ch.isspace():
|
|
178
|
+
continue
|
|
179
|
+
elif ch.isascii() and ch.isalnum():
|
|
180
|
+
n += 0.6
|
|
181
|
+
else:
|
|
182
|
+
n += 0.3
|
|
183
|
+
return n
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ============================================================================
|
|
187
|
+
# 契约(JSON Schema 子集的执行版;schema 文件负责文档,这里负责跑)
|
|
188
|
+
# ============================================================================
|
|
189
|
+
|
|
190
|
+
LINE_ITEM_SPEC = {
|
|
191
|
+
"t": "dict", "required": ["text"],
|
|
192
|
+
"props": {
|
|
193
|
+
"text": {"t": "str", "nonempty": True},
|
|
194
|
+
"ok": {"t": "bool"},
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
LINE_SPEC = {
|
|
199
|
+
"t": "dict", "required": ["text"],
|
|
200
|
+
"props": {
|
|
201
|
+
"id": {"t": "str", "pattern": ID_RE, "example": "l01"},
|
|
202
|
+
"kind": {"t": "str", "enum": KINDS},
|
|
203
|
+
"text": {"t": "str", "nonempty": True},
|
|
204
|
+
"sub": {"t": "str"},
|
|
205
|
+
"big": {"t": "str"},
|
|
206
|
+
"subtitle": {"t": "str"},
|
|
207
|
+
"chapter": {"t": "str"},
|
|
208
|
+
"kicker": {"t": "str"},
|
|
209
|
+
"items": {"t": "list", "item": LINE_ITEM_SPEC},
|
|
210
|
+
"hold": {"t": "num", "min": 0.0},
|
|
211
|
+
"lead": {"t": "num", "min": 0.0},
|
|
212
|
+
"tail": {"t": "num", "min": 0.0},
|
|
213
|
+
"persona": {"t": "str"},
|
|
214
|
+
"tone": {"t": "str", "enum": KINDS},
|
|
215
|
+
"voice": {"t": "str"},
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# sfxkit 配方专属参数(references/sfx-design.md §7 列出的主要参数):
|
|
220
|
+
# 写在这里只为了"不被当成拼错",一律 {'t': 'any'} 原样透传给配方,不做类型校验。
|
|
221
|
+
CUE_RECIPE_PARAMS = ("pitch", "kind", "direction", "tone", "end", "brightness", "metal",
|
|
222
|
+
"tail", "start_hz", "end_hz", "rate", "density", "accel", "stop",
|
|
223
|
+
"shift", "depth", "strength", "gustiness", "whistle", "env",
|
|
224
|
+
"intensity", "surface", "crackle")
|
|
225
|
+
|
|
226
|
+
CUE_SPEC = {
|
|
227
|
+
"t": "dict", "required": ["at", "type"], "passthrough": True,
|
|
228
|
+
"props": dict({
|
|
229
|
+
"at": {"t": ("num", "str")},
|
|
230
|
+
"type": {"t": "str", "nonempty": True},
|
|
231
|
+
"dur": {"t": "num", "min": 0.0},
|
|
232
|
+
"gain": {"t": "num"},
|
|
233
|
+
"size": {"t": "num"},
|
|
234
|
+
"pan": {"t": "num"},
|
|
235
|
+
"seed": {"t": "int"},
|
|
236
|
+
"visual_at": {"t": ("num", "str")},
|
|
237
|
+
"visual_t": {"t": ("num", "str")},
|
|
238
|
+
}, **{k: {"t": "any"} for k in CUE_RECIPE_PARAMS}),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
SCRIPT_SPEC = {
|
|
242
|
+
"t": "dict", "required": ["title", "lines"],
|
|
243
|
+
"props": {
|
|
244
|
+
"title": {"t": "str", "nonempty_warn": True},
|
|
245
|
+
"end_text": {"t": "str"},
|
|
246
|
+
"style": {"t": "str", "enum_dynamic": "style"},
|
|
247
|
+
"engine": {"t": "str", "enum": ENGINES},
|
|
248
|
+
"voice": {"t": ("str", "dict"), "open": "dict"},
|
|
249
|
+
"rate": {"t": "str", "pattern": RATE_RE, "example": "+6%"},
|
|
250
|
+
"pitch": {"t": "str", "pattern": PITCH_RE, "example": "+0Hz"},
|
|
251
|
+
"bpm": {"t": ("num", "str"), "auto": True, "auto_only": True, "positive": True},
|
|
252
|
+
"seed": {"t": "int", "min": 0},
|
|
253
|
+
"crf": {"t": "int", "min": 1, "max": 51},
|
|
254
|
+
"preset": {"t": "str", "enum": PRESETS},
|
|
255
|
+
"lines": {"t": "list", "item": LINE_SPEC, "min_items": 1},
|
|
256
|
+
"lead": {"t": "num", "min": 0.0},
|
|
257
|
+
"tail": {"t": "num", "min": 0.0},
|
|
258
|
+
"min_dur": {"t": "num", "min": 0.0},
|
|
259
|
+
"hold": {"t": "num", "min": 0.0},
|
|
260
|
+
"max_dur": {"t": "num", "min": 0.0},
|
|
261
|
+
"bar": {"t": "num", "min": 0.0},
|
|
262
|
+
"sub_max_hold": {"t": ("num", "null")},
|
|
263
|
+
"trim_silence": {"t": "bool"},
|
|
264
|
+
"trim_db": {"t": "num"},
|
|
265
|
+
"tts_workers": {"t": "int", "min": 1},
|
|
266
|
+
"chunk_frames": {"t": "int", "min": 0},
|
|
267
|
+
"reencode_video": {"t": "bool"},
|
|
268
|
+
"voice_gain": {"t": "num"},
|
|
269
|
+
"whoosh": {"t": "bool"},
|
|
270
|
+
"whoosh_amp": {"t": "num", "min": 0.0},
|
|
271
|
+
"tail_map": {"t": "dict", "open": True},
|
|
272
|
+
"pacing": {"t": "dict", "props": {
|
|
273
|
+
"lead": {"t": "num", "min": 0.0}, "tail": {"t": "num", "min": 0.0},
|
|
274
|
+
"min_dur": {"t": "num", "min": 0.0}, "hold": {"t": "num", "min": 0.0},
|
|
275
|
+
"item_stagger": {"t": "num", "min": 0.0}}},
|
|
276
|
+
"visual": {"t": "dict", "props": {
|
|
277
|
+
"theme": {"t": "str"}, "layout": {"t": "str"},
|
|
278
|
+
"theme_overrides": {"t": "dict", "open": True}}},
|
|
279
|
+
"music": {"t": "dict", "props": {
|
|
280
|
+
"enabled": {"t": "bool"},
|
|
281
|
+
"mode": {"t": "str", "enum": MUSIC_MODES},
|
|
282
|
+
"style": {"t": "str"},
|
|
283
|
+
"mood": {"t": "str"},
|
|
284
|
+
"bpm": {"t": ("num", "str"), "auto": True, "auto_only": True, "positive": True},
|
|
285
|
+
"key": {"t": ("num", "str"), "auto": True},
|
|
286
|
+
"seed": {"t": "int", "min": 0},
|
|
287
|
+
"seed_from_content": {"t": "bool"},
|
|
288
|
+
"gain_db": {"t": "num"}, "duck_db": {"t": "num"},
|
|
289
|
+
"seg": {"t": "num", "min": 0.0}, "amp": {"t": "num", "min": 0.0},
|
|
290
|
+
"gain": {"t": "num", "min": 0.0}, "xfade": {"t": "num", "min": 0.0},
|
|
291
|
+
"chords": {"t": "list", "item": {"t": "list", "item": {"t": "num"}}},
|
|
292
|
+
"sections": {"t": "list", "item": {"t": "dict", "open": True}}}},
|
|
293
|
+
"sfx": {"t": "dict", "props": {
|
|
294
|
+
"enabled": {"t": "bool"},
|
|
295
|
+
"gain_db": {"t": "num"},
|
|
296
|
+
"mix_gain_db": {"t": "num"},
|
|
297
|
+
"cues": {"t": "list", "item": CUE_SPEC}}},
|
|
298
|
+
"web": {"t": "dict", "props": {
|
|
299
|
+
"entry": {"t": "str"},
|
|
300
|
+
"chunk_s": {"t": "num", "min": 0.0}}},
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ============================================================================
|
|
306
|
+
# 递归校验
|
|
307
|
+
# ============================================================================
|
|
308
|
+
|
|
309
|
+
def _types_cn(types):
|
|
310
|
+
return " 或 ".join(_TYPE_CN.get(t, t) for t in types)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _check(val, rule, path, rep, ctx):
|
|
314
|
+
types = rule.get("t", "any")
|
|
315
|
+
if isinstance(types, str):
|
|
316
|
+
types = (types,)
|
|
317
|
+
if "any" in types:
|
|
318
|
+
return # 配方专属参数:只认键名,不做类型校验
|
|
319
|
+
if val is None:
|
|
320
|
+
if "null" in types:
|
|
321
|
+
return
|
|
322
|
+
rep.err(path, "不能是 null:删掉这个键,或填 %s" % _types_cn(types), "null")
|
|
323
|
+
return
|
|
324
|
+
if isinstance(val, str) and rule.get("auto") and val.strip().lower() in ("auto", ""):
|
|
325
|
+
return # "" 与 "auto" 同义(load_script 也这么认)
|
|
326
|
+
if isinstance(val, str) and rule.get("auto_only") and val.strip():
|
|
327
|
+
rep.err(path, "字符串只能写 \"auto\"(bpm 要么是大于 0 的数字,要么是 \"auto\";"
|
|
328
|
+
"收到 %r,render.py 会直接报错)" % val, "bad_auto")
|
|
329
|
+
return
|
|
330
|
+
if not any(_TYPE_TEST[t](val) for t in types if t in _TYPE_TEST):
|
|
331
|
+
rep.err(path, "类型错误:应为 %s,实际是 %s(%r)"
|
|
332
|
+
% (_types_cn(types), _tname(val), val), "bad_type")
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
enum = rule.get("enum")
|
|
336
|
+
if rule.get("enum_dynamic") == "style":
|
|
337
|
+
styles = ctx.get("styles")
|
|
338
|
+
if styles is None:
|
|
339
|
+
if not ctx.get("style_warned"):
|
|
340
|
+
ctx["style_warned"] = True
|
|
341
|
+
rep.warn(path, "stylekit.STYLES 不可用(找不到 %s/stylekit.py),"
|
|
342
|
+
"跳过 style 枚举校验——只提示,不判错" % str(HERE), "stylekit_missing")
|
|
343
|
+
else:
|
|
344
|
+
enum = ["auto"] + list(styles)
|
|
345
|
+
if enum and isinstance(val, str) and val not in enum:
|
|
346
|
+
near = _suggest(val, [e for e in enum if isinstance(e, str)])
|
|
347
|
+
hint = (";是不是想写 %r?" % near) if near else ""
|
|
348
|
+
rep.err(path, "枚举错误:%r 不是可用值(可用:%s)%s"
|
|
349
|
+
% (val, " / ".join(str(e) for e in enum), hint), "bad_enum")
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
353
|
+
if rule.get("positive") and val <= 0:
|
|
354
|
+
rep.err(path, "必须大于 0(收到 %r;bpm 这类字段也可以写 \"auto\")" % val, "bad_range")
|
|
355
|
+
if rule.get("min") is not None and val < rule["min"]:
|
|
356
|
+
rep.err(path, "不能小于 %s(收到 %r)" % (rule["min"], val), "bad_range")
|
|
357
|
+
if rule.get("max") is not None and val > rule["max"]:
|
|
358
|
+
rep.err(path, "不能大于 %s(收到 %r)" % (rule["max"], val), "bad_range")
|
|
359
|
+
|
|
360
|
+
if isinstance(val, str):
|
|
361
|
+
if rule.get("nonempty") and not val.strip():
|
|
362
|
+
rep.err(path, "不能是空字符串(render.py 会把这条当缺字段)", "empty")
|
|
363
|
+
if rule.get("nonempty_warn") and not val.strip():
|
|
364
|
+
rep.warn(path, "是空字符串:顶栏/片名会是空的(不影响跑通,但成片会露馅)", "empty")
|
|
365
|
+
pat = rule.get("pattern")
|
|
366
|
+
if pat is not None and not pat.match(val):
|
|
367
|
+
rep.err(path, "格式不对:%r(要匹配 %s;例如 %s)"
|
|
368
|
+
% (val, pat.pattern, rule.get("example", "")), "bad_pattern")
|
|
369
|
+
|
|
370
|
+
if isinstance(val, list):
|
|
371
|
+
if rule.get("min_items") and len(val) < rule["min_items"]:
|
|
372
|
+
rep.err(path, "至少要 %d 项(当前 %d 项)" % (rule["min_items"], len(val)), "too_few")
|
|
373
|
+
item = rule.get("item")
|
|
374
|
+
if item:
|
|
375
|
+
for i, it in enumerate(val):
|
|
376
|
+
_check(it, item, "%s[%d]" % (path, i), rep, ctx)
|
|
377
|
+
|
|
378
|
+
if isinstance(val, dict):
|
|
379
|
+
if rule.get("open"):
|
|
380
|
+
return # 自由格式字典(tail_map/theme_overrides…):不查未知键
|
|
381
|
+
props = rule.get("props") or {}
|
|
382
|
+
for req in rule.get("required") or []:
|
|
383
|
+
if req not in val:
|
|
384
|
+
rep.err("%s.%s" % (path, req), "缺少必填键 %r(%s)"
|
|
385
|
+
% (req, "render.py 会直接拒绝这份剧本"), "missing")
|
|
386
|
+
for k, v in val.items():
|
|
387
|
+
if k in props:
|
|
388
|
+
_check(v, props[k], "%s.%s" % (path, k), rep, ctx)
|
|
389
|
+
elif str(k).startswith("_"):
|
|
390
|
+
continue # _comment 之类的扩展键:不查不报
|
|
391
|
+
else:
|
|
392
|
+
near = _suggest(k, list(props))
|
|
393
|
+
if near:
|
|
394
|
+
rep.warn("%s.%s" % (path, k),
|
|
395
|
+
"未知键 %r —— 是不是想写 %r?(render.py 不认识它会静默忽略:"
|
|
396
|
+
"跑得通,但这个设置不生效)" % (k, near), "unknown_key")
|
|
397
|
+
elif rule.get("passthrough"):
|
|
398
|
+
rep.warn("%s.%s" % (path, k),
|
|
399
|
+
"未知参数 %r:会原样透传给 sfxkit 配方(拼错则静默无效);"
|
|
400
|
+
"常用的是 at/type/dur/gain/size/pan/seed,见 references/sfx-design.md"
|
|
401
|
+
% k, "unknown_cue_param")
|
|
402
|
+
else:
|
|
403
|
+
rep.warn("%s.%s" % (path, k),
|
|
404
|
+
"未知键 %r:render.py 不读它,会被静默忽略"
|
|
405
|
+
"(确实要放扩展信息就写成 _%s 开头)" % (k, k), "unknown_key")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ============================================================================
|
|
409
|
+
# 跨字段校验
|
|
410
|
+
# ============================================================================
|
|
411
|
+
|
|
412
|
+
def _cue_ref(at):
|
|
413
|
+
"""复刻 filmkit.cue_time 的解析:返回 ("abs"|"id"|"bad", 值或错误说明)。"""
|
|
414
|
+
if isinstance(at, (int, float)) and not isinstance(at, bool):
|
|
415
|
+
return "abs", float(at)
|
|
416
|
+
s = str(at or "").strip()
|
|
417
|
+
if not s:
|
|
418
|
+
return "bad", "at 是空字符串(写 12.5 / abs:12.5 / l03 / l03+0.35)"
|
|
419
|
+
if s.startswith("abs:"):
|
|
420
|
+
try:
|
|
421
|
+
return "abs", float(s[4:])
|
|
422
|
+
except ValueError:
|
|
423
|
+
return "bad", "abs: 后面不是数字(写 abs:12.5)"
|
|
424
|
+
try:
|
|
425
|
+
return "abs", float(s)
|
|
426
|
+
except ValueError:
|
|
427
|
+
pass
|
|
428
|
+
cut = len(s)
|
|
429
|
+
for i, ch in enumerate(s):
|
|
430
|
+
if ch in "+-" and i > 0:
|
|
431
|
+
cut = i
|
|
432
|
+
break
|
|
433
|
+
sid, off = s[:cut], s[cut:]
|
|
434
|
+
if off:
|
|
435
|
+
try:
|
|
436
|
+
float(off)
|
|
437
|
+
except ValueError:
|
|
438
|
+
return "bad", "偏移 %r 不是数字(写 l03+0.35 或 l03-0.2)" % off
|
|
439
|
+
return "id", sid
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _find_timeline(script_path):
|
|
443
|
+
cands = [script_path.parent / "work" / "timeline.json",
|
|
444
|
+
Path.cwd() / "work" / "timeline.json",
|
|
445
|
+
script_path.parent / "timeline.json"]
|
|
446
|
+
for c in cands:
|
|
447
|
+
if c.is_file():
|
|
448
|
+
return c
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def check_cross(script, script_path, rep, ctx):
|
|
453
|
+
"""schema 表达不了的跨字段约束:id 引用、hold 与 timeline 对账。"""
|
|
454
|
+
lines = [ln for ln in (script.get("lines") or []) if isinstance(ln, dict)]
|
|
455
|
+
|
|
456
|
+
# ---- lines id:缺失 / 重复 ----
|
|
457
|
+
ids = []
|
|
458
|
+
for i, ln in enumerate(lines):
|
|
459
|
+
lid = ln.get("id")
|
|
460
|
+
if lid is None or not str(lid).strip():
|
|
461
|
+
rep.warn("lines[%d].id" % i, "没有 id(render.py 会自动生成 l%02d);"
|
|
462
|
+
"要写 sfx.cues 就显式给 id" % (i + 1), "missing_id")
|
|
463
|
+
continue
|
|
464
|
+
lid = str(lid)
|
|
465
|
+
if lid in ids:
|
|
466
|
+
rep.err("lines[%d].id" % i, "id 重复:%r(TTS 缓存与 cue 会串,后一条会覆盖前一条)"
|
|
467
|
+
% lid, "dup_id")
|
|
468
|
+
ids.append(lid)
|
|
469
|
+
idset = set(ids)
|
|
470
|
+
|
|
471
|
+
# ---- sfx.cues[].at 引用的场景 id 必须在 lines 里 ----
|
|
472
|
+
sfx = script.get("sfx") if isinstance(script.get("sfx"), dict) else {}
|
|
473
|
+
cues = sfx.get("cues") if isinstance(sfx.get("cues"), list) else []
|
|
474
|
+
for i, cue in enumerate(cues):
|
|
475
|
+
if not isinstance(cue, dict):
|
|
476
|
+
continue
|
|
477
|
+
at = cue.get("at", cue.get("t"))
|
|
478
|
+
if at is None:
|
|
479
|
+
continue # 缺 at 已由必填检查报过
|
|
480
|
+
kind, val = _cue_ref(at)
|
|
481
|
+
if kind == "bad":
|
|
482
|
+
rep.err("sfx.cues[%d].at" % i, "at 写法不对:%s" % val, "bad_cue_at")
|
|
483
|
+
elif kind == "id" and val not in idset:
|
|
484
|
+
rep.err("sfx.cues[%d].at" % i,
|
|
485
|
+
"at=%r 引用的场景 id %r 不在 lines 里(现有:%s)。"
|
|
486
|
+
"改过台词 id 就要同步这里,否则这条音效会被 CueBus 静默跳过"
|
|
487
|
+
% (at, val, ", ".join(ids[:8]) or "lines 里没有 id"), "cue_id_missing")
|
|
488
|
+
|
|
489
|
+
# ---- hold 之和 + 语音预估 vs work/timeline.json(差 > 0.5 s 就 WARN)----
|
|
490
|
+
tl_path = _find_timeline(script_path)
|
|
491
|
+
ctx["timeline"] = str(tl_path) if tl_path else None
|
|
492
|
+
if tl_path is None:
|
|
493
|
+
return
|
|
494
|
+
try:
|
|
495
|
+
tl = json.loads(tl_path.read_text(encoding="utf-8"))
|
|
496
|
+
total = float(tl["total"])
|
|
497
|
+
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError) as e:
|
|
498
|
+
rep.warn(str(tl_path), "读不了 timeline(%s),跳过时长对账" % e, "timeline_unreadable")
|
|
499
|
+
return
|
|
500
|
+
lead = _f(script.get("lead", DEFAULT_LEAD), DEFAULT_LEAD) or DEFAULT_LEAD
|
|
501
|
+
tail = _f(script.get("tail", DEFAULT_TAIL), DEFAULT_TAIL) or DEFAULT_TAIL
|
|
502
|
+
min_dur = _f(script.get("min_dur", DEFAULT_MIN_DUR), DEFAULT_MIN_DUR) or DEFAULT_MIN_DUR
|
|
503
|
+
ghold = _f(script.get("hold", 0.0))
|
|
504
|
+
holds, est, speech = 0.0, 0.0, 0.0
|
|
505
|
+
for ln in lines:
|
|
506
|
+
voice = _weighted_chars(ln.get("text")) / CPS
|
|
507
|
+
hold = _f(ln.get("hold", ghold))
|
|
508
|
+
holds += hold
|
|
509
|
+
speech += voice
|
|
510
|
+
est += max(min_dur, lead + voice + tail + hold)
|
|
511
|
+
if abs(est - total) > TIMELINE_TOL:
|
|
512
|
+
rep.warn(str(tl_path),
|
|
513
|
+
"剧本 hold 合计 %.2f s + 语音预估 %.2f s(含 lead/tail)≈ 全片 %.2f s,"
|
|
514
|
+
"与 timeline 的 total=%.2f s 差 %.2f s > %.1f s。"
|
|
515
|
+
"改过台词 / hold / 节奏参数就重跑 --stage layout(别拿旧时间轴直接渲)"
|
|
516
|
+
% (holds, speech, est, total, abs(est - total), TIMELINE_TOL), "timeline_drift")
|
|
517
|
+
tl_ids = [str(s.get("id")) for s in (tl.get("scenes") or []) if isinstance(s, dict)]
|
|
518
|
+
if idset and tl_ids and set(tl_ids) != idset:
|
|
519
|
+
miss = sorted(idset - set(tl_ids))
|
|
520
|
+
extra = sorted(set(tl_ids) - idset)
|
|
521
|
+
rep.warn(str(tl_path),
|
|
522
|
+
"timeline 的场景 id 与 lines 对不上(lines 有但 timeline 没有:%s;"
|
|
523
|
+
"timeline 有但 lines 没有:%s):增删过台词就重跑 --stage layout"
|
|
524
|
+
% (miss[:6] or "无", extra[:6] or "无"), "timeline_ids")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ============================================================================
|
|
528
|
+
# 入口
|
|
529
|
+
# ============================================================================
|
|
530
|
+
|
|
531
|
+
def _setup_stdio():
|
|
532
|
+
"""被管道/文件重定向时切 UTF-8(与 render.py 同一口径,避免 Windows 控制台乱码)。"""
|
|
533
|
+
for stream in (sys.stdout, sys.stderr):
|
|
534
|
+
try:
|
|
535
|
+
if stream is not None and not stream.isatty():
|
|
536
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
537
|
+
except Exception: # noqa: BLE001
|
|
538
|
+
pass
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _fail(msg, as_json, code=2):
|
|
542
|
+
if as_json:
|
|
543
|
+
sys.stdout.write(json.dumps({"ok": False, "error": msg}, ensure_ascii=False) + "\n")
|
|
544
|
+
else:
|
|
545
|
+
sys.stderr.write("[filmcheck] %s\n" % msg)
|
|
546
|
+
sys.stderr.write(" 用法:python filmcheck.py <film.json> [--strict] [--json]\n")
|
|
547
|
+
raise SystemExit(code)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _load_styles():
|
|
551
|
+
"""从 stylekit.STYLES 单向读取风格 id;拿不到返回 None(调用方降级成只 WARN)。"""
|
|
552
|
+
try:
|
|
553
|
+
if str(HERE) not in sys.path:
|
|
554
|
+
sys.path.insert(0, str(HERE))
|
|
555
|
+
import stylekit # noqa: F401 (只读枚举,不复制)
|
|
556
|
+
# 优先用公开的 STYLE_IDS(8 套),没有就退回 STYLES 的键(含内部 legacy)
|
|
557
|
+
ids = list(getattr(stylekit, "STYLE_IDS", ()) or getattr(stylekit, "STYLES", {}).keys())
|
|
558
|
+
return ids or None
|
|
559
|
+
except Exception: # noqa: BLE001
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _classify(data):
|
|
564
|
+
"""film = 像剧本;artifact = 其他产物(配乐报告等),跳过;unknown = 判不了。"""
|
|
565
|
+
if not isinstance(data, dict):
|
|
566
|
+
return "unknown"
|
|
567
|
+
if "lines" in data or "title" in data:
|
|
568
|
+
return "film"
|
|
569
|
+
if any(k in data for k in OTHER_ARTIFACT_KEYS):
|
|
570
|
+
return "artifact"
|
|
571
|
+
return "unknown"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _default_path():
|
|
575
|
+
for c in (Path.cwd() / "film.json", HERE / "film.json"):
|
|
576
|
+
if c.is_file():
|
|
577
|
+
return c
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def main(argv=None):
|
|
582
|
+
_setup_stdio()
|
|
583
|
+
ap = argparse.ArgumentParser(
|
|
584
|
+
description="film.json 契约校验器(未知键 WARN + 枚举/范围 ERROR + 跨字段对账)")
|
|
585
|
+
ap.add_argument("path", nargs="?", default="", help="film.json 路径(缺省找 cwd/film.json)")
|
|
586
|
+
ap.add_argument("--strict", action="store_true", help="warning 也算失败(退出码 1)")
|
|
587
|
+
ap.add_argument("--json", action="store_true", dest="as_json", help="机读输出(stdout 只有 JSON)")
|
|
588
|
+
args = ap.parse_args(argv)
|
|
589
|
+
|
|
590
|
+
path = Path(args.path).expanduser() if args.path else _default_path()
|
|
591
|
+
if path is None:
|
|
592
|
+
_fail("找不到 film.json:先 cd 到项目目录,或显式传路径", args.as_json)
|
|
593
|
+
if not path.exists():
|
|
594
|
+
_fail("文件不存在:%s" % path, args.as_json)
|
|
595
|
+
try:
|
|
596
|
+
raw = path.read_text(encoding="utf-8")
|
|
597
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
598
|
+
_fail("读不了 %s:%s(用 UTF-8 另存)" % (path, e), args.as_json)
|
|
599
|
+
try:
|
|
600
|
+
data = json.loads(raw)
|
|
601
|
+
except json.JSONDecodeError as e:
|
|
602
|
+
_fail("%s 不是合法 JSON:第 %d 行第 %d 列 %s(先跑 python -m json.tool 看一眼)"
|
|
603
|
+
% (path, e.lineno, e.colno, e.msg), args.as_json)
|
|
604
|
+
|
|
605
|
+
kind = _classify(data)
|
|
606
|
+
rep = Report()
|
|
607
|
+
ctx = {"styles": _load_styles(), "style_warned": False, "timeline": None}
|
|
608
|
+
skipped = False
|
|
609
|
+
if kind == "artifact":
|
|
610
|
+
skipped = True
|
|
611
|
+
elif kind == "unknown":
|
|
612
|
+
rep.err("$", "顶层既没有 title 也没有 lines,无法按 film.json 校验"
|
|
613
|
+
"(render.py 会直接报“缺少字段”)", "not_film")
|
|
614
|
+
else:
|
|
615
|
+
_check(data, SCRIPT_SPEC, "$", rep, ctx)
|
|
616
|
+
check_cross(data, path, rep, ctx)
|
|
617
|
+
|
|
618
|
+
failed = bool(rep.errors) or (bool(args.strict) and bool(rep.warnings))
|
|
619
|
+
payload = {
|
|
620
|
+
"ok": not failed,
|
|
621
|
+
"file": str(path),
|
|
622
|
+
"strict": bool(args.strict),
|
|
623
|
+
"skipped": skipped,
|
|
624
|
+
"errors": rep.errors,
|
|
625
|
+
"warnings": rep.warnings,
|
|
626
|
+
"counts": {"errors": len(rep.errors), "warnings": len(rep.warnings)},
|
|
627
|
+
"style_ids": ctx.get("styles"),
|
|
628
|
+
"timeline": ctx.get("timeline"),
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if args.as_json:
|
|
632
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
633
|
+
else:
|
|
634
|
+
print("[filmcheck] %s" % path)
|
|
635
|
+
if skipped:
|
|
636
|
+
print(" [SKIP] 这不是 film.json(顶层没有 title/lines,像是配乐报告或其他产物)"
|
|
637
|
+
"——跳过契约校验。")
|
|
638
|
+
for e in rep.errors:
|
|
639
|
+
print(" [ERROR] %s: %s" % (e["path"], e["message"]))
|
|
640
|
+
for w in rep.warnings:
|
|
641
|
+
print(" [WARN] %s: %s" % (w["path"], w["message"]))
|
|
642
|
+
if skipped:
|
|
643
|
+
print(" 结论:跳过(0 error / 0 warning)")
|
|
644
|
+
elif not rep.errors and not rep.warnings:
|
|
645
|
+
print(" 结论:契约通过(0 error / 0 warning)")
|
|
646
|
+
else:
|
|
647
|
+
tail = "(--strict:warning 也算失败)" if args.strict else ""
|
|
648
|
+
print(" 结论:%d error / %d warning%s%s"
|
|
649
|
+
% (len(rep.errors), len(rep.warnings), " → 失败" if failed else " → 通过", tail))
|
|
650
|
+
if rep.errors:
|
|
651
|
+
print(" 先按上面 [ERROR] 逐条改;[WARN] 里未知键的,正是“改了没生效”的那类坑。")
|
|
652
|
+
return 0 if not failed else 1
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
if __name__ == "__main__":
|
|
656
|
+
sys.exit(main())
|