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