@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,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())