@dsh-gis/dsh-video-studio-skill 0.6.0 → 0.8.1

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 (43) hide show
  1. package/CHANGELOG.md +951 -0
  2. package/README.md +89 -21
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/index.js +23 -7
  5. package/package.json +7 -3
  6. package/schemas/film.schema.json +235 -4
  7. package/skills/video-studio/SKILL.md +299 -27
  8. package/skills/video-studio/assets/film.json +7 -14
  9. package/skills/video-studio/assets/golden_frames.json +65 -0
  10. package/skills/video-studio/assets/scenes.py +115 -10
  11. package/skills/video-studio/assets/web/engine.js +81 -3
  12. package/skills/video-studio/assets/web/film.js +249 -101
  13. package/skills/video-studio/assets/web/index.html +9 -2
  14. package/skills/video-studio/assets/web/qa_text.js +36 -8
  15. package/skills/video-studio/references/audio-variation.md +159 -0
  16. package/skills/video-studio/references/audio.md +67 -5
  17. package/skills/video-studio/references/change-impact.md +2 -2
  18. package/skills/video-studio/references/quality-gates.md +25 -3
  19. package/skills/video-studio/references/style-packs.md +7 -3
  20. package/skills/video-studio/references/v08-quality.md +256 -0
  21. package/skills/video-studio/references/web-engine.md +19 -2
  22. package/skills/video-studio/references/word-timeline.md +147 -0
  23. package/skills/video-studio/requirements.txt +16 -1
  24. package/skills/video-studio/scripts/filmcheck.py +202 -12
  25. package/skills/video-studio/scripts/filmkit.py +792 -56
  26. package/skills/video-studio/scripts/gates.py +161 -14
  27. package/skills/video-studio/scripts/preflight.py +774 -0
  28. package/skills/video-studio/scripts/qa_frames.py +154 -23
  29. package/skills/video-studio/scripts/qaasr.py +1563 -0
  30. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  31. package/skills/video-studio/scripts/render.py +1134 -78
  32. package/skills/video-studio/scripts/scorekit.py +305 -76
  33. package/skills/video-studio/scripts/selfcheck.py +576 -2
  34. package/skills/video-studio/scripts/sfxkit.py +262 -44
  35. package/skills/video-studio/scripts/stagewatch.py +159 -36
  36. package/skills/video-studio/scripts/stylekit.py +137 -18
  37. package/skills/video-studio/scripts/voicekit.py +23 -7
  38. package/skills/video-studio/scripts/webengine.py +1186 -85
  39. package/tools/audio_similarity.py +279 -0
  40. package/tools/ci_check.py +310 -59
  41. package/tools/estimate.py +251 -61
  42. package/tools/golden_frames.py +664 -0
  43. 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", "source-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 没重跑 -> 产物一定过时
@@ -179,28 +234,69 @@ def check(work, stage, sources=None):
179
234
  for kind in (io.get("sources") or []):
180
235
  now = _src_fp(kind, sources)
181
236
  was = old_src.get(kind, "missing")
182
- if now != was:
183
- fname = {"script": "剧本 film.json", "scenes": "画面代码 scenes.py"}.get(kind, kind)
237
+ if now == was:
238
+ continue
239
+ fname = {"script": "剧本 film.json", "scenes": "画面代码 scenes.py"}.get(kind, kind)
240
+ if str(stage) in planned:
241
+ # 源头变了 + 本轮马上就要重跑本阶段 = 正常待办,不是过时。
242
+ # 不软化的话,「改一句台词 -> --stage all --strict-stale」会在第一个 stage
243
+ # 直接失败("没重跑却装了旧内容"——其实马上就要重跑),把主工作流堵死;
244
+ # 与 inputs 的 input-pending 是同一套语义,只是源头走的是另一个循环。
184
245
  issues.append(dict(
185
- stage=str(stage), kind="source-changed", file=kind,
186
- reason="%s 在本阶段上次跑完之后改过(%s -> %s),但本阶段没有重跑;"
187
- "它现在装的还是旧内容" % (fname, was, now),
246
+ stage=str(stage), kind="source-pending", file=kind, soft=True,
247
+ reason="%s 在上次跑本阶段之后变了(%s -> %s);本阶段本轮会重跑,"
248
+ "按新内容重算" % (fname, was, now),
188
249
  rerun=RERUN_HINT.get(str(stage), ""),
189
250
  ))
251
+ continue
252
+ issues.append(dict(
253
+ stage=str(stage), kind="source-changed", file=kind,
254
+ reason="%s 在本阶段上次跑完之后改过(%s -> %s),但本阶段没有重跑;"
255
+ "它现在装的还是旧内容" % (fname, was, now),
256
+ rerun=RERUN_HINT.get(str(stage), ""),
257
+ ))
190
258
  old_inputs = rec.get("inputs") or {}
191
259
  for name in io["inputs"]:
192
260
  now = _fp(name, work)
193
261
  was = old_inputs.get(name, "missing")
194
- if now != was:
262
+ if now == was:
263
+ continue
264
+ if str(stage) in planned:
265
+ # 本阶段本轮就要重跑:拿新输入重算正是它该干的事。照实打一行(不静默),
266
+ # 但不参与 strict 判定——否则 `--stage frames mux` 这种 stagewatch 自己建议的
267
+ # 重跑组合,会在 mux 开跑前被自己拦下,strict 下永远出不了片。
268
+ issues.append(dict(
269
+ stage=str(stage), kind="input-pending", file=name, soft=True,
270
+ reason="输入 %s 在上次跑本阶段之后变了(%s -> %s);本阶段本轮会重跑,"
271
+ "按新输入重算" % (name, was, now),
272
+ rerun=RERUN_HINT.get(str(stage), ""),
273
+ ))
274
+ continue
275
+ # 不由本阶段重算时:还要确认产出这个输入的 stage **自己**没坏。
276
+ # 上游自己就过时(源头改了 / 它的输入也变了 / 它的产物丢了),那这台旧货是真的不能用。
277
+ prod = _producer_of(name)
278
+ ups = _upstream_hard(work, prod, sources, _seen) if prod else []
279
+ if ups:
280
+ issues.append(dict(
281
+ stage=str(stage), kind="input-stale", file=name,
282
+ reason="输入 %s 变了(%s -> %s),并且产出它的 %s 阶段本身就已经过时:%s"
283
+ % (name, was, now, prod, ";".join(ups[:3])),
284
+ rerun=RERUN_HINT.get(str(stage), ""),
285
+ ))
286
+ else:
195
287
  issues.append(dict(
196
288
  stage=str(stage), kind="input-changed", file=name,
197
289
  reason="输入 %s 在上次跑本阶段之后变了(%s -> %s),本次会拿**新输入**算,"
198
290
  "但它下游的产物仍是旧的" % (name, was, now),
199
291
  rerun=RERUN_HINT.get(str(stage), ""),
200
292
  ))
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"]
293
+ # 自己的产物丢了:上次跑到一半被中断 / 被删 / 被挪走(与"输入变了"是两件事,都要报)。
294
+ # 只报「上次记录到真实指纹、现在没了」的文件:有些产物是**条件性**的(legacy 剧本
295
+ # 不写 voice_plan.json),它们上次就记为 missing,现在还是 missing,那是正常状态;
296
+ # 一律按"现在是 missing 就报"会让这类文件变成永久误报(--strict-stale 下直接失败)。
297
+ rec_out = rec.get("outputs") or {}
298
+ missing_out = [k for k, was in rec_out.items()
299
+ if was not in (None, "missing") and _fp(k, work) == "missing"]
204
300
  if missing_out:
205
301
  issues.append(dict(stage=str(stage), kind="output-missing",
206
302
  reason="上次跑过本阶段,但产物 %s 不见了(被删 / 被挪 / 磁盘满)"
@@ -209,8 +305,11 @@ def check(work, stage, sources=None):
209
305
  return issues
210
306
 
211
307
 
212
- def explain(work, stages=None, sources=None):
213
- """--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。"""
308
+ def explain(work, stages=None, sources=None, self_built=False, pending=()):
309
+ """--explain-stale:把"现在哪些阶段是旧的、为什么、该跑什么"一次说清。
310
+
311
+ pending: 本次命令行要跑的 stage 集合(见 check);--stage explain 不传 = 全按"不重跑"判。
312
+ """
214
313
  d = load(work)
215
314
  rows = []
216
315
  for st in (stages or STAGE_ORDER):
@@ -218,7 +317,7 @@ def explain(work, stages=None, sources=None):
218
317
  if io is None:
219
318
  continue
220
319
  rec = (d.get("stages") or {}).get(st)
221
- issues = check(work, st, sources)
320
+ issues = check(work, st, sources, self_built=self_built, pending=pending)
222
321
  rows.append(dict(stage=st, recorded=bool(rec),
223
322
  at=(rec or {}).get("atText", ""),
224
323
  issues=issues))
@@ -226,40 +325,64 @@ def explain(work, stages=None, sources=None):
226
325
 
227
326
 
228
327
  def report(work, stages=None, strict=False, allow_stale=False, ledger=None, write=None,
229
- sources=None):
328
+ sources=None, self_built=False, pending=()):
230
329
  """查 + 打印 + 判定。返回 (errors, warnings);errors 在 strict 下非空。
231
330
 
232
331
  ledger: 传 gates.GateLedger 时把结论记进账本(一条汇总 gate)。
233
332
  write: 传 callable(str, str) 时按行输出(方便测试捕获)。
333
+ self_built: 见 check()——本次运行的 work/ 是刚建的,别报"没有记录=过时"。
334
+ pending: 本次命令行要跑的 stage 集合(见 check)。其中"本阶段会重算"的输入变化
335
+ 标 soft,照实打印但不参与 strict 判定。
234
336
  """
235
337
  out = write or (lambda level, msg: print(msg))
236
- rows = explain(work, stages, sources)
338
+ rows = explain(work, stages, sources, self_built=self_built, pending=pending)
237
339
  problems = [(r["stage"], i) for r in rows for i in r["issues"]]
238
340
  warns, errs = [], []
239
341
  for st, it in problems:
240
- msg = ("[stale] %s: %s" % (st, it["reason"]))
241
- if allow_stale:
342
+ soft = bool(it.get("soft")) or it.get("kind") in SOFT_KINDS
343
+ # 两类不要用同一个前缀:soft 是"本轮会重算"的待办,不是过时。用 [stale] 会把
344
+ # 人吓一跳,也会让"grep [stale] 判红"的 CI 配方误报。
345
+ msg = (("[待重算] %s: %s" if soft else "[stale] %s: %s") % (st, it["reason"]))
346
+ if soft:
347
+ # 本轮会重算的输入变化:照实说,但不算失败。--strict-stale 也不拦。
348
+ warns.append(msg)
349
+ elif allow_stale:
242
350
  warns.append(msg + "(--allow-stale 已放行,本次结果可能掺旧数据)")
243
351
  elif strict:
244
352
  errs.append(msg)
245
353
  else:
246
354
  warns.append(msg)
247
- if it.get("rerun"):
355
+ # 原因行必须打出来:调用方(render.py --stage all)只显示"最小重跑",
356
+ # 用户会看到"共 1 处过时"却不知道是哪个 stage、为什么——这正是本模块
357
+ # 开头承诺的"可以保守,但不能静默"。返回的 warns/errs 是给调用方判定的,
358
+ # 不代替人读的输出。
359
+ out(("warn" if soft else "err") if (strict and not allow_stale) else "warn", msg)
360
+ if it.get("rerun") and not soft:
248
361
  out("warn", " 最小重跑: %s" % it["rerun"])
362
+ hard = [(st, it) for st, it in problems
363
+ if not (it.get("soft") or it.get("kind") in SOFT_KINDS)]
249
364
  if problems:
250
365
  out("warn", "")
251
- out("warn", " ↑ 共 %d 处过时。默认只告警;--strict-stale 让它失败,"
252
- "--allow-stale 显式放行(会记进 QC 报告)。" % len(problems))
366
+ if hard:
367
+ out("warn", " ↑ 共 %d 处过时。默认只告警;--strict-stale 让它失败,"
368
+ "--allow-stale 显式放行。放行会记进本次门禁账本,"
369
+ "交付阶段(qa / mux / all / stills / style)会体现在 QC 报告里。"
370
+ % len(hard))
371
+ else:
372
+ out("warn", " ↑ 上面 %d 条都是「本轮会重算」的输入变化(不是过时,不拦 strict)。"
373
+ % len(problems))
253
374
  if ledger is not None:
254
375
  try:
255
- if not problems:
256
- ledger.ok("stale", "阶段新鲜度", "上游输入与上次记录一致")
376
+ if not hard:
377
+ ledger.ok("stale", "阶段新鲜度",
378
+ "上游输入与上次记录一致" if not problems
379
+ else "%d 处输入会在本轮重算,无需拦" % len(problems))
257
380
  elif errs:
258
- ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(problems))
381
+ ledger.fail("stale", "阶段新鲜度", "%d 处过时(--strict-stale)" % len(hard))
259
382
  else:
260
383
  ledger.skip("stale", "阶段新鲜度",
261
384
  "%d 处过时,已告警未阻断%s"
262
- % (len(problems), "(--allow-stale)" if allow_stale else ""))
385
+ % (len(hard), "(--allow-stale)" if allow_stale else ""))
263
386
  except Exception: # noqa: BLE001
264
387
  pass
265
388
  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()}
@@ -247,7 +334,7 @@ THEMES["lo-fi"] = _theme(
247
334
  ("深夜", "学习", "效率", "专注", "写作", "整理", "笔记", "书", "日常", "房间"),
248
335
  # 冷调灰绿:和 paper(暖牛皮纸)在色相上分开,不靠"亮度差不多"硬撑
249
336
  dict(bg=(232, 238, 236), bg2=(210, 220, 216), surface=(244, 248, 246),
250
- ink=(44, 52, 50), muted=(86, 100, 96), dim=(112, 126, 122),
337
+ ink=(44, 52, 50), muted=(84, 98, 94), dim=(112, 126, 122),
251
338
  accent=(20, 86, 84), accent2=(104, 72, 96),
252
339
  good=(40, 100, 72), bad=(146, 52, 50), rule=(44, 52, 50)),
253
340
  dict(vignette=0.12, vignette_tint=(206, 200, 190), grain=7.0, grain_freq=0.24,
@@ -546,7 +633,11 @@ def score_styles(script):
546
633
  score += 1.0 if sid in ("cinema", "paper", "mono") else 0.0
547
634
  if prof["hook"] > 0 and prof["outro"] > 0:
548
635
  score += 0.5 if sid in ("tech-deep", "vivid", "epic") else 0.0
549
- score += (int(fp["style"][:4], 16) % 7) * 0.0001 # 指纹破平
636
+ # 指纹破平:必须把**风格 id** 混进哈希,否则这一项对所有行是同一个常数,
637
+ # 等于没破平(v0.8 深审实测:旧写法在"AI 报告"上 tech-deep / magazine 同分 12.0,
638
+ # 永远按 STYLE_IDS 顺序选 tech-deep)。幅度上限 0.0006 << 最小真实分差 0.5,
639
+ # 只在精确同分时起作用,且不改变"改错别字不换风格"(指纹只吃标题)。
640
+ score += (_digest("tiebreak", fp["style"], sid) % 7) * 0.0001
550
641
  rows.append((sid, round(score, 4), hits))
551
642
  rows.sort(key=lambda r: (-r[1], STYLE_IDS.index(r[0])))
552
643
  return rows
@@ -558,7 +649,12 @@ def pick_style(script):
558
649
  best_id, best_score, best_hits = rows[0]
559
650
  fp = fingerprint(script)
560
651
  if best_score >= 1.0:
561
- reason = ["关键词命中:%s" % "、".join(dict.fromkeys(best_hits))[:80]]
652
+ if best_hits:
653
+ reason = ["关键词命中:%s" % "、".join(dict.fromkeys(best_hits))[:80]]
654
+ else:
655
+ # 分数可以只由结构加分(items / 长句 / hook+outro)凑到 1.0 以上,
656
+ # 这时旧文案会写"关键词命中:"后面空一截 —— 等于向用户报告没发生的事。
657
+ reason = ["无关键词命中,按题材结构加分选中(items / 长句 / hook+outro)"]
562
658
  else:
563
659
  pool = list(STYLE_IDS)
564
660
  best_id = pool[int(fp["tempo"], 16) % len(pool)]
@@ -726,12 +822,18 @@ def resolve_script_style(script):
726
822
  else:
727
823
  chap_layout = {ch: layouts[i % len(layouts)] for i, ch in enumerate(chapters)}
728
824
 
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)
825
+ # theme_overrides 走带校验的合并(颜色支持 "#rrggbb",非法值在这里就报错,
826
+ # 不要等到 scenes.py 渲到第 300 帧才 int("#") 崩——见 apply_theme_overrides)
827
+ theme = apply_theme_overrides(THEMES[pack["theme"]], vis.get("theme_overrides"))
828
+ # visual.layout 是"显式换整片版式":旧的实现只把它写进 chapter_layout,而
829
+ # PIL 路线渲染读的是 theme["layout"]、web 路线读 layout_default —— 两条路线都不看
830
+ # chapter_layout,于是这条文档化的覆盖等于没写(v0.8 深审实测:visual.layout=
831
+ # split-right 的帧与默认帧 sha256 完全相同)。这里补写 theme["layout"] 与 layout_default,
832
+ # 让两条路线都跟着走;theme_overrides 更具体,它显式写了 layout 时以它为准。
833
+ if explicit_layout and "layout" not in (vis.get("theme_overrides") or {}):
834
+ theme["layout"] = explicit_layout
835
+ if explicit_layout:
836
+ chap_layout = {ch: str(theme["layout"]) for ch in chapters}
735
837
 
736
838
  return {
737
839
  "version": 1,
@@ -747,7 +849,7 @@ def resolve_script_style(script):
747
849
  # 主题字典随计划一起返回:scenes.py 的 _scene_theme 优先用它(含 theme_overrides),
748
850
  # 也方便 render.py 直接传给 draw_subtitle/write_ass,不必再按 id 查一次。
749
851
  "theme": theme,
750
- "layout_default": layouts[0],
852
+ "layout_default": str(theme["layout"]) if explicit_layout else layouts[0],
751
853
  "chapter_layout": chap_layout,
752
854
  "music": {
753
855
  "style": music_style,
@@ -770,7 +872,8 @@ def resolve_script_style(script):
770
872
  # 七、门禁:主题体检(对比度 / 明暗 / 结构)
771
873
  # ============================================================================
772
874
  #
773
- # 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(ink 16.3 / muted 7.8 / accent 7.0)。
875
+ # 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(tech-deep:ink 16.3 / muted 7.8 / accent 7.0,
876
+ # 那是旧版按 bg/bg2 中点量的;v0.8 改成按两端最差值量后同一主题是 14.8 / 7.1 / 6.3,仍远高于线)。
774
877
 
775
878
  LINT_LIMITS = {
776
879
  "ink": 7.0, # 正文级(WCAG AAA 正文线)
@@ -784,18 +887,30 @@ LINT_LIMITS = {
784
887
  }
785
888
 
786
889
 
890
+ def bg_contrast(th, role):
891
+ """文字色对背景的**最差**对比度:bg/bg2 是上下两端的主渐变(scenes._bg 按行插值),
892
+ 任何一行文字都可能落在两端附近,所以取二者较小值,而不是看中点。
893
+
894
+ 为什么不能只看中点:lo-fi 的 muted(86,100,96) 对 bg 5.28、对 bg2 只有 4.42,
895
+ 中点平均 4.84 把 AA 4.5 的门槛"平均"过去了 —— 实测过(v0.8 深审),
896
+ 这一版把门槛改成端点最差值,并同时下调 lo-fi 的 muted 到 (84,98,94)
897
+ (bg 5.44 / bg2 4.56,两端都过)。
898
+ """
899
+ return min(contrast(th[role], th["bg"]), contrast(th[role], th["bg2"]))
900
+
901
+
787
902
  def theme_lint(th):
788
903
  """主题体检,返回问题列表(空 = 过)。坏主题在 load 阶段就拦,别等渲完 300 帧。"""
789
904
  if not isinstance(th, dict):
790
905
  return ["主题不是 dict:%r" % (th,)]
791
906
  bad = []
792
- bgs = midpoint(th["bg"], th["bg2"])
793
907
  for role, need in (("ink", LINT_LIMITS["ink"]), ("muted", LINT_LIMITS["muted"]),
794
908
  ("dim", LINT_LIMITS["dim"]), ("accent", LINT_LIMITS["accent"]),
795
909
  ("accent2", LINT_LIMITS["accent2"])):
796
- got = contrast(th[role], bgs)
910
+ got = bg_contrast(th, role)
797
911
  if got < need:
798
- bad.append("%s 对背景对比度 %.2f < %.1f" % (role, got, need))
912
+ bad.append("%s 对背景对比度 %.2f < %.1f(bg/bg2 两端取最差)"
913
+ % (role, got, need))
799
914
  for role in ("good", "bad"):
800
915
  got = contrast(th[role], th["surface"])
801
916
  if got < LINT_LIMITS[role]:
@@ -863,7 +978,6 @@ def blend_theme(a, b, p):
863
978
  def _describe(sid):
864
979
  pack = STYLES[sid]
865
980
  th = THEMES[pack["theme"]]
866
- bgs = midpoint(th["bg"], th["bg2"])
867
981
  lines = [
868
982
  "[%s] %s (theme=%s / layouts=%s)" % (sid, pack["name"], pack["theme"],
869
983
  ",".join(pack["layouts"])),
@@ -877,9 +991,10 @@ def _describe(sid):
877
991
  % (pack["pacing"]["lead"], pack["pacing"]["tail"], pack["pacing"]["min_dur"]),
878
992
  " palette : bg=%s bg2=%s ink=%s accent=%s"
879
993
  % (th["bg"], th["bg2"], th["ink"], th["accent"]),
880
- " contrast : ink %.2f / muted %.2f / dim %.2f / accent %.2f"
881
- % (contrast(th["ink"], bgs), contrast(th["muted"], bgs),
882
- contrast(th["dim"], bgs), contrast(th["accent"], bgs)),
994
+ # 与 theme_lint 同口径:bg/bg2 两端取最差(旧版打中点值,会掩盖端点不达标)
995
+ " contrast : ink %.2f / muted %.2f / dim %.2f / accent %.2f(bg/bg2 最差端)"
996
+ % (bg_contrast(th, "ink"), bg_contrast(th, "muted"),
997
+ bg_contrast(th, "dim"), bg_contrast(th, "accent")),
883
998
  " font : family=%s display=%spx hud=%s"
884
999
  % (th["family"], th["scale"]["display"][0], th["hud"]),
885
1000
  ]
@@ -900,6 +1015,10 @@ def main(argv=None):
900
1015
  except Exception: # noqa: BLE001
901
1016
  pass
902
1017
  if a.show:
1018
+ # 未知 id 以前是 KeyError traceback,现在给可用清单 + exit 2(与 render.py 的口径一致)
1019
+ if a.show not in STYLES:
1020
+ print("[未知风格] %r;可用:%s" % (a.show, " / ".join(ALL_STYLE_IDS)), file=sys.stderr)
1021
+ return 2
903
1022
  if a.json:
904
1023
  import json
905
1024
  print(json.dumps({"style": STYLES[a.show],
@@ -207,8 +207,12 @@ except Exception: # noqa: BLE001 — stylekit 缺失时
207
207
  # ============================================================================
208
208
  # 三、小工具:参数解析/格式化、汉字计数、抖动
209
209
  # ============================================================================
210
- _PCT_RE = re.compile(r"^\s*([+-]?)(\d+)\s*%\s*$")
211
- _HZ_RE = re.compile(r"^\s*([+-]?)(\d+)\s*Hz\s*$")
210
+ # 允许小数("+4.5%"):edge-tts 只收整数百分比,所以解析后按 _round_half_away 取整。
211
+ # 以前正则只认整数,"+4.5%" 解析失败 → 静默回落 0 —— 而 0 与 "+0%" 在 spec_key 里
212
+ # 是**同一个 TTS 缓存键**,于是"把语速从 0 调到 4.5%"完全没生效,还复用了旧音频。
213
+ # 这是配置写对了却不生效里最难查的一类:没有报错、没有日志、听起来就是"没反应"。
214
+ _PCT_RE = re.compile(r"^\s*([+-]?)(\d+(?:\.\d+)?)\s*%\s*$")
215
+ _HZ_RE = re.compile(r"^\s*([+-]?)(\d+(?:\.\d+)?)\s*Hz\s*$")
212
216
 
213
217
 
214
218
  def _round_half_away(v):
@@ -218,24 +222,24 @@ def _round_half_away(v):
218
222
 
219
223
 
220
224
  def parse_pct(v, default=0):
221
- """'+4%' -> 4;'-3%' -> -3;数字原样取整;解析失败回落 default。"""
225
+ """'+4%' -> 4;'-3%' -> -3;'+4.5%' -> 5(取整);数字原样取整;解析失败回落 default。"""
222
226
  if isinstance(v, (int, float)) and not isinstance(v, bool):
223
227
  return int(_round_half_away(v))
224
228
  m = _PCT_RE.match(str(v if v is not None else ""))
225
229
  if not m:
226
230
  return int(default)
227
- n = int(m.group(2))
231
+ n = int(_round_half_away(float(m.group(2))))
228
232
  return -n if m.group(1) == "-" else n
229
233
 
230
234
 
231
235
  def parse_hz(v, default=0):
232
- """'+2Hz' -> 2;解析失败回落 default。"""
236
+ """'+2Hz' -> 2;'+2.5Hz' -> 3(取整);解析失败回落 default。"""
233
237
  if isinstance(v, (int, float)) and not isinstance(v, bool):
234
238
  return int(_round_half_away(v))
235
239
  m = _HZ_RE.match(str(v if v is not None else ""))
236
240
  if not m:
237
241
  return int(default)
238
- n = int(m.group(2))
242
+ n = int(_round_half_away(float(m.group(2))))
239
243
  return -n if m.group(1) == "-" else n
240
244
 
241
245
 
@@ -306,7 +310,11 @@ def _quantize(v, u):
306
310
  # ============================================================================
307
311
  # 四、文本规则(说什么):全部确定性,命中即返回理由
308
312
  # ============================================================================
309
- _QUOTE_OPEN = ('"', "\u201c", "\u300c", "\u300e", "\u300a")
313
+ # 引语起始符。**不含书名号 《(《)**:正文里出现"《设计模式》"是书名,不是引用某人在说话。
314
+ # 实测踩过:把 《 算进引语后,"今天我们聊《设计模式》"这句的 tone 被判成 quote,
315
+ # 进而触发 rule:quote:persona,把 persona 从 narrator_male 换成 narrator_female ——
316
+ # 旁白中途换人,听感像换了主播(而这句根本没有人引语)。
317
+ _QUOTE_OPEN = ('"', "\u201c", "\u300c", "\u300e")
310
318
  _QUOTE_WORDS = ("他说", "她说", "有人问", "所谓", "据称")
311
319
  _NUM_UNITS = ("%", "万", "亿", "年", "倍")
312
320
  _TURN_WORDS = ("但是", "然而", "不过", "其实", "问题是")
@@ -861,6 +869,14 @@ def main(argv=None):
861
869
  return _cli_list()
862
870
  if a.calibrate:
863
871
  only = [x.strip() for x in (a.only or "").split(",") if x.strip()] or None
872
+ if only:
873
+ unknown = [x for x in only if x not in VOICE_PERSONAS]
874
+ if unknown:
875
+ # 以前拼错 persona 会静默变成空集合 -> 打出"所有音色都合成失败(多半是没网)",
876
+ # 把配置错误伪装成网络错误。这里先点名校验,给人话可照抄的可用清单。
877
+ print("[未知 persona] %s;可用:%s"
878
+ % ("、".join(unknown), " / ".join(PERSONA_IDS)), file=sys.stderr)
879
+ return 2
864
880
  return calibrate(only, log=print)
865
881
  if a.plan:
866
882
  return _cli_plan(a.plan)