@dsh-gis/dsh-video-studio-skill 0.6.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 (32) hide show
  1. package/README.md +41 -16
  2. package/index.js +20 -7
  3. package/package.json +4 -2
  4. package/schemas/film.schema.json +75 -4
  5. package/skills/video-studio/SKILL.md +95 -23
  6. package/skills/video-studio/assets/film.json +7 -14
  7. package/skills/video-studio/assets/scenes.py +115 -10
  8. package/skills/video-studio/assets/web/engine.js +18 -1
  9. package/skills/video-studio/assets/web/film.js +29 -4
  10. package/skills/video-studio/assets/web/index.html +5 -0
  11. package/skills/video-studio/references/audio-variation.md +158 -0
  12. package/skills/video-studio/references/audio.md +67 -5
  13. package/skills/video-studio/references/change-impact.md +1 -1
  14. package/skills/video-studio/references/quality-gates.md +1 -1
  15. package/skills/video-studio/references/web-engine.md +1 -1
  16. package/skills/video-studio/requirements.txt +2 -1
  17. package/skills/video-studio/scripts/filmcheck.py +81 -12
  18. package/skills/video-studio/scripts/filmkit.py +271 -18
  19. package/skills/video-studio/scripts/gates.py +75 -12
  20. package/skills/video-studio/scripts/qa_frames.py +109 -19
  21. package/skills/video-studio/scripts/render.py +420 -51
  22. package/skills/video-studio/scripts/scorekit.py +289 -46
  23. package/skills/video-studio/scripts/selfcheck.py +302 -2
  24. package/skills/video-studio/scripts/sfxkit.py +132 -30
  25. package/skills/video-studio/scripts/stagewatch.py +141 -31
  26. package/skills/video-studio/scripts/stylekit.py +94 -6
  27. package/skills/video-studio/scripts/voicekit.py +8 -0
  28. package/skills/video-studio/scripts/webengine.py +68 -14
  29. package/tools/audio_similarity.py +279 -0
  30. package/tools/ci_check.py +145 -34
  31. package/tools/estimate.py +133 -54
  32. package/tools/selftest_noscipy.py +70 -0
@@ -37,8 +37,15 @@ BIG_BYTES = 8 << 20 # > 8 MB 走 size+mtime 快路径
37
37
  # 早期版本只跟踪"stage 之间的产物",于是"改了一句台词但没重跑 tts"查不出任何过时——
38
38
  # 而那正是最需要它报警的场景(旧 tts 配新剧本 = 配音和字幕全错位)。
39
39
  STAGE_IO = {
40
- "style": dict(sources=["script"], inputs=[], outputs=["style.json", "voice_plan.json"]),
41
- "tts": dict(sources=["script"], inputs=["style.json"], outputs=["tts.json"]),
40
+ # voice_plan.json 是 **tts** 阶段写的(render.py 的 st_tts:非 legacy 才写),
41
+ # 不是 style 写的——v0.7.2 前它被挂在 style.outputs 下,于是单跑 `--stage style`
42
+ # 之后每次重跑都报「产物 voice_plan.json 不见了」(其实从来没写过),
43
+ # 开了 --strict-stale 就直接失败,而 --stage style 是全新项目的第一条生产命令。
44
+ # legacy 剧本本来就不写这个文件:check() 只在「上次记录的是具体指纹、现在没了」
45
+ # 时才报 output-missing,所以 legacy 下记为 missing 不会被当成永久过时。
46
+ "style": dict(sources=["script"], inputs=[], outputs=["style.json"]),
47
+ "tts": dict(sources=["script"], inputs=["style.json"],
48
+ outputs=["tts.json", "voice_plan.json"]),
42
49
  "layout": dict(sources=["script"],
43
50
  inputs=["tts.json", "style.json", "voice_plan.json"],
44
51
  outputs=["timeline.json", "sub.srt", "sub.ass"]),
@@ -54,8 +61,11 @@ STAGE_IO = {
54
61
  inputs=["timeline.json", "audio.wav", "cues.json"],
55
62
  outputs=["silent.mp4"]),
56
63
  "mux": dict(sources=["script"], inputs=["silent.mp4", "audio.wav"], outputs=[]),
64
+ # gates.json 不列进 qa 的产物:它由 _finish_gates 在 style/frames/mux/qa 任何一次收尾时
65
+ # 都会写,不能当"qa 跑过"的证据(否则删掉 qa 记录后它还在,会误判 no-manifest)。
66
+ # qa/sheet.png(联系表)只在 --stage qa 里生成,才是真凭证。
57
67
  "qa": dict(sources=["script", "scenes"], inputs=[],
58
- outputs=["gates.json", "qa/sheet.png"]),
68
+ outputs=["qa/sheet.png", "qa/deep.json"]),
59
69
  "stills": dict(sources=["script", "scenes"], inputs=["timeline.json", "style.json"],
60
70
  outputs=[]),
61
71
  }
@@ -123,6 +133,19 @@ def save(work, d):
123
133
  return p
124
134
 
125
135
 
136
+ def _producer_of(art):
137
+ """work/ 里的产物文件是哪个 stage 写的(由 STAGE_IO 反查,找不到返回 None)。"""
138
+ for st, io in STAGE_IO.items():
139
+ if art in (io.get("outputs") or []):
140
+ return st
141
+ return None
142
+
143
+
144
+ # 硬类型:这些永远参与 --strict-stale 判定(产物丢了 / 源头改了 / 上游自己就过时)
145
+ HARD_KINDS = ("source-changed", "output-missing", "no-manifest", "input-stale")
146
+ SOFT_KINDS = ("input-pending",)
147
+
148
+
126
149
  def _src_fp(kind, sources):
127
150
  """源头输入指纹:script=剧本(film.json)、scenes=场景代码(scenes.py)。"""
128
151
  p = (sources or {}).get(kind)
@@ -146,32 +169,64 @@ def snapshot(work, stage, sources=None):
146
169
  return d["stages"][str(stage)]
147
170
 
148
171
 
149
- def check(work, stage, sources=None):
172
+ def _upstream_hard(work, stage, sources=None, _seen=None):
173
+ """产出某输入的 stage 是否有**硬**问题(源头变了 / 它的输入也变了 / 产物丢了)。
174
+
175
+ 只取硬类型:软化过的 input-pending 不算(那只说明"它也会重跑")。
176
+ 递归带上 _seen 防环——STAGE_IO 是 DAG,但用户手改的 manifest 不该把检查器搞成死循环。
177
+ """
178
+ if not stage:
179
+ return []
180
+ issues = check(work, stage, sources, pending=(), _seen=_seen)
181
+ return [it["reason"] for it in issues if it.get("kind") in HARD_KINDS]
182
+
183
+
184
+ def check(work, stage, sources=None, self_built=False, pending=(), _seen=None):
150
185
  """stage 开始前查新鲜度:返回问题列表(每项含 stage / reason / rerun)。
151
186
 
187
+ pending: 本次命令行**已经计划要跑**的 stage 集合(render.py 传 args.stage 展开后的列表)。
188
+ 对本 stage 来说,"自己上次跑完之后上游变了" + "本轮马上就要重跑自己" = 正常待办,
189
+ 不是过时(`--stage frames mux` 里 frames 刚重出 silent.mp4,mux 报"输入变了"是自相矛盾)。
190
+ 这类问题标 kind="input-pending"、soft=True:照实打印,但不参与 --strict-stale 判定。
191
+ 真问题不受影响:上游 stage **自己**过时(它的输入也变了)仍按 input-stale/output-missing 硬报,
192
+ 所以"单独跑 mux、上游 frames 早过时"照样失败。
193
+
152
194
  只报"这一跑真的会读到旧东西"的情况:
153
195
  1) 本 stage 的上游输入与上次记录不同(上游被重跑过,或人手改过文件);
154
196
  2) 本 stage 自己的产物比它的输入还旧(上次跑到一半被中断)。
155
197
  另一个 stage 的产物变了但**不**是本周 stage 的输入时,不报(跟我们无关)。
198
+
199
+ self_built: 这些输入是**本次运行自己刚写出来的**(render.py --stage all 跑在
200
+ 一个刚建的 work/ 上)。此时"manifest 没有本阶段记录"只是因为我们还没跑到它,
201
+ 不是旧产物;报 no-manifest 等于让全新目录的首跑刷一屏假警报(v0.7.2 修)。
156
202
  """
157
203
  io = STAGE_IO.get(str(stage))
158
204
  if io is None:
159
205
  return []
206
+ _seen = set(_seen or ())
207
+ if str(stage) in _seen:
208
+ return []
209
+ _seen.add(str(stage))
160
210
  d = load(work)
161
211
  stages = d.get("stages") or {}
162
212
  rec = stages.get(str(stage))
213
+ planned = set(str(x) for x in (pending or ()))
163
214
  issues = []
164
215
  if not isinstance(rec, dict):
165
- # 从没跑过这个 stage:只有当"别的 stage 有记录"(说明是接手别人的 work 目录,
166
- # 或这套产物是旧版本跑出来的)时才提示;全新目录第一次跑不刷噪声。
167
- present = [k for k in io["inputs"]
168
- if _fp(k, work) != "missing"]
169
- # 只有"有声明产物"的阶段才谈得上"跑过但没记录"。stills / mux 的产物不在 work 里
170
- # (mux 写 --out,stills 写 work/stills/),无从证明跑过,就别冤枉它们过时。
171
- if stages and io["outputs"] and len(present) == len(io["inputs"]) and io["inputs"]:
216
+ # 从没记录过这个 stage。判据必须能区分两件事:
217
+ # * 「还没跑到它」——分步跑(SKILL.md 主推:--stage style / tts / layout / …)时,
218
+ # 刚跑完 style,tts 的输入 style.json 当然存在、manifest 里当然没有 tts 记录。
219
+ # 这**不是**过时,按"输入齐了"报会把这套标准用法整个打成红(--strict-stale 下必然失败)。
220
+ # * 「跑过它但查不到记录」——旧版本产物 / 手改 / 复制来的 work 目录。
221
+ # 区分证据:本 stage **自己声明的产物**已经在 work/ 里。产物在、记录没有,才证明它跑过。
222
+ # 输入齐全不能当证据(分步跑时本来就齐),输出存在才可以。
223
+ present_out = [k for k in (io.get("outputs") or []) if _fp(k, work) != "missing"]
224
+ # stills / mux 的产物不在 work/(mux 写 --out,stills 写 work/stills/),无从证明跑过,
225
+ # 不冤枉它们;self_built(本次新建的 work/)连 manifest 都刚建,也不报。
226
+ if not self_built and stages and present_out:
172
227
  issues.append(dict(stage=str(stage), kind="no-manifest",
173
- reason="work 里有 %s 但 manifest.json 没有本阶段的记录"
174
- "(旧版本跑过 / 手改过)" % "、".join(io["inputs"]),
228
+ reason="work 里已经有本阶段的产物(%s),但 manifest.json 没有本阶段的记录"
229
+ "(旧版本跑过 / 手改过)" % "、".join(present_out),
175
230
  rerun=RERUN_HINT.get(str(stage), "")))
176
231
  return issues
177
232
  # 源头先查:剧本 / scenes.py 改了但本 stage 没重跑 -> 产物一定过时
@@ -191,16 +246,44 @@ def check(work, stage, sources=None):
191
246
  for name in io["inputs"]:
192
247
  now = _fp(name, work)
193
248
  was = old_inputs.get(name, "missing")
194
- if now != was:
249
+ if now == was:
250
+ continue
251
+ if str(stage) in planned:
252
+ # 本阶段本轮就要重跑:拿新输入重算正是它该干的事。照实打一行(不静默),
253
+ # 但不参与 strict 判定——否则 `--stage frames mux` 这种 stagewatch 自己建议的
254
+ # 重跑组合,会在 mux 开跑前被自己拦下,strict 下永远出不了片。
255
+ issues.append(dict(
256
+ stage=str(stage), kind="input-pending", file=name, soft=True,
257
+ reason="输入 %s 在上次跑本阶段之后变了(%s -> %s);本阶段本轮会重跑,"
258
+ "按新输入重算" % (name, was, now),
259
+ rerun=RERUN_HINT.get(str(stage), ""),
260
+ ))
261
+ continue
262
+ # 不由本阶段重算时:还要确认产出这个输入的 stage **自己**没坏。
263
+ # 上游自己就过时(源头改了 / 它的输入也变了 / 它的产物丢了),那这台旧货是真的不能用。
264
+ prod = _producer_of(name)
265
+ ups = _upstream_hard(work, prod, sources, _seen) if prod else []
266
+ if ups:
267
+ issues.append(dict(
268
+ stage=str(stage), kind="input-stale", file=name,
269
+ reason="输入 %s 变了(%s -> %s),并且产出它的 %s 阶段本身就已经过时:%s"
270
+ % (name, was, now, prod, ";".join(ups[:3])),
271
+ rerun=RERUN_HINT.get(str(stage), ""),
272
+ ))
273
+ else:
195
274
  issues.append(dict(
196
275
  stage=str(stage), kind="input-changed", file=name,
197
276
  reason="输入 %s 在上次跑本阶段之后变了(%s -> %s),本次会拿**新输入**算,"
198
277
  "但它下游的产物仍是旧的" % (name, was, now),
199
278
  rerun=RERUN_HINT.get(str(stage), ""),
200
279
  ))
201
- # 自己的产物丢了:上次跑到一半被中断 / 被删 / 被挪走(与"输入变了"是两件事,都要报)
202
- out_fps = {k: _fp(k, work) for k in (rec.get("outputs") or {})}
203
- missing_out = [k for k, v in out_fps.items() if v == "missing"]
280
+ # 自己的产物丢了:上次跑到一半被中断 / 被删 / 被挪走(与"输入变了"是两件事,都要报)。
281
+ # 只报「上次记录到真实指纹、现在没了」的文件:有些产物是**条件性**的(legacy 剧本
282
+ # 不写 voice_plan.json),它们上次就记为 missing,现在还是 missing,那是正常状态;
283
+ # 一律按"现在是 missing 就报"会让这类文件变成永久误报(--strict-stale 下直接失败)。
284
+ rec_out = rec.get("outputs") or {}
285
+ missing_out = [k for k, was in rec_out.items()
286
+ if was not in (None, "missing") and _fp(k, work) == "missing"]
204
287
  if missing_out:
205
288
  issues.append(dict(stage=str(stage), kind="output-missing",
206
289
  reason="上次跑过本阶段,但产物 %s 不见了(被删 / 被挪 / 磁盘满)"
@@ -209,8 +292,11 @@ def check(work, stage, sources=None):
209
292
  return issues
210
293
 
211
294
 
212
- def explain(work, stages=None, sources=None):
213
- """--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。"""
295
+ def explain(work, stages=None, sources=None, self_built=False, pending=()):
296
+ """--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。
297
+
298
+ pending: 本次命令行要跑的 stage 集合(见 check);--stage explain 不传 = 全按"不重跑"判。
299
+ """
214
300
  d = load(work)
215
301
  rows = []
216
302
  for st in (stages or STAGE_ORDER):
@@ -218,7 +304,7 @@ def explain(work, stages=None, sources=None):
218
304
  if io is None:
219
305
  continue
220
306
  rec = (d.get("stages") or {}).get(st)
221
- issues = check(work, st, sources)
307
+ issues = check(work, st, sources, self_built=self_built, pending=pending)
222
308
  rows.append(dict(stage=st, recorded=bool(rec),
223
309
  at=(rec or {}).get("atText", ""),
224
310
  issues=issues))
@@ -226,40 +312,64 @@ def explain(work, stages=None, sources=None):
226
312
 
227
313
 
228
314
  def report(work, stages=None, strict=False, allow_stale=False, ledger=None, write=None,
229
- sources=None):
315
+ sources=None, self_built=False, pending=()):
230
316
  """查 + 打印 + 判定。返回 (errors, warnings);errors 在 strict 下非空。
231
317
 
232
318
  ledger: 传 gates.GateLedger 时把结论记进账本(一条汇总 gate)。
233
319
  write: 传 callable(str, str) 时按行输出(方便测试捕获)。
320
+ self_built: 见 check()——本次运行的 work/ 是刚建的,别报"没有记录=过时"。
321
+ pending: 本次命令行要跑的 stage 集合(见 check)。其中"本阶段会重算"的输入变化
322
+ 标 soft,照实打印但不参与 strict 判定。
234
323
  """
235
324
  out = write or (lambda level, msg: print(msg))
236
- rows = explain(work, stages, sources)
325
+ rows = explain(work, stages, sources, self_built=self_built, pending=pending)
237
326
  problems = [(r["stage"], i) for r in rows for i in r["issues"]]
238
327
  warns, errs = [], []
239
328
  for st, it in problems:
240
- msg = ("[stale] %s: %s" % (st, it["reason"]))
241
- if allow_stale:
329
+ soft = bool(it.get("soft")) or it.get("kind") in SOFT_KINDS
330
+ # 两类不要用同一个前缀:soft 是"本轮会重算"的待办,不是过时。用 [stale] 会把
331
+ # 人吓一跳,也会让"grep [stale] 判红"的 CI 配方误报。
332
+ msg = (("[待重算] %s: %s" if soft else "[stale] %s: %s") % (st, it["reason"]))
333
+ if soft:
334
+ # 本轮会重算的输入变化:照实说,但不算失败。--strict-stale 也不拦。
335
+ warns.append(msg)
336
+ elif allow_stale:
242
337
  warns.append(msg + "(--allow-stale 已放行,本次结果可能掺旧数据)")
243
338
  elif strict:
244
339
  errs.append(msg)
245
340
  else:
246
341
  warns.append(msg)
247
- if it.get("rerun"):
342
+ # 原因行必须打出来:调用方(render.py --stage all)只显示"最小重跑",
343
+ # 用户会看到"共 1 处过时"却不知道是哪个 stage、为什么——这正是本模块
344
+ # 开头承诺的"可以保守,但不能静默"。返回的 warns/errs 是给调用方判定的,
345
+ # 不代替人读的输出。
346
+ out(("warn" if soft else "err") if (strict and not allow_stale) else "warn", msg)
347
+ if it.get("rerun") and not soft:
248
348
  out("warn", " 最小重跑: %s" % it["rerun"])
349
+ hard = [(st, it) for st, it in problems
350
+ if not (it.get("soft") or it.get("kind") in SOFT_KINDS)]
249
351
  if problems:
250
352
  out("warn", "")
251
- out("warn", " ↑ 共 %d 处过时。默认只告警;--strict-stale 让它失败,"
252
- "--allow-stale 显式放行(会记进 QC 报告)。" % len(problems))
353
+ if hard:
354
+ out("warn", " ↑ 共 %d 处过时。默认只告警;--strict-stale 让它失败,"
355
+ "--allow-stale 显式放行。放行会记进本次门禁账本,"
356
+ "交付阶段(qa / mux / all / stills / style)会体现在 QC 报告里。"
357
+ % len(hard))
358
+ else:
359
+ out("warn", " ↑ 上面 %d 条都是「本轮会重算」的输入变化(不是过时,不拦 strict)。"
360
+ % len(problems))
253
361
  if ledger is not None:
254
362
  try:
255
- if not problems:
256
- ledger.ok("stale", "阶段新鲜度", "上游输入与上次记录一致")
363
+ if not hard:
364
+ ledger.ok("stale", "阶段新鲜度",
365
+ "上游输入与上次记录一致" if not problems
366
+ else "%d 处输入会在本轮重算,无需拦" % len(problems))
257
367
  elif errs:
258
- ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(problems))
368
+ ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(hard))
259
369
  else:
260
370
  ledger.skip("stale", "阶段新鲜度",
261
371
  "%d 处过时,已告警未阻断%s"
262
- % (len(problems), "(--allow-stale)" if allow_stale else ""))
372
+ % (len(hard), "(--allow-stale)" if allow_stale else ""))
263
373
  except Exception: # noqa: BLE001
264
374
  pass
265
375
  return errs, warns
@@ -37,6 +37,7 @@ from __future__ import annotations
37
37
 
38
38
  import argparse
39
39
  import hashlib
40
+ import re
40
41
  import sys
41
42
 
42
43
  # ============================================================================
@@ -77,6 +78,92 @@ def _rgb(seq):
77
78
  return t
78
79
 
79
80
 
81
+ _HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
82
+
83
+
84
+ def _color_of(v, key):
85
+ """把 theme_overrides 里的颜色值统一成 (r,g,b):支持 [r,g,b] 和 "#rrggbb" / "#rgb"。
86
+
87
+ 为什么必须支持 hex:schemas/film.schema.json 与 references/style-packs.md 的示例
88
+ 就是 {"accent": "#ff8a3d"},而 JSON 没有元组,照着文档写 hex 是必然的。
89
+ v0.5 旧实现把字符串原样塞进主题,scenes.py 第一次 int("#") 就崩在渲染期
90
+ (实测:ink="#ffffff" -> ValueError: invalid literal for int() with base 10: '#')。
91
+ """
92
+ if isinstance(v, str):
93
+ m = _HEX_RE.match(v.strip())
94
+ if not m:
95
+ raise ValueError("theme_overrides.%s 的颜色写法不认识:%r(用 [r,g,b] 或 \"#rrggbb\")"
96
+ % (key, v))
97
+ h = m.group(1)
98
+ if len(h) == 3:
99
+ h = "".join(ch * 2 for ch in h)
100
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
101
+ if isinstance(v, (list, tuple)) and len(v) == 3:
102
+ return _rgb(v)
103
+ raise ValueError("theme_overrides.%s 必须是颜色([r,g,b] 或 \"#rrggbb\"),收到 %r"
104
+ % (key, v))
105
+
106
+
107
+ # 主题里"颜色字段"的白名单(值是 0..255 的 RGB 元组)。不靠"长度 3 的元组"猜:
108
+ # keywords 也是元组(长度 10),将来若加一个正好 3 项的关键词元组就会被误当颜色。
109
+ _THEME_COLOR_KEYS = frozenset(("bg", "bg2", "surface", "ink", "muted", "dim",
110
+ "accent", "accent2", "good", "bad", "rule", "vignette_tint"))
111
+
112
+
113
+ def apply_theme_overrides(theme, overrides):
114
+ """把 film.json 的 visual.theme_overrides 合并进主题**并做类型校验**。
115
+
116
+ 契约(schemas/film.schema.json:只接受主题里已存在的键;references/style-packs.md §3):
117
+ - 未知键忽略:以后主题加字段时老剧本不因为它不认识就炸;
118
+ - 颜色键(值本来是三色元组的那些)接受 [r,g,b] / "#rrggbb" / "#rgb",统一成元组;
119
+ - 数值键接受数字或数字字符串;字符串键必须是字符串;
120
+ - 嵌套结构(card / scale / qa …)不支持覆盖,静默忽略(与 v0.5 一致,避免破坏老配置);
121
+ 真要改嵌套字段请改风格包。
122
+ 校验只在"值类型不对、旧版会在渲染期崩"的场合抛 ValueError(提前到 --stage style)。
123
+ 返回新的 theme dict(不改入参)。
124
+ """
125
+ out = dict(theme or {})
126
+ for k, v in (overrides or {}).items():
127
+ if k not in out:
128
+ continue
129
+ base = out[k]
130
+ if k in _THEME_COLOR_KEYS:
131
+ out[k] = _color_of(v, k)
132
+ continue
133
+ if isinstance(base, bool):
134
+ if not isinstance(v, bool):
135
+ raise ValueError("theme_overrides.%s 必须是布尔,收到 %r" % (k, v))
136
+ out[k] = bool(v)
137
+ continue
138
+ if isinstance(base, (int, float)):
139
+ if isinstance(v, bool) or not isinstance(v, (int, float, str)):
140
+ raise ValueError("theme_overrides.%s 必须是数字,收到 %r" % (k, v))
141
+ try:
142
+ num = float(v)
143
+ except (TypeError, ValueError):
144
+ raise ValueError("theme_overrides.%s 必须是数字,收到 %r" % (k, v))
145
+ out[k] = int(round(num)) if isinstance(base, int) else float(num)
146
+ continue
147
+ if isinstance(base, dict):
148
+ # 嵌套结构(card / scale / qa)不支持覆盖。这里保持 v0.5 的**静默忽略**:
149
+ # 老配置里如果写了这种覆盖,旧版只是没生效、并不会崩;升级后突然报错是破坏性变更。
150
+ # 想改嵌套字段请改风格包(STYLES/THEMES),见 references/style-packs.md §7。
151
+ continue
152
+ if isinstance(base, str):
153
+ if not isinstance(v, str):
154
+ raise ValueError("theme_overrides.%s 必须是字符串,收到 %r" % (k, v))
155
+ if k == "layout" and v not in LAYOUTS:
156
+ raise ValueError("theme_overrides.layout 未知版式 %r;可用:%s"
157
+ % (v, " / ".join(LAYOUT_IDS)))
158
+ if k == "hud" and v not in ("chip-left", "corner-line", "none"):
159
+ raise ValueError("theme_overrides.hud 未知取值 %r;可用:chip-left / corner-line / none"
160
+ % (v,))
161
+ out[k] = v
162
+ continue
163
+ # 其它类型(None 等):保持原值,不折腾
164
+ return out
165
+
166
+
80
167
  def _theme(style_id, label, keywords, palette, material, typ, layout, motion, hud, qa):
81
168
  """把主题字段组装成一个扁平 dict(场景代码直接下标取,不嵌套,读起来快)。"""
82
169
  p = {k: _rgb(v) for k, v in palette.items()}
@@ -726,12 +813,9 @@ def resolve_script_style(script):
726
813
  else:
727
814
  chap_layout = {ch: layouts[i % len(layouts)] for i, ch in enumerate(chapters)}
728
815
 
729
- theme = dict(THEMES[pack["theme"]])
730
- for k, v in (vis.get("theme_overrides") or {}).items():
731
- if k in theme and isinstance(v, (int, float, str)):
732
- theme[k] = v
733
- elif k in theme and isinstance(v, (list, tuple)) and len(v) == 3:
734
- theme[k] = _rgb(v)
816
+ # theme_overrides 走带校验的合并(颜色支持 "#rrggbb",非法值在这里就报错,
817
+ # 不要等到 scenes.py 渲到第 300 帧才 int("#") 崩——见 apply_theme_overrides)
818
+ theme = apply_theme_overrides(THEMES[pack["theme"]], vis.get("theme_overrides"))
735
819
 
736
820
  return {
737
821
  "version": 1,
@@ -900,6 +984,10 @@ def main(argv=None):
900
984
  except Exception: # noqa: BLE001
901
985
  pass
902
986
  if a.show:
987
+ # 未知 id 以前是 KeyError traceback,现在给可用清单 + exit 2(与 render.py 的口径一致)
988
+ if a.show not in STYLES:
989
+ print("[未知风格] %r;可用:%s" % (a.show, " / ".join(ALL_STYLE_IDS)), file=sys.stderr)
990
+ return 2
903
991
  if a.json:
904
992
  import json
905
993
  print(json.dumps({"style": STYLES[a.show],
@@ -861,6 +861,14 @@ def main(argv=None):
861
861
  return _cli_list()
862
862
  if a.calibrate:
863
863
  only = [x.strip() for x in (a.only or "").split(",") if x.strip()] or None
864
+ if only:
865
+ unknown = [x for x in only if x not in VOICE_PERSONAS]
866
+ if unknown:
867
+ # 以前拼错 persona 会静默变成空集合 -> 打出"所有音色都合成失败(多半是没网)",
868
+ # 把配置错误伪装成网络错误。这里先点名校验,给人话可照抄的可用清单。
869
+ print("[未知 persona] %s;可用:%s"
870
+ % ("、".join(unknown), " / ".join(PERSONA_IDS)), file=sys.stderr)
871
+ return 2
864
872
  return calibrate(only, log=print)
865
873
  if a.plan:
866
874
  return _cli_plan(a.plan)
@@ -32,6 +32,7 @@ import asyncio
32
32
  import base64
33
33
  import hashlib
34
34
  import json
35
+ import math
35
36
  import os
36
37
  import re
37
38
  import shutil
@@ -186,6 +187,31 @@ def _format_contract(contract: dict) -> str:
186
187
  "有" if contract.get("hasFK") else "无"))
187
188
 
188
189
 
190
+ async def page_number(page, key: str, *, default=None) -> float:
191
+ """读页面上的数值契约(window.DURATION / window.FPS),缺了或不是正数就给可操作报错。
192
+
193
+ v0.5 以前直接 float(await page.evaluate("window.DURATION")):页面忘了导出 DURATION
194
+ 时得到的是 None,报错只剩一句 "float() argument must be ... 'NoneType'",看不出
195
+ 是页面契约缺字段(实测 page_meta / export_cues 都会这样)。
196
+ default 传 None 表示必填(DURATION);传数字表示缺省值(FPS 历史口径是 30)。
197
+ """
198
+ raw = await page.evaluate("k => window[k]", key)
199
+ if raw is None:
200
+ if default is not None:
201
+ return float(default)
202
+ raise RuntimeError(
203
+ "页面没有导出 window.%s(页面契约见 references/web-engine.md §3)。"
204
+ "index.html 里在 film.js 之前写 window.WINDOW_DURATION = 秒数,"
205
+ "或让模板按场景表自动推导(assets/web/film.js 的 resolveDuration)。" % key)
206
+ try:
207
+ val = float(raw)
208
+ except (TypeError, ValueError):
209
+ raise RuntimeError("页面 window.%s 不是数字:%r(必须是数字)" % (key, raw))
210
+ if not math.isfinite(val) or val <= 0:
211
+ raise RuntimeError("页面 window.%s 非法:%r(必须是正数)" % (key, raw))
212
+ return val
213
+
214
+
189
215
  async def open_page(pw, url, width=W, height=1080, log=False, deterministic=None,
190
216
  ready_timeout=300_000):
191
217
  """打开页面并等到 window.ready === true,返回 (browser, page, cdp)。
@@ -279,8 +305,8 @@ async def _page_meta_async(html_path, deterministic=None):
279
305
  async with _playwright()() as pw:
280
306
  browser, page, _cdp = await open_page(pw, url, deterministic=deterministic)
281
307
  try:
282
- duration = float(await page.evaluate("window.DURATION"))
283
- fps = float(await page.evaluate("window.FPS || 30"))
308
+ duration = await page_number(page, "DURATION")
309
+ fps = await page_number(page, "FPS", default=FPS)
284
310
  return duration, fps
285
311
  finally:
286
312
  try:
@@ -315,7 +341,7 @@ async def _snap_async(html_path, times, out: Path, tag: str, deterministic=None)
315
341
  async with _playwright()() as pw:
316
342
  browser, page, cdp = await open_page(pw, url, W, H, deterministic=deterministic)
317
343
  try:
318
- fps = float(await page.evaluate("window.FPS || 30"))
344
+ fps = await page_number(page, "FPS", default=FPS)
319
345
  for t in times:
320
346
  png = await _grab(page, cdp, int(round(t * fps)))
321
347
  path = out / ("%st%07.2f.png" % (tag, t))
@@ -353,6 +379,13 @@ def _norm_cue_list(raw, src: str) -> list:
353
379
 
354
380
 
355
381
  def _norm_events(raw) -> list:
382
+ """规整页面导出的视觉事件;统一打 src="web" 标记。
383
+
384
+ 为什么打标记:render.py 在 engine=web 时会把 film.json / scenes.py 的 cue 与页面 cue
385
+ **三路合并**(三路都是合法的登记方式),但 scenes.py 的 cue 是按 PIL 时间轴算的,
386
+ 与页面动画时间基准不同。qa_frames 的 sync_check 靠 src 判断"能不能配",
387
+ 否则会拿 29s PIL 时间轴上的 ui_blip 去配 12s 页面上的 ui_blip,报出假 FAIL。
388
+ """
356
389
  out = []
357
390
  for item in raw or []:
358
391
  if not isinstance(item, dict):
@@ -362,6 +395,7 @@ def _norm_events(raw) -> list:
362
395
  continue
363
396
  ev["t"] = round(float(ev["t"]), 3)
364
397
  ev.setdefault("kind", "misc")
398
+ ev.setdefault("src", "web")
365
399
  out.append(ev)
366
400
  out.sort(key=lambda e: e["t"])
367
401
  return out
@@ -378,8 +412,8 @@ async def _export_cues_async(html_path, deterministic=None) -> dict:
378
412
  async with _playwright()() as pw:
379
413
  browser, page, _cdp = await open_page(pw, url, W, H, deterministic=deterministic)
380
414
  try:
381
- duration = float(await page.evaluate("window.DURATION"))
382
- fps = float(await page.evaluate("window.FPS || 30"))
415
+ duration = await page_number(page, "DURATION")
416
+ fps = await page_number(page, "FPS", default=FPS)
383
417
  cues = await page.evaluate("window.getCues ? window.getCues() : []")
384
418
  events = await page.evaluate("window.getEvents ? window.getEvents() : []")
385
419
  finally:
@@ -532,17 +566,26 @@ async def _write_chunk(br: _Browser, f0: int, f1: int, dst: Path, fps: float,
532
566
  part = dst.with_name(dst.name[: -len(".mp4")] + PART_SUFFIX)
533
567
  if part.exists():
534
568
  part.unlink()
535
- proc = subprocess.Popen(
536
- _encoder_cmd(part, fps, crf, preset),
537
- stdin=subprocess.PIPE,
538
- stderr=subprocess.PIPE,
539
- )
569
+ # stderr 落临时文件,不用 PIPE:编码器一旦刷出超过管道缓冲(Windows 64 KB)的 stderr,
570
+ # 没人读就会写阻塞;而我们这边 proc.stdin.write 是**同步**阻塞调用(不交出事件循环),
571
+ # 两边互等就是死锁 —— asyncio.wait_for 也救不了它。临时文件没有缓冲上限,也不会堵。
572
+ errf = tempfile.TemporaryFile()
573
+ try:
574
+ proc = subprocess.Popen(
575
+ _encoder_cmd(part, fps, crf, preset),
576
+ stdin=subprocess.PIPE,
577
+ stderr=errf,
578
+ )
579
+ except BaseException:
580
+ errf.close()
581
+ raise
540
582
  try:
541
583
  for f in range(f0, f1):
542
584
  proc.stdin.write(await br.frame(f))
543
585
  proc.stdin.close()
544
586
  rc = proc.wait(timeout=300)
545
- err = (proc.stderr.read() or b"").decode("utf-8", "replace")
587
+ errf.seek(0)
588
+ err = errf.read().decode("utf-8", "replace")
546
589
  except BaseException:
547
590
  try:
548
591
  proc.kill()
@@ -560,7 +603,7 @@ async def _write_chunk(br: _Browser, f0: int, f1: int, dst: Path, fps: float,
560
603
  raise
561
604
  finally:
562
605
  try:
563
- proc.stderr.close()
606
+ errf.close()
564
607
  except Exception:
565
608
  pass
566
609
  if rc != 0:
@@ -702,7 +745,7 @@ async def _qa_text_async(html_path, t0, t1, step, deterministic=None) -> dict:
702
745
  browser, page, _cdp = await open_page(pw, url, W, H, deterministic=deterministic)
703
746
  try:
704
747
  if t1 is None:
705
- t1 = float(await page.evaluate("window.DURATION"))
748
+ t1 = await page_number(page, "DURATION")
706
749
  result = await page.evaluate(
707
750
  "([a, b, c]) => window.qaText(a, b, c)", [float(t0), float(t1), float(step)]
708
751
  )
@@ -1262,7 +1305,18 @@ def main(argv=None) -> int:
1262
1305
  return 2
1263
1306
 
1264
1307
  if args.cmd == "snap":
1265
- times = _parse_times(args.times)
1308
+ try:
1309
+ times = _parse_times(args.times)
1310
+ except ValueError:
1311
+ print("[错误] --times 里有不是数字的项:%r(要逗号分隔的秒数,如 3,9.5,13)"
1312
+ % (args.times,), flush=True)
1313
+ return 2
1314
+ if args.every is not None and float(args.every) <= 0:
1315
+ # 以前 --every 0 被当成"没写"、--every -1 则是死循环:先 page_meta 开一次浏览器,
1316
+ # 之后 while k <= end 永远成立,times 一路追加到内存爆掉(实测 40 s 都出不来)。
1317
+ print("[错误] --every 必须是正数秒(当前 %r);不想等间隔采样就别传 --every" % (args.every,),
1318
+ flush=True)
1319
+ return 2
1266
1320
  if args.every:
1267
1321
  end = args.t_to if args.t_to is not None else page_meta(args.html)[0]
1268
1322
  k = float(args.t_from)