@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.4

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 (63) hide show
  1. package/CHANGELOG.md +473 -0
  2. package/README.md +263 -327
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +31 -42
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1216 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +2 -4
  11. package/skills/video-studio/assets/golden_frames.json +13 -10
  12. package/skills/video-studio/assets/scenes.py +153 -65
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/appearance.md +123 -0
  15. package/skills/video-studio/references/audio-variation.md +74 -93
  16. package/skills/video-studio/references/audio.md +64 -66
  17. package/skills/video-studio/references/change-impact.md +5 -5
  18. package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
  19. package/skills/video-studio/references/frame-qa.md +48 -16
  20. package/skills/video-studio/references/pipeline.md +4 -4
  21. package/skills/video-studio/references/pitfalls.md +22 -3
  22. package/skills/video-studio/references/proposal.md +160 -0
  23. package/skills/video-studio/references/quality-gates.md +11 -11
  24. package/skills/video-studio/references/scene-authoring.md +2 -2
  25. package/skills/video-studio/references/scene-grammar.md +40 -42
  26. package/skills/video-studio/references/sfx-design.md +8 -6
  27. package/skills/video-studio/references/web-engine.md +8 -8
  28. package/skills/video-studio/references/word-timeline.md +9 -9
  29. package/skills/video-studio/requirements.txt +9 -13
  30. package/skills/video-studio/scripts/designkit.py +2115 -0
  31. package/skills/video-studio/scripts/filmcheck.py +22 -48
  32. package/skills/video-studio/scripts/filmkit.py +2980 -2904
  33. package/skills/video-studio/scripts/gates.py +4 -4
  34. package/skills/video-studio/scripts/grammar.py +73 -13
  35. package/skills/video-studio/scripts/musicgen.py +2042 -0
  36. package/skills/video-studio/scripts/preflight.py +14 -7
  37. package/skills/video-studio/scripts/previewkit.py +848 -0
  38. package/skills/video-studio/scripts/propose.py +825 -0
  39. package/skills/video-studio/scripts/qa_frames.py +73 -39
  40. package/skills/video-studio/scripts/qaasr.py +26 -7
  41. package/skills/video-studio/scripts/qavmaf.py +89 -23
  42. package/skills/video-studio/scripts/render.py +545 -231
  43. package/skills/video-studio/scripts/scorekit.py +55 -1755
  44. package/skills/video-studio/scripts/selfcheck.py +815 -215
  45. package/skills/video-studio/scripts/sfxkit.py +27 -26
  46. package/skills/video-studio/scripts/stagewatch.py +24 -8
  47. package/skills/video-studio/scripts/stylekit.py +207 -888
  48. package/skills/video-studio/scripts/voicekit.py +508 -387
  49. package/skills/video-studio/scripts/webengine.py +71 -72
  50. package/tools/audio_similarity.py +64 -37
  51. package/tools/ci_check.py +20 -14
  52. package/tools/estimate.py +34 -29
  53. package/tools/golden_frames.py +72 -28
  54. package/tools/silhouette_qa.py +520 -168
  55. package/docs/demo-pil-sheet.png +0 -0
  56. package/docs/styles/demo-results.json +0 -76
  57. package/docs/styles/sheet-8-styles.png +0 -0
  58. package/docs/styles/sheet-ai.png +0 -0
  59. package/docs/styles/sheet-before-after.png +0 -0
  60. package/docs/styles/sheet-craft.png +0 -0
  61. package/docs/styles/sheet-night.png +0 -0
  62. package/skills/video-studio/assets/score_demo.json +0 -307
  63. package/skills/video-studio/references/style-packs.md +0 -283
@@ -1,14 +1,14 @@
1
1
  # -*- coding: utf-8 -*-
2
- """render.py — 一条命令从剧本到成片(v0.5.0)。
2
+ """render.py — 一条命令从剧本到成片。
3
3
 
4
4
  python render.py --script film.json --scenes scenes.py --out out/film.mp4 [--stage all]
5
5
 
6
6
  阶段(每个 stage 只做自己的事,不顺手重渲全片):
7
- style 解析风格包 -> work/style.json(画风/配乐/旁白/节奏一次定死,v0.5)
7
+ style 解析设计 -> work/style.json(画风/配乐/旁白/节奏一次定死)
8
8
  tts 逐句合成旁白 + 实测时长(带输入校验的缓存;按声线计划换人换语气)
9
9
  layout 由实测时长排时间轴 -> timeline.json / sub.srt / sub.ass(含停留窗口 hold)
10
10
  cues 画面提示点 -> work/cues.json(web 引擎从页面导出,pil 引擎从 film.json 生成)
11
- score 程序化配乐 -> work/music.wav(scorekit;无则跳过)
11
+ score 参数化配乐 -> work/music.wav(musicgen;缺了直接报错,不再回退模板引擎)
12
12
  sfx 按提示点合成音效轨 -> work/sfx.wav(sfxkit;无则跳过)
13
13
  audio 三总线混音(旁白 + 配乐 + 音效,音效响起时配乐让位)-> audio.wav
14
14
  frames 逐帧渲染 -> silent.mp4(--engine pil|web;分块并行、断点续跑)
@@ -22,9 +22,9 @@
22
22
  --stage all 按顺序跑完;单跑某个 stage 时,若前置产物缺失会明确报错并提示先跑哪个。
23
23
  --engine 覆盖 film.json 的 engine:pil = Pillow 逐帧(默认,依赖最少);web = 无头浏览器逐帧。
24
24
 
25
- v0.6.0 起每个 stage 都往「门禁账本」记结论(pass / fail / skipped 三档),并在
25
+ 每个 stage 都往「门禁账本」记结论(pass / fail / skipped 三档),并在
26
26
  --stage qa(或 --stage all 收尾)写出 out/QC-REPORT.md:这是**机器可读的交付结论**,
27
- 取代"agent 在对话里复述质检结果"。人工签核走 --stage accept,绑内容指纹——
27
+ 而不是"agent 在对话里复述质检结果"。人工签核走 --stage accept,绑内容指纹——
28
28
  改了剧本 / scenes.py / 引擎,旧签核自动失效。
29
29
  """
30
30
  from __future__ import annotations
@@ -68,7 +68,7 @@ def _setup_stdio():
68
68
 
69
69
  只在"非交互"时切(isatty=False):Windows 下 python render.py ... | tee out.log
70
70
  的 stdout 默认是 GBK 代码页,日志里中文全是乱码;真实控制台保持系统编码,
71
- 交互运行时中文照样正常。与 scorekit / webengine 的入口处理保持同一口径。
71
+ 交互运行时中文照样正常。与 musicgen / webengine 的入口处理保持同一口径。
72
72
  """
73
73
  for stream in (sys.stdout, sys.stderr):
74
74
  try:
@@ -111,7 +111,7 @@ def _frames_plan_stamp(tl, script, scenes_path, chunk):
111
111
  script=hashlib.sha1(body.encode("utf-8")).hexdigest()[:16])
112
112
  return hashlib.sha1(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:16]
113
113
 
114
- # 可选模块(v0.3.0):缺哪个就明确报错/跳过,不影响老流程
114
+ # 可选模块:缺哪个就明确报错/跳过,不影响主流程
115
115
  HERE_SCRIPTS = str(HERE)
116
116
 
117
117
 
@@ -147,8 +147,11 @@ def _web_entry(script):
147
147
  if not os.path.exists(entry):
148
148
  raise SystemExit(
149
149
  "[缺少 web 页面] %s\n"
150
- " 把 <SKILL_DIR>/assets/web/ 整个目录拷到项目里(cp -r <SKILL_DIR>/assets/web ./web),"
151
- "或在 film.json 里写 web.entry 指向你自己的页面。" % entry)
150
+ " web 引擎需要**你自己的页面**(本技能不提供模板)。\n"
151
+ " PIL 路线不需要页面,零拷贝就能出片(--engine pil)。\n"
152
+ " 想先跑通 web 链路可拷示例页:\n"
153
+ " cp -r <SKILL_DIR>/assets/web ./web # 示例页只有 3 段占位场景,不是出片模板\n"
154
+ " 或在 film.json 里写 web.entry 指向你自己的页面。" % entry)
152
155
  return entry
153
156
 
154
157
 
@@ -174,7 +177,7 @@ def _web_where(entry):
174
177
 
175
178
  # ---------------------------------------------------------------- 装载
176
179
  def load_module(path: str, name="film_scenes"):
177
- """每次都用独立模块名 + 全新 module 对象,避免跨片污染(历史坑:渲染出上一部片)。"""
180
+ """每次都用独立模块名 + 全新 module 对象,避免跨片污染(否则会渲染出上一部片)。"""
178
181
  path = str(Path(path).resolve())
179
182
  spec = importlib.util.spec_from_file_location(name, path)
180
183
  mod = importlib.util.module_from_spec(spec)
@@ -203,7 +206,7 @@ def load_script(p) -> dict:
203
206
  for k in ("music", "sfx", "web"):
204
207
  if s.get(k) is not None and not isinstance(s.get(k), dict):
205
208
  raise SystemExit("film.json 的 %s 必须是对象(当前是 %s)" % (k, type(s[k]).__name__))
206
- # v0.5:bpm 允许 "auto"(由 stylekit 按风格梯度 + 内容指纹决定)。
209
+ # bpm 允许 "auto"(由风格梯度 + 内容指纹决定)。
207
210
  # 同时允许写在 music.bpm 里(优先级更高)。
208
211
  for _k, _v in (("bpm", s.get("bpm")),
209
212
  ("music.bpm", (s.get("music") or {}).get("bpm"))):
@@ -257,7 +260,7 @@ def _to_gif(mp4, gif, fps=12, width=640):
257
260
 
258
261
 
259
262
  def _atomic_json_retry(path, obj, tries=3, sleep_s=0.1):
260
- """原子写 JSON + 有限退避(v0.8)。
263
+ """原子写 JSON + 有限退避。
261
264
 
262
265
  Windows 上文件被杀毒/预览进程短暂占用时,一次写入失败会让**整片**跑挂。
263
266
  只对"可重试的写状态"退避,不对内容生成盲目重试(那会掩盖真错误)。
@@ -285,64 +288,141 @@ def _need(cond, msg):
285
288
  raise SystemExit("[前置产物缺失] " + msg)
286
289
 
287
290
 
288
- # ---------------------------------------------------------------- 风格解析(v0.5.0)
291
+ # ---------------------------------------------------------------- 风格解析
289
292
  def _stylekit():
290
293
  """惰性 import stylekit;缺文件时给一句能照着做的错,而不是 ModuleNotFoundError。"""
291
294
  try:
292
295
  import stylekit as ST
293
296
  return ST
294
297
  except Exception as e: # noqa: BLE001
295
- raise SystemExit("[缺少 stylekit] v0.5 起 render.py 需要同目录的 stylekit.py"
298
+ raise SystemExit("[缺少 stylekit] render.py 需要同目录的 stylekit.py"
296
299
  "(skills/video-studio/scripts/stylekit.py):%s" % e)
297
300
 
298
301
 
302
+ def _design_path(work):
303
+ return os.path.join(str(work), "design.json") if work else ""
304
+
305
+
306
+ def _plan_from_design(script, work, verbose=True):
307
+ """如果 work/design.json 存在(用户已确认提案),**以它为准**解析计划。
308
+
309
+ 这是唯一主路径:design.json 是"这一版长什么样"的冻结点,剧本只提供台词。
310
+ 返回 None 表示没有 design(调用方按 fail-closed 报错并给出两步指引)。
311
+ """
312
+ direct = script.get("design")
313
+ design = None
314
+ if isinstance(direct, dict):
315
+ design = direct
316
+ else:
317
+ p = _design_path(work)
318
+ if p and os.path.exists(p):
319
+ try:
320
+ with open(p, encoding="utf-8") as fh:
321
+ design = json.load(fh)
322
+ except (OSError, json.JSONDecodeError) as e:
323
+ raise SystemExit("[design] %s 读不出来:%s(重新 propose.py --confirm 一次)"
324
+ % (p, e)) from e
325
+ if not isinstance(design, dict):
326
+ return None
327
+ try:
328
+ import designkit as DK
329
+ except ImportError as e:
330
+ raise SystemExit("[design] 缺少 designkit.py(%s)——design.json 需要它解析成计划" % e) from e
331
+ # 骨架版/半成品 designkit 会 import 成功但接口没实现:那种情况必须给一句人话,
332
+ # 不能让 AttributeError 的 traceback 冒充"渲染器坏了"(这种误报踩过多次)。
333
+ missing = [n for n in ("validate_design", "design_to_plan")
334
+ if not callable(getattr(DK, n, None))]
335
+ if missing:
336
+ raise SystemExit("[design] designkit.py 不完整:缺 %s(这个文件还没实现完)"
337
+ % "、".join(missing))
338
+ bad = DK.validate_design(design)
339
+ if bad:
340
+ raise SystemExit("[设计体检失败] %s\n - %s"
341
+ % (design.get("id") or "(无 id)", "\n - ".join(bad)))
342
+ plan = DK.design_to_plan(design)
343
+ _apply_audio_overrides(script, plan)
344
+ if verbose:
345
+ print(" 设计: %s(%s)" % (plan.get("style"), (design.get("pitch") or "")[:60]))
346
+ return plan
347
+
348
+
349
+ def _apply_audio_overrides(script, plan):
350
+ """把 design.audio 里的音量/音效参数填进 script 的 music/sfx **缺省位**。
351
+
352
+ 为什么需要:design.json 里 audio.music.gain_db / duck_db 与 audio.sfx.enabled /
353
+ density / gain_db 若只被 validate_design 接受、没有任何渲染代码读它,
354
+ 用户改了它们,成片一模一样(audit 实测:enabled=false 仍出 8 条音效)。
355
+
356
+ 优先级(明确且可预期):**film.json 显式写了的 > design 的值 > 模块内置默认**。
357
+ 只填缺省位,绝不覆盖剧本里的显式写法——用户手里的 film.json 永远是最终裁决权。
358
+ """
359
+ ov = plan.get("audio_overrides") if isinstance(plan, dict) else None
360
+ if not isinstance(ov, dict):
361
+ return
362
+ mus_ov = ov.get("music") if isinstance(ov.get("music"), dict) else {}
363
+ sfx_ov = ov.get("sfx") if isinstance(ov.get("sfx"), dict) else {}
364
+ mus = script.get("music")
365
+ if mus_ov:
366
+ if not isinstance(mus, dict):
367
+ mus = {}
368
+ script["music"] = mus
369
+ for k, v in mus_ov.items():
370
+ mus.setdefault(k, v)
371
+ sfx = script.get("sfx")
372
+ if sfx_ov:
373
+ if not isinstance(sfx, dict):
374
+ sfx = {}
375
+ script["sfx"] = sfx
376
+ for k, v in sfx_ov.items():
377
+ sfx.setdefault(k, v)
378
+
379
+
299
380
  def resolve_style(script, work=None, verbose=True):
300
381
  """解析风格计划并挂到 script["_style"] 上(同一进程内各 stage 共享)。
301
382
 
383
+ 优先级:
384
+ 1. script["_style"] 已存在(同进程内各 stage 复用)
385
+ 2. work/design.json(用户已确认的提案)—— 这是唯一正式主路径
386
+ 3. 都没有 -> fail-closed 报错(没有内置风格可回落)
387
+
302
388
  纯函数 + 落盘 work/style.json(审计用:用户能看到"为什么是这套风格")。
303
389
  """
304
390
  if isinstance(script.get("_style"), dict):
305
391
  return script["_style"]
306
- ST = _stylekit()
307
- plan = ST.resolve_script_style(script)
308
- # 主题体检:坏主题现在拦,不要等渲完 300 帧。
309
- # v0.7.2 修:以前只体检**基础主题**(ST.THEMES[theme_id]),而 scenes 实际用的是
310
- # plan["theme"](= 基础主题 + film.json 的 visual.theme_overrides)。于是用 overrides
311
- # 把 ink/bg 调成低对比度时,体检照样 PASS,渲完才发现字幕看不清。
312
- # 实测:overrides ink=[30,30,30] / bg=[10,10,10] -> 基础主题 lint 为空,
313
- # 最终主题 lint = ['ink 与背景对比度 1.09 < 7.0']。
314
- bad = ST.theme_lint(plan.get("theme") or ST.THEMES[plan["theme_id"]])
315
- if bad:
316
- raise SystemExit("[主题体检失败] %s:%s" % (plan["theme_id"], ";".join(bad)))
317
- # v0.9:结构签名(视觉语言包)随计划一起解析,并同样在 --stage style 就体检。
318
- # 为什么不能等渲染:装饰母题名写错/转场时长越界,都要等渲到第 300 帧才发现,
319
- # 而这两类错误恰恰会让整片观感崩掉(转场 1 秒 = 每段都在"糊过去")。
320
- try:
321
- plan["language"] = ST.visual_language(plan["style"])
322
- vbad = ST.visual_lint(plan["style"])
323
- except AttributeError:
324
- # 老 stylekit(没有 VISUAL_LANGUAGES)-> 不阻塞渲染,scenes.py 会退到默认语言
325
- plan["language"] = None
326
- vbad = []
327
- if vbad:
328
- raise SystemExit("[视觉语言体检失败] %s:%s" % (plan["style"], ";".join(vbad)))
329
- script["_style"] = plan
330
- if work and verbose:
331
- path = os.path.join(work, "style.json")
332
- try:
333
- os.makedirs(work, exist_ok=True)
334
- json.dump(plan, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
335
- except Exception as e: # noqa: BLE001
336
- print(" [WARN] style.json 落盘失败:%s" % e)
337
- return plan
338
-
339
-
340
- def _theme_for(script):
341
- """返回当前片的主题字典;legacy 返回 None(= v0.4 的硬编码外观,逐字节兼容)。"""
392
+ plan = _plan_from_design(script, work, verbose=verbose)
393
+ if plan is not None:
394
+ script["_style"] = plan
395
+ if work and verbose:
396
+ try:
397
+ os.makedirs(work, exist_ok=True)
398
+ with open(os.path.join(work, "style.json"), "w", encoding="utf-8") as fh:
399
+ json.dump(plan, fh, ensure_ascii=False, indent=1)
400
+ except Exception as e: # noqa: BLE001
401
+ print(" [WARN] style.json 落盘失败:%s" % e)
402
+ return plan
403
+ # **没有第三优先级**:回落到"按关键词自动选风格"等于模板库,
404
+ # 那正是本技能不提供的,所以必须明确报错,不静默回落。
405
+ raise SystemExit(
406
+ "[style] 找不到设计(work/design.json):\n"
407
+ " 本技能不提供任何内置风格,每一版外观都必须现场生成并由人确认。\n"
408
+ " 正确做法:\n"
409
+ " 1) python propose.py --brief \"题材/要求\" --script film.json # 出 3 个方案\n"
410
+ " 2) python propose.py --confirm --candidate A # 冻结设计\n"
411
+ " 3) python render.py --script film.json --stage all # 出片\n"
412
+ " (work=%r)" % (work,))
413
+
414
+
415
+ def _theme_for(script, work=None):
416
+ """返回当前片的主题字典;legacy 计划返回 None(现存的兼容分支)。
417
+
418
+ work **必须传**:design 路径的身份在 work/design.json 里,不传就直接 SystemExit ——
419
+ 但"传错 work"仍然值得防:work 指到别的目录时会读到**另一份** design.json,
420
+ 参考帧与成片不同源(实测:--stage qa 单独跑 VMAF 1.07,全部内联跑 97.15)。
421
+ """
342
422
  plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
343
423
  if plan is not None and plan.get("legacy"):
344
424
  return None
345
- plan = resolve_style(script)
425
+ plan = resolve_style(script, work)
346
426
  if plan.get("legacy"):
347
427
  return None
348
428
  # plan["theme"] 是 stylekit 合并过 film.json 的 visual.theme_overrides 之后的最终主题,
@@ -353,7 +433,21 @@ def _theme_for(script):
353
433
  th = plan.get("theme")
354
434
  if isinstance(th, dict):
355
435
  return th
356
- return _stylekit().THEMES[plan["theme_id"]]
436
+ # **先看 plan["theme"]**(designkit 生成的计划自带完整 35 字段主题)。
437
+ # 不能按 theme_id 去查表:没有内置主题表,设计路径的 theme_id(如 "a-44957fb5")
438
+ # 查不到 -> KeyError,整条出片链路会在 layout 阶段崩掉。
439
+ th = plan.get("theme")
440
+ if isinstance(th, dict) and th:
441
+ return th
442
+ # 没有 theme 计划就是"这份 script 没走过 design/propose"。
443
+ # 明确报错并给迁移指引,不静默回落(静默回落 = 用一套不存在的默认外观出片)。
444
+ raise SystemExit(
445
+ "[style] 这份剧本没有可用的主题:\n"
446
+ " 计划里缺 theme 字段,而内置主题表已在本版删除。\n"
447
+ " 正确做法:python propose.py --brief \"题材\" --script film.json # 出方案\n"
448
+ " python propose.py --confirm --candidate A # 冻结 design.json\n"
449
+ " python render.py --script film.json --stage all\n"
450
+ " (theme_id=%r)" % (plan.get("theme_id"),))
357
451
 
358
452
 
359
453
  def _print_style(plan):
@@ -365,7 +459,7 @@ def _print_style(plan):
365
459
  print(" 主题: %s 版式: %s" % (plan["theme_id"],
366
460
  ",".join(sorted(set(plan["chapter_layout"].values())))
367
461
  or plan["layout_default"]))
368
- # v0.9:视觉语言(结构签名)也要能在这里看到 —— 用户判断"这套风格是不是只是换色"
462
+ # 视觉语言(结构签名)也要能在这里看到 —— 用户判断"这套风格是不是只是换色"
369
463
  # 的第一现场就是 --stage style 的打印,藏起来等于让他去读 style.json。
370
464
  vl = plan.get("language") if isinstance(plan.get("language"), dict) else None
371
465
  if vl:
@@ -377,8 +471,24 @@ def _print_style(plan):
377
471
  % (mf.get("kind"), tr.get("kind"), float(tr.get("dur") or 0.0),
378
472
  float(ty.get("tracking") or 0.0), ty.get("number"),
379
473
  float(bi.get("x") or 0.0), float(bi.get("y") or 0.0)))
474
+ # 不能无条件读 m["style"]:designkit 明确不写这个键(配乐风格表已删),
475
+ # 那样公开命令 `--stage style` 会 100% KeyError: "style"。
476
+ # 按「参数化配乐」的真实身份打印:调式 + BPM + 调性 + mood。
477
+ _music_id = m.get("arranger") or m.get("style") or ("%s %gBPM" % (m.get("scale", "?"), m.get("bpm", 0)))
380
478
  print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
381
- % (m["style"], m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
479
+ % (_music_id, m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
480
+ _sub = plan.get("subtitle") if isinstance(plan.get("subtitle"), dict) else None
481
+ if _sub:
482
+ _y = _sub.get("y")
483
+ print(" 字幕: %s %dpx 对齐=%s 背板α=%d 圆角=%d 最多%d行"
484
+ % ("y=%s" % _y if _y is not None else "底部安全区",
485
+ int(_sub.get("size") or 46), _sub.get("align") or "center",
486
+ int(_sub.get("plate_alpha") if _sub.get("plate_alpha") is not None else 170),
487
+ int(_sub.get("radius") if _sub.get("radius") is not None else 10),
488
+ int(_sub.get("max_lines") or 2)))
489
+ _cyc = plan.get("layout_cycle") or []
490
+ if len(_cyc) >= 2:
491
+ print(" 版式轮换: %s(按章节轮换,防全片一张版式)" % " -> ".join(map(str, _cyc)))
382
492
  if v["map"]:
383
493
  print(" 旁白: %s" % " ".join("%s=%s" % kv for kv in sorted(v["map"].items())))
384
494
  p = plan["pacing"]
@@ -395,11 +505,11 @@ def st_style(script, work):
395
505
 
396
506
  # ---------------------------------------------------------------- TTS
397
507
  def _voice_plan(script, plan):
398
- """声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch 走 v0.4 单声线路径。"""
508
+ """声线计划(voicekit)。缺 voicekit 时返回 None -> tts_batch 走单声线路径。"""
399
509
  try:
400
510
  import voicekit as VK
401
511
  except Exception as e: # noqa: BLE001
402
- print(" [WARN] 没有 voicekit.py,本次旁白单声线(v0.4 行为):%s" % e)
512
+ print(" [WARN] 没有 voicekit.py,本次旁白单声线(单声线兼容):%s" % e)
403
513
  return None
404
514
  try:
405
515
  vp = VK.voice_plan(script, plan)
@@ -414,7 +524,7 @@ def st_tts(script, work, force=False):
414
524
  plan = resolve_style(script, work)
415
525
  print("== TTS (%d 句)" % len(lines))
416
526
  if plan["legacy"]:
417
- print(" 声线: v0.4 兼容(单一声线 %s)" % script.get("voice", "zh-CN-YunxiNeural"))
527
+ print(" 声线: 兼容模式(单一声线 %s)" % script.get("voice", "zh-CN-YunxiNeural"))
418
528
  vp = None
419
529
  else:
420
530
  vp = _voice_plan(script, plan)
@@ -449,7 +559,7 @@ def st_tts(script, work, force=False):
449
559
 
450
560
 
451
561
  def _write_words(work, done, want_words):
452
- """把逐句词级时间戳汇总成 work/words.json(v0.8)。
562
+ """把逐句词级时间戳汇总成 work/words.json。
453
563
 
454
564
  结构:{schema, enabled, lines:{<id>:{dur, n, covered, tail, mono, words:[...]}}, stats}
455
565
  门禁与字幕都从这里读,不再各自去翻 *.mp3.json(单一事实来源)。
@@ -488,7 +598,7 @@ def st_layout(script, work, lines=None):
488
598
  print("== Layout")
489
599
  pace = plan["pacing"]
490
600
  # 逐句 lead/tail:voicekit 的 voice_plan 已经算好每句的停顿,这里只负责喂给时间轴。
491
- # 旧项目(legacy)没有 voice_plan.json,lead_map/tail_map 都是 None -> 全局常数,v0.4 行为。
601
+ # legacy 计划没有 voice_plan.json,lead_map/tail_map 都是 None -> 全局常数。
492
602
  vp = None
493
603
  vp_path = os.path.join(work, "voice_plan.json")
494
604
  if not plan["legacy"] and os.path.exists(vp_path):
@@ -519,7 +629,7 @@ def st_layout(script, work, lines=None):
519
629
  ",编码时 lanczos 缩放" if vp["scale"] else ""))
520
630
  tl["title"] = script["title"]
521
631
  tl["voice"] = script.get("voice", "zh-CN-YunxiNeural")
522
- # v0.8:词级时间戳(句子时长仍是权威;词只用于字幕断行/锚点/门禁)
632
+ # 词级时间戳(句子时长仍是权威;词只用于字幕断行/锚点/门禁)
523
633
  tl["words"] = bool(script.get("words", True))
524
634
  wp = os.path.join(work, "words.json")
525
635
  if tl["words"] and os.path.exists(wp):
@@ -535,8 +645,8 @@ def st_layout(script, work, lines=None):
535
645
  "legacy": plan["legacy"],
536
646
  "chapter_layout": plan["chapter_layout"],
537
647
  "item_stagger": pace["item_stagger"]}
538
- # ---- v0.9 场景语法:内容 -> 构图选型(落 timeline.scenes[] 与 work/storyboard.json) ----
539
- # legacy 不注入:v0.4 的三个老绘制器只读 kind,注入新字段等于给基线加噪声。
648
+ # ---- 场景语法:内容 -> 构图选型(落 timeline.scenes[] 与 work/storyboard.json) ----
649
+ # legacy 不注入:兼容绘制器只读 kind,注入新字段等于给基线加噪声。
540
650
  if not plan["legacy"]:
541
651
  try:
542
652
  import grammar as G
@@ -559,7 +669,9 @@ def st_layout(script, work, lines=None):
559
669
  json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
560
670
  ensure_ascii=False, indent=1)
561
671
  n_srt = K.write_srt(tl["scenes"], os.path.join(work, "sub.srt"))
562
- theme = (K._stylekit().THEMES[plan["theme_id"]] if not plan["legacy"] else None)
672
+ # 同上:设计计划自带 theme,不能假定 theme_id 一定在内置表里(见 _theme_for 的注释)
673
+ # 没有内置主题表:theme 必须由计划自带(design 路径一定有)
674
+ theme = (plan.get("theme") if not plan["legacy"] else None)
563
675
  n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
564
676
  print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
565
677
  % (tl["total"], tl["frames"], len(tl["scenes"]), n_srt, n_ass))
@@ -575,6 +687,26 @@ def st_layout(script, work, lines=None):
575
687
  print(" %-6s %-7s %r" % (lid, fld, chars))
576
688
  for s in tl["scenes"]:
577
689
  print(" %-8s %7.2f-%7.2f %s" % (s["id"], s["start"], s["end"], s["text"][:40]))
690
+ # ---- 目标时长对账 ----
691
+ # brief 里写的「2 分钟 / 60 秒」只影响 design.structure.segments 与 target_sec,
692
+ # 而成片时长由 TTS 实测决定 —— 用户以为"我要求了时长",实际拿到 23s。
693
+ # 不改时长(截断旁白或拉长留白都是坏做法),但**必须当场说出来差多少**,
694
+ # 并给出可执行的做法(加台词 / 调 hold / 减台词)。
695
+ target = (plan or {}).get("target_sec")
696
+ if target:
697
+ try:
698
+ target = float(target)
699
+ except (TypeError, ValueError):
700
+ target = None
701
+ if target and target > 0:
702
+ diff = float(tl["total"]) - target
703
+ pct = diff / target * 100.0
704
+ print(" 目标时长 %.1fs / 实际 %.2fs(%+.1fs,%+.0f%%)"
705
+ % (target, tl["total"], diff, pct))
706
+ if abs(pct) > 15.0:
707
+ print(" [WARN] 与目标时长差超过 15%:成片时长由**实测旁白**决定,不收口就要改文案。"
708
+ "\n 想变长:加台词或调大 film.json 的 hold / lead / tail;"
709
+ "\n 想变短:删减台词(hold 只会加留白,不会缩短旁白)。")
578
710
  return tl
579
711
 
580
712
 
@@ -614,6 +746,15 @@ def st_cues(script, work, tl, engine="pil"):
614
746
  # scenes.py 里的 cues() 钩子:允许直接返回 [{at,type,...}] 或调用 bus.add(...)
615
747
  n_hook = 0
616
748
  mod = None
749
+ # scenes.py 的 cues() 常要读主题/视觉语言(assets 版就调 resolve_script_style)。
750
+ # 单跑 `--stage cues` 时前面没有 stage 把 script["_style"] 挂好,钩子就会去解析"设计"
751
+ # 并直接 raise —— 公开命令 100% 崩,报错还指向"先去提案"(其实设计已确认)。
752
+ # 所以在调用钩子前解析一次(幂等,已挂 _style 时直接复用)。
753
+ if script.get("_scenes") and os.path.exists(script["_scenes"]) and not isinstance(script.get("_style"), dict):
754
+ try:
755
+ script["_style"] = resolve_style(script, work)
756
+ except SystemExit:
757
+ pass # 没设计:cues 阶段不因它失败,钩子里真需要会自己报错
617
758
  if script.get("_scenes") and os.path.exists(script["_scenes"]):
618
759
  try:
619
760
  mod = load_module(script["_scenes"])
@@ -626,7 +767,7 @@ def st_cues(script, work, tl, engine="pil"):
626
767
  except Exception as e: # noqa: BLE001
627
768
  raise SystemExit("scenes.py 的 cues() 钩子报错:%s" % e) from e
628
769
  n_hook = _extend_safe(got, "scenes.py")
629
- # v0.7.2:web 引擎下要提醒"两套时间基准混用"。
770
+ # web 引擎下要提醒"两套时间基准混用"。
630
771
  # scenes.py 的 cues() 是按 **PIL 时间轴**(timeline.json 的段落与 visual_at)算的,
631
772
  # 页面动画则按 **页面自己的时间轴**(window.DURATION)。两者合并进同一份 cues.json 后:
632
773
  # - 音效会按 PIL 时间响(页面画面此刻可能正在另一幕);
@@ -683,7 +824,7 @@ def _st_cues_web(script, work):
683
824
  tl = json.load(open(tp, encoding="utf-8"))
684
825
  except Exception: # noqa: BLE001
685
826
  tl = {}
686
- style = _web_style_payload(we, script, tl)
827
+ style = _web_style_payload(we, script, tl, work)
687
828
  vp = (tl.get("video") or {})
688
829
  data = we.export_cues(entry, style=style,
689
830
  width=int(vp.get("out_w") or K.W),
@@ -693,33 +834,18 @@ def _st_cues_web(script, work):
693
834
 
694
835
 
695
836
  # ---------------------------------------------------------------- 配乐 / 音效
696
- def st_score(script, work, tl):
697
- """程序化配乐 -> work/music.wav(scorekit 不在就跳过,不阻断出片)。
837
+ def _score_with_musicgen(script, work, tl, plan_s, mus):
838
+ """用参数化编排器出成片配乐(与 previewkit 的样音同一个引擎、同一组参数)。
698
839
 
699
- 配乐来源由 K.music_plan 统一判定(与 st_audio 共用同一份语义):
700
- - mode=off/none 或 enabled=false:不出配乐,也不删已存在的 music.wav
701
- (删文件会误删"外部提供的配乐",而 off 的语义只是"本片不要配乐");
702
- - mode=bed:走 v0.2 和弦床,不生成 scorekit 产物;
703
- - mode=file:外部 work/music.wav,原样交给 audio 阶段;
704
- - 缺省:有 bpm 就生成,没有 bpm 沿用已有 music.wav / 和弦床(v0.2 兼容)。
840
+ 为什么必须走参数化编排:本技能不提供风格表/模板,配乐必须由设计里的参数现场生成。
841
+ musicgen 的六个参数(progressions / motif / drums / voices / mood / sections)
842
+ 全部参与事件展开,实测参数相关性 seed 0.16 / mood 0.32 / voices 0.22,无一是死旋钮。
705
843
  """
706
- print("== Score")
707
- mus = script.get("music") or {}
708
- music_path = os.path.join(work, "music.wav")
709
- plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
710
- print(" " + why)
711
- if plan in ("off", "bed", "file", "none"):
712
- return None
713
- if plan in ("missing-file", "missing-bpm", "unknown-mode"):
714
- raise SystemExit("[score] " + why)
715
- sk = _try_module("scorekit")
716
- if sk is None:
717
- print(" [WARN] 没有 scorekit.py,跳过配乐(audio 阶段没有 music.wav,本片将无配乐)")
718
- return None
719
- anchors = K.scenes_anchors(tl["scenes"])
844
+ print("== Score(musicgen 参数化编排)")
845
+ mg = _try_module("musicgen")
846
+ mp = plan_s["music"]
720
847
  sections = mus.get("sections")
721
848
  if sections:
722
- # film.json 允许用 id 表述分段起点,换成绝对秒
723
849
  fixed = []
724
850
  for s in sections:
725
851
  s2 = dict(s)
@@ -727,76 +853,103 @@ def st_score(script, work, tl):
727
853
  s2["start"] = K.cue_time(s2.pop("at"), tl["scenes"])
728
854
  fixed.append(s2)
729
855
  sections = fixed
730
- plan_s = resolve_style(script)
731
- mp = plan_s["music"]
732
- t0 = time.time()
733
- style_arg = None if plan_s["legacy"] else str(mp.get("style") or plan_s["style"])
734
- kwargs = dict(bpm=float(mp["bpm"]), key=str(mp["key"] or "Dm"), anchors=anchors,
735
- sections=sections, seed=int(mp["seed_effective"]))
736
- # v0.7:内容指纹参与配乐结构变奏(和弦替换 / 动机级数 / 鼓型 ghost,见
737
- # references/audio-variation.md §3.3)。只在非 legacy 时传——不传 fingerprint 的
738
- # scorekit 认不出这个 kwarg,会走下面的 TypeError 回退链,照样出片只少变奏。
739
- # legacy 必须不传:v0.4 的逐样本兼容是硬契约(selfcheck 20/26 条守着)。
740
- fp_hex = ""
741
- if not plan_s["legacy"]:
742
- fp_hex = str((plan_s.get("fingerprint") or {}).get("full") or "")
743
- kw_fp = dict(kwargs, fingerprint=fp_hex) if fp_hex else kwargs
744
- # scorekit 0.5 起:style/mood + music_plan(一次把 bpm/key/mood/seed 全交出去,
745
- # 免得两处默认值打架)+ pace(把过碎的段落并进前一段)。
746
- # 旧 scorekit 没有这些 kwargs -> TypeError -> 自动退回老接口,不阻断出片。
747
- # 四级回退:带指纹 -> 不带指纹 -> 只有 style/mood -> 最老的签名。
748
- pace = {"min_dur": float(plan_s["pacing"]["min_dur"]),
749
- "hold": float(plan_s["pacing"]["hold"])}
750
- mood_arg = str(mp.get("mood") or "steady")
751
- samples = report = None
752
- # v0.7.2:多级签名兼容链以前对**任何** TypeError 都继续换签名试,最后统一报
753
- # "不接受任何一种已知签名"。scorekit 内部真出 TypeError(例如某个参数是 None 被拿去做
754
- # 算术)时,用户会照着"签名不兼容"的方向白找半天。这里区分两类:
755
- # - 报错文本含 "unexpected keyword argument" -> 确实是不认识这个参数,换签名重试;
756
- # - 其它 TypeError -> 是**内部**错误,带上签名与错误信息立刻报,别吞。
757
- attempts = (
758
- dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kw_fp),
759
- dict(style=style_arg, mood=mood_arg, music_plan=mp, pace=pace, **kwargs),
760
- dict(style=style_arg, mood=mood_arg, **kwargs),
761
- dict(**kwargs),
856
+ kwargs = dict(
857
+ bpm=float(mp["bpm"]),
858
+ key=str(mp["key"] or "Dm"),
859
+ scale=str(mp.get("scale") or "minor"),
860
+ mood=str(mp.get("mood") or "steady"),
861
+ # seed_effective 是"内容指纹掺进去之后"的 32 位大数;musicgen 内部按
862
+ # sha1(seed) 派生,直接给大整数没问题,但统一夹到 2^31-1 内可以保证
863
+ # 取模口径固定(既有 work/ 目录里的 score.json 能对上)。
864
+ seed=int(mp.get("seed_effective") or mp.get("seed") or 0) % (2 ** 31 - 1),
865
+ sections=sections,
762
866
  )
763
- for attempt in attempts:
764
- try:
765
- samples, report = sk.render(float(tl["total"]), **attempt)
766
- break
767
- except TypeError as err:
768
- if "unexpected keyword argument" in str(err):
769
- continue
770
- raise SystemExit(
771
- "[score] scorekit.render() 内部报错(不是签名不兼容,别去改调用参数):%s\n"
772
- " 本次签名:%s\n 要看完整 traceback:set RENDER_DEBUG=1 后重跑"
773
- % (err, ", ".join(sorted(k for k in attempt if k != "self")))
774
- ) from err
775
- if samples is None:
776
- raise SystemExit("[score] scorekit.render() 不接受任何一种已知签名,无法出配乐"
777
- "(试过 %d 种:从最全的参数到最简)" % len(attempts))
778
- if report and not plan_s["legacy"]:
779
- print(" 配乐风格: %s / mood %s / %s BPM / %s"
780
- % (report.get("style") or style_arg, mp.get("mood"),
781
- report.get("bpm"), report.get("key")))
867
+ # 设计里显式给出的编排参数原样透传(progressions/motif/drums/voices/energy_curve)。
868
+ # 只在键存在时传:musicgen 对 None 与缺省的处理不同(缺省=自己按 seed 派生)。
869
+ for key in ("progressions", "motif", "drums", "voices", "energy_curve"):
870
+ if mp.get(key) is not None:
871
+ kwargs[key] = mp[key]
872
+ t0 = time.time()
873
+ try:
874
+ samples, report = mg.render(float(tl["total"]), **kwargs)
875
+ except Exception as e: # noqa: BLE001
876
+ raise SystemExit(
877
+ "[score] musicgen.render() 失败:%s\n"
878
+ " 参数:bpm=%s key=%s scale=%s mood=%s seed=%s\n"
879
+ " 这是配乐生成的真错误(不是签名不兼容)——先把参数改对,别改成静默跳过。"
880
+ % (e, kwargs.get("bpm"), kwargs.get("key"), kwargs.get("scale"),
881
+ kwargs.get("mood"), kwargs.get("seed"))) from e
782
882
  out = os.path.join(work, "music.wav")
783
- sk.save(out, samples)
883
+ mg.save(out, samples)
784
884
  if report:
785
885
  json.dump(report, open(os.path.join(work, "score.json"), "w", encoding="utf-8"),
786
886
  ensure_ascii=False, indent=1)
887
+ print(" musicgen: %s %s / %s BPM / %s / mood %s / seed %s"
888
+ % (report.get("key_name") or report.get("key"), report.get("scale"),
889
+ report.get("bpm"), "", report.get("mood"), report.get("seed")))
787
890
  samples_read, _sr = K.wav_read(out)
788
891
  print(" music.wav %.2fs 峰值 %.3f %.1fs" % (
789
892
  K.wav_dur(out), float(np.max(np.abs(samples_read))), time.time() - t0))
790
893
  return out
791
894
 
792
895
 
793
- def _sfx_seed_context(script):
794
- """v0.7:算音效的内容派生上下文 (style, content_fp, key, enabled)。
896
+ def st_score(script, work, tl):
897
+ """参数化配乐 -> work/music.wav(musicgen 现场编排,没有模板可回退)。
898
+
899
+ 配乐来源由 K.music_plan 统一判定(与 st_audio 共用同一份语义):
900
+ - mode=off/none 或 enabled=false:不出配乐,也不删已存在的 music.wav
901
+ (删文件会误删"外部提供的配乐",而 off 的语义只是"本片不要配乐");
902
+ - mode=bed:走和弦床,不生成 musicgen 产物;
903
+ - mode=file:外部 work/music.wav,原样交给 audio 阶段;
904
+ - 缺省:有 bpm 就生成,没有 bpm 沿用已有 music.wav / 和弦床。
905
+
906
+ 需要真的生成配乐时,**只有 musicgen 一条路**:legacy 计划或 musicgen.py 缺失都会
907
+ 明确报错并给迁移指引,不用 scorekit 的配乐模板兜底(零模板)。
908
+ """
909
+ print("== Score")
910
+ mus = script.get("music") or {}
911
+ music_path = os.path.join(work, "music.wav")
912
+ plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
913
+ print(" " + why)
914
+ if plan in ("off", "bed", "file", "none"):
915
+ return None
916
+ if plan in ("missing-file", "missing-bpm", "unknown-mode"):
917
+ raise SystemExit("[score] " + why)
918
+ # **配乐只有 musicgen 一条路**(参数化编排器,没有风格表)。
919
+ #
920
+ # 为什么必须同源:若 previewkit 用 musicgen 出 12 秒样音、而 st_score 用 scorekit
921
+ # 出成片音乐,两者是**两个不同的作曲器**,实测同一设计下相关系数 -0.0057(就是两首
922
+ # 不同的曲子)。用户看样音选方案,成片音乐完全不同,等于"确认"这个动作在音频上失效。
923
+ #
924
+ # scorekit 没有风格表(本技能不提供模板),它的 render() 只剩 legacy 逐样本兼容路径。
925
+ # 所以这里**没有 scorekit 回退**:scorekit 一旦回来当兜底,成片音乐就又变成一套
926
+ # 固定模板,与"每条视频都不一样"直接冲突。
927
+ plan_s0 = resolve_style(script, work)
928
+ if plan_s0.get("legacy"):
929
+ raise SystemExit(
930
+ "[score] legacy 配乐路径已移除(零模板):\n"
931
+ " scorekit 的 9 套配乐风格表已删除,legacy 回退会把成片配乐锁死成一套固定模板。\n"
932
+ " 正确做法(两步):\n"
933
+ " 1) python propose.py --brief \"题材/要求\" --script film.json # 出 3 个方案\n"
934
+ " 2) python propose.py --confirm --candidate A # 冻结 work/design.json\n"
935
+ " 3) python render.py --script film.json --stage score # 用 musicgen 现场编排\n"
936
+ " (想手写配乐参数:film.json 的 music.bpm / key / mood / sections 仍然有效)")
937
+ if _try_module("musicgen") is None:
938
+ raise SystemExit(
939
+ "[score] 找不到 musicgen.py,无法生成配乐:\n"
940
+ " 配乐只有参数化编排器一条路(scorekit 的风格模板已删除,不再兜底)。\n"
941
+ " 检查 <SKILL_DIR>/scripts/musicgen.py 是否存在(技能脚本就在技能目录里跑,不用拷);\n"
942
+ " 只需要旁白/音效时把 film.json 的 music.mode 设为 off 或 file。")
943
+ return _score_with_musicgen(script, work, tl, plan_s0, mus)
795
944
 
796
- legacy(没写 style 字段)时返回全 None -> CueBus 走旧的 seed_base + i 路径,
797
- v0.4 及以前的片子逐样本不变。参考 references/audio-variation.md §3.2 / §4。
945
+
946
+ def _sfx_seed_context(script, work=None):
947
+ """算音效的内容派生上下文 (style, content_fp, key, enabled)。
948
+
949
+ legacy(没写 style 字段)时返回全 None -> CueBus 走 seed_base + i 路径,
950
+ 逐样本与基线一致。参考 references/audio-variation.md §3.2 / §4。
798
951
  """
799
- plan = resolve_style(script)
952
+ plan = resolve_style(script, work)
800
953
  sfx_cfg = script.get("sfx") or {}
801
954
  want_content = bool(sfx_cfg.get("content_seed", True))
802
955
  if plan.get("legacy") or not want_content:
@@ -807,9 +960,9 @@ def _sfx_seed_context(script):
807
960
 
808
961
 
809
962
  def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2.2):
810
- """段落边界前的转场音效自动让位(v0.7,契约 §4)。
963
+ """段落边界前的转场音效自动让位(契约 §4)。
811
964
 
812
- 配乐在段落交界自己已经有一段过门(scorekit 的 swell / riser / sub_boom),
965
+ 配乐在段落交界自己已经有一段过门(编排器写的 swell / riser / sub_boom),
813
966
  sfx 轨再在同一时刻放 whoosh / riser,同一频段叠两层就是"配乐和音效听起来像同一套"。
814
967
  做法:对位于 [边界-window, 边界] 内的转场类 cue,在它的时长内乘一个 dB 增益包络。
815
968
  只压转场类,不碰 impact / 提示音(那是"该响"的语义重音)。
@@ -862,8 +1015,8 @@ def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2
862
1015
  def st_sfx(script, work, tl):
863
1016
  """按提示点合成音效轨 -> work/sfx.wav。没有 cues.json 就跳过。
864
1017
 
865
- v0.7:非 legacy 时走内容派生 seed(跨片同一位置不再逐样本相同),
866
- 并把配乐调性透传给提示音配方(不再硬编码 Dm);
1018
+ 非 legacy 时走内容派生 seed(跨片同一位置不逐样本相同),
1019
+ 并把配乐调性透传给提示音配方(不硬编码 Dm);
867
1020
  段落边界前的转场音效自动让位(不与配乐过门撞车)。
868
1021
  """
869
1022
  print("== SFX")
@@ -877,7 +1030,7 @@ def st_sfx(script, work, tl):
877
1030
  print(" cues.json 里没有提示点,跳过")
878
1031
  return None
879
1032
  sfx_cfg = script.get("sfx") or {}
880
- style, content_fp, key = _sfx_seed_context(script)
1033
+ style, content_fp, key = _sfx_seed_context(script, work)
881
1034
  try:
882
1035
  samples = bus.build_audio(total=tl["total"],
883
1036
  base_db=sfx_cfg.get("base_db"),
@@ -905,9 +1058,10 @@ def st_sfx(script, work, tl):
905
1058
  def _voice_track(tl, script):
906
1059
  """把逐句旁白铺成一条总线,返回 (n,) 单声道。
907
1060
 
908
- v0.5.0 修一个 v0.4 的静默 bug:voice_gain 原来在这里和 mix_film 里**各乘一次**
909
- (净效果 voice_gain^2,默认 1.0 时看不出来)。现在全局增益只在 mix_film 乘一次,
910
- 这里只负责**逐句**增益(voicekit 的 gain_db,默认 0 dB -> 1.0,与 v0.4 一致)。
1061
+ 全局增益只能在一处乘:voice_gain 若在这里和 mix_film 里**各乘一次**,
1062
+ 净效果是 voice_gain^2(默认 1.0 时看不出来,改了才炸)。
1063
+ 所以全局增益只在 mix_film 乘一次,这里只负责**逐句**增益
1064
+ (voicekit 的 gain_db,默认 0 dB -> 1.0)。
911
1065
  """
912
1066
  bus = K.AudioBus(tl["total"])
913
1067
  for s in tl["scenes"]:
@@ -919,7 +1073,7 @@ def _voice_track(tl, script):
919
1073
 
920
1074
 
921
1075
  def _legacy_music(tl, script):
922
- """v0.2 的和弦床(没有 scorekit / music.wav 时的兜底)。"""
1076
+ """和弦床(没有 music.wav 时的兜底)。"""
923
1077
  mus = script.get("music") or {}
924
1078
  if not (mus.get("enabled", True) and mus.get("chords")):
925
1079
  return None
@@ -935,11 +1089,11 @@ def _legacy_music(tl, script):
935
1089
  def st_audio(tl, work, script):
936
1090
  """三总线混音:旁白 + 配乐 + 音效 -> audio.wav(立体声)。
937
1091
 
938
- v0.3.0 起把三条总线分开处理再合:
1092
+ 三条总线分开处理再合:
939
1093
  - 旁白:单声道,居中(voice_gain 可调)
940
1094
  - 配乐:来源由 K.music_plan 判定(score/file/bed/off),口径与 st_score 完全一致——
941
1095
  music.mode=off 时一条配乐都不混,即使 work/music.wav 还在(外部配乐请用 mode=file)
942
- - 音效:work/sfx.wav(按 cues.json 合成);没有 cues 时退回 v0.2 的转场 whoosh
1096
+ - 音效:work/sfx.wav(按 cues.json 合成);没有 cues 时退回转场 whoosh
943
1097
  音效响起时配乐自动让位(duck_db),避免重击被配乐吃掉。
944
1098
  """
945
1099
  print("== Audio")
@@ -951,8 +1105,8 @@ def st_audio(tl, work, script):
951
1105
  music_path = os.path.join(work, "music.wav")
952
1106
  plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
953
1107
  if plan == "off":
954
- # 关键语义:mode=off = 不要配乐。旧实现会无条件读 work/music.wav,
955
- # 于是"上一轮跑出来的配乐"仍被混进新片(用户改了 mode 却听不出变化)。
1108
+ # 关键语义:mode=off = 不要配乐。无条件读 work/music.wav 的话,
1109
+ # "上一轮跑出来的配乐"仍会被混进新片(用户改了 mode 却听不出变化)。
956
1110
  print(" 配乐: " + why)
957
1111
  elif plan == "bed":
958
1112
  music = _legacy_music(tl, script)
@@ -969,7 +1123,7 @@ def st_audio(tl, work, script):
969
1123
  print(" 配乐: " + why)
970
1124
  if plan == "score" and music is None:
971
1125
  # 单独跑 --stage audio 时 score 还没跑过:不混配乐,但必须说清楚为什么
972
- print(" [WARN] music 用 scorekit 但 %s 不存在(先跑 --stage score);本次无配乐"
1126
+ print(" [WARN] music.mode=score 但 %s 不存在(先跑 --stage score);本次无配乐"
973
1127
  % music_path)
974
1128
 
975
1129
  sfx = None
@@ -978,7 +1132,7 @@ def st_audio(tl, work, script):
978
1132
  sfx, _ = K.wav_read(sfx_path)
979
1133
  print(" 音效: sfx.wav(按 cues.json 合成)")
980
1134
  elif script.get("whoosh", True) and not os.path.exists(os.path.join(work, "cues.json")):
981
- # v0.2 兼容:没有提示点时才用"每段开头一声 whoosh"的旧行为
1135
+ # 兼容路径:没有提示点时才用"每段开头一声 whoosh"的行为
982
1136
  bus = K.AudioBus(total, channels=2)
983
1137
  for s in tl["scenes"][1:]:
984
1138
  if float(s["start"]) < 0.2:
@@ -988,10 +1142,10 @@ def st_audio(tl, work, script):
988
1142
  sfx = bus.bus[: int(round(total * K.SR))].copy()
989
1143
  print(" 音效: 旧版转场 whoosh(没有 cues.json)")
990
1144
 
991
- plan_s = resolve_style(script)
1145
+ plan_s = resolve_style(script, work)
992
1146
  # 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
993
1147
  # 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
994
- # v0.7:整体 +1 dB —— 旧值(2.0/2.5/3.0/4.5)下配乐只比音效低 1.5 dB、duck 仅 3 dB,
1148
+ # 整体 +1 dB 的来由:更低的值(2.0/2.5/3.0/4.5)下配乐只比音效低 1.5 dB、duck 仅 3 dB,
995
1149
  # 段落过门时两者在同一频段几乎等高,是"配乐和音效听起来像同一套"的混音侧成因。
996
1150
  duck_default = 4.0
997
1151
  if not plan_s["legacy"]:
@@ -999,7 +1153,7 @@ def st_audio(tl, work, script):
999
1153
  "cinema": 5.5, "epic": 5.5}.get(plan_s["style"], 4.0)
1000
1154
  samples = K.mix_film(voice, music, sfx, total=total,
1001
1155
  voice_gain=float(script.get("voice_gain", 1.0)),
1002
- # v0.7.1 旁白优先:配乐基线从 -1.5 dB 降到 -12 dB。
1156
+ # 旁白优先:配乐基线 -12 dB。
1003
1157
  # 视频以旁白为主,-1.5 dB 时背景与台词响度几乎等高,观众要费力分辨字;
1004
1158
  # 12 dB 是"人声明显压住背景"的经验线(audio.md §2 的那条设计标准)。
1005
1159
  music_gain_db=float(mus.get("gain_db", -12.0)),
@@ -1023,9 +1177,9 @@ def st_audio(tl, work, script):
1023
1177
 
1024
1178
  # ---------------------------------------------------------------- 预检闸门
1025
1179
  def _run_preflight(script, work, engine, tl=None, skip=False):
1026
- """渲染前资源闸门(v0.8):缺字形 / 坏音频 / 缺字体,在渲第一帧之前就 fail。
1180
+ """渲染前资源闸门:缺字形 / 坏音频 / 缺字体,在渲第一帧之前就 fail。
1027
1181
 
1028
- 为什么值得单独立一道闸:这类问题以前都是"片渲完、QA 才报"——870 帧白跑,
1182
+ 为什么值得单独立一道闸:这类问题若只在"片渲完、QA 才报"——870 帧白跑,
1029
1183
  而且 reports 里只有一句"缺字形",说不清是哪个字段哪句台词。
1030
1184
  三档语义:没装 preflight.py -> skipped(写明原因);有 error -> fail;
1031
1185
  只有 warning -> pass(warning 仍进账本,不静默)。
@@ -1071,6 +1225,29 @@ def _run_preflight(script, work, engine, tl=None, skip=False):
1071
1225
  return True
1072
1226
 
1073
1227
 
1228
+ # ---------------------------------------------------------------- 字幕样式
1229
+ _SUB_CACHE = {}
1230
+
1231
+
1232
+ def _sub_kw(script):
1233
+ """从已解析的 plan 里取字幕参数(缓存按 script 对象身份,连续帧不重复解析)。
1234
+
1235
+ 字幕样式由 design.json 的 visual.subtitle 决定;参考帧渲染路径
1236
+ (qavmaf._draw_ref_frame)必须调用同一个 filmkit.subtitle_kwargs(plan),
1237
+ 否则两帧字幕参数不同会把同源好片的 VMAF 拉低。
1238
+ """
1239
+ key = id(script)
1240
+ cached = _SUB_CACHE.get(key)
1241
+ if cached is not None:
1242
+ return cached
1243
+ plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
1244
+ kw = K.subtitle_kwargs(plan)
1245
+ if len(_SUB_CACHE) > 8: # 批量渲染会跨很多 script:别无限长
1246
+ _SUB_CACHE.clear()
1247
+ _SUB_CACHE[key] = kw
1248
+ return kw
1249
+
1250
+
1074
1251
  # ---------------------------------------------------------------- 渲染
1075
1252
  def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True):
1076
1253
  """渲染 [f0, f1) 帧区间到 out_path。纯函数:同一帧号永远画出同一张图。"""
@@ -1096,7 +1273,8 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
1096
1273
  _THEME_CACHE.update(k=id(script), th=th)
1097
1274
  _hold = script.get("sub_max_hold")
1098
1275
  frame = K.draw_subtitle(frame, K.sub_at(scenes, t, _hold), theme=th,
1099
- words=K.sub_words_at(scenes, t, _hold))
1276
+ words=K.sub_words_at(scenes, t, _hold),
1277
+ **_sub_kw(script))
1100
1278
  fade = 1.0
1101
1279
  if t < 0.5:
1102
1280
  fade *= K.ease_out(t / 0.5)
@@ -1124,7 +1302,7 @@ def _seg_usable(path, frames):
1124
1302
 
1125
1303
  为什么不能只看 size>1024:Ctrl-C / 杀进程留下的半截 mp4 也 >1024,被当成
1126
1304
  "已存在"跳过后,拼接只在最后报一句"拼接后时长 0.07s != 预期 29.00s"。
1127
- v0.7.2 起分块先写 .part 再 os.replace,正常中断不会留下半截正式文件;这里
1305
+ 分块先写 .part 再 os.replace,正常中断不会留下半截正式文件;这里
1128
1306
  再补一道头部 nb_frames 比对,挡住"文件在/大小对但帧数不对"的异常(例如被
1129
1307
  外部工具截断过)。
1130
1308
 
@@ -1148,17 +1326,23 @@ def _seg_usable(path, frames):
1148
1326
 
1149
1327
  def _render_chunk_worker(args):
1150
1328
  """分块渲染工作单元(供 multiprocessing 调用;函数必须模块级可 pickle)。"""
1151
- (tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args
1329
+ (tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args[:9]
1330
+ work = args[9] if len(args) > 9 else ""
1152
1331
  tl = json.load(open(tl_path, encoding="utf-8"))
1153
1332
  script = json.load(open(script_path, encoding="utf-8"))
1154
1333
  script["_total"] = tl["total"]
1155
- # v0.6.0 修:JSON 快照会把元组变成 list(主题色是 (r,g,b) 元组),子进程里
1156
- # scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
1334
+ # 不能直接用 JSON 快照里的计划:JSON 会把元组变成 list(主题色是 (r,g,b) 元组),
1335
+ # 子进程里 scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
1157
1336
  # 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
1158
1337
  # 既拿回元组,也保证子进程的主题与父进程逐字段一致。
1338
+ #
1339
+ # 重解时**必须带上 work**。design 路径的身份在 work/design.json 里,
1340
+ # 不带 work 就只能走 fail-closed 报错 -> 画出一套完全不同的主题。
1341
+ # 实测后果:--jobs 4 的成片与 --jobs 1 / QA 参考帧 MAE 0.059、VMAF 直接掉到 1.49。
1342
+ # 这种"并行度不同就出不同片子"的 bug 最隐蔽——单线程跑一遍永远看不见。
1159
1343
  script.pop("_style", None)
1160
1344
  try:
1161
- script["_style"] = resolve_style(script, verbose=False)
1345
+ script["_style"] = resolve_style(script, work or None, verbose=False)
1162
1346
  except Exception as e: # noqa: BLE001
1163
1347
  print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
1164
1348
  script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
@@ -1179,7 +1363,7 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
1179
1363
  script["_total"] = tl["total"] # 场景算全局进度条要用
1180
1364
  # 分块 worker 是 spawn 出来的新进程:主题必须随快照走,否则子进程算出的
1181
1365
  # _theme_for 与父进程不一致 -> 拼接处出现"半部片换了配色"。
1182
- script.setdefault("_style", resolve_style(script))
1366
+ script.setdefault("_style", resolve_style(script, work))
1183
1367
  crf = int(script.get("crf", 18))
1184
1368
  preset = script.get("preset", "medium")
1185
1369
  total_frames = tl["frames"]
@@ -1256,7 +1440,8 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
1256
1440
  except OSError:
1257
1441
  pass
1258
1442
  part = os.path.join(seg_dir, "_part_" + os.path.basename(seg))
1259
- todo.append((tl_path, scenes_path, script_path, f0, f1, seg, crf, preset, part))
1443
+ todo.append((tl_path, scenes_path, script_path, f0, f1, seg, crf, preset, part,
1444
+ str(work)))
1260
1445
 
1261
1446
  # 旧计划留下的分块不会参与拼接,但会一直占盘:说出来,不静默
1262
1447
  stale = [n for n in os.listdir(seg_dir)
@@ -1298,15 +1483,18 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
1298
1483
  return silent
1299
1484
 
1300
1485
 
1301
- def _web_style_payload(we, script, tl=None):
1302
- """把风格包解析成 webengine 能吃的 __STYLE__ 载荷(v0.8)。
1486
+ def _web_style_payload(we, script, tl=None, work=None):
1487
+ """把风格包解析成 webengine 能吃的 __STYLE__ 载荷。
1303
1488
 
1304
1489
  style=None(legacy 项目 / 解析失败)时返回 None —— webengine 收到 None 连
1305
- add_init_script 都不调,页面行为与 v0.7 逐字节一致(这是兼容性硬要求)。
1490
+ add_init_script 都不调,页面行为逐字节不变(这是兼容性硬要求)。
1306
1491
  """
1307
1492
  try:
1308
- style_plan = resolve_style(script, verbose=False)
1309
- except Exception as e: # noqa: BLE001
1493
+ style_plan = resolve_style(script, work, verbose=False)
1494
+ # 必须连 SystemExit 一起接:resolve_style 在"没有设计/主题不过体检"时用 SystemExit
1495
+ # 报错(这是它向 CLI 报用法错误的方式),而 SystemExit 继承自 BaseException,
1496
+ # 只 catch Exception 接不住 —— web 页面会拿着半截载荷渲染,或者整个进程直接退出。
1497
+ except (Exception, SystemExit) as e: # noqa: BLE001
1310
1498
  print(" [WARN] web 风格包解析失败,页面用内置配色:%s" % e)
1311
1499
  return None
1312
1500
  if not style_plan or style_plan.get("legacy"):
@@ -1328,14 +1516,14 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
1328
1516
 
1329
1517
  页面的 window.DURATION / window.FPS 是切块与校验的唯一真值来源:
1330
1518
  和 timeline.json 对不上就直接报错(十有八九是改了剧本没重新 layout)。
1331
- v0.8:把风格包与画幅/帧率一起注入页面(window.__STYLE__),页面不再是深蓝橙写死。
1519
+ 把风格包与画幅/帧率一起注入页面(window.__STYLE__),页面颜色不再写死。
1332
1520
  """
1333
1521
  we = _opt_module("webengine",
1334
1522
  "浏览器渲染需要 webengine.py(本技能 scripts/ 里自带;还需要 pip install playwright)")
1335
1523
  entry = _web_entry(script)
1336
1524
  silent = os.path.join(work, "silent.mp4")
1337
1525
  web = script.get("web") or {}
1338
- style = _web_style_payload(we, script, tl)
1526
+ style = _web_style_payload(we, script, tl, work)
1339
1527
  vp = tl.get("video") or {}
1340
1528
  canvas_w = int(vp.get("out_w") or K.W)
1341
1529
  canvas_h = int(vp.get("out_h") or K.H)
@@ -1399,8 +1587,8 @@ def _qa_gates(lg, rep, deep, tl, work):
1399
1587
  v = next((s for s in streams if s.get("codec_type") == "video"), {})
1400
1588
  a = next((s for s in streams if s.get("codec_type") == "audio"), None)
1401
1589
  fmt = pr.get("format") or {}
1402
- # v0.8:容器门禁比对的是**交付规格**(timeline 里记的 video 块),不是引擎内部常量。
1403
- # 老 timeline(升级前生成的)没有 video 字段 -> 回落到 1920x1080,行为不变。
1590
+ # 容器门禁比对的是**交付规格**(timeline 里记的 video 块),不是引擎内部常量。
1591
+ # 没有 video 字段的 timeline -> 回落到 1920x1080。
1404
1592
  _vp = tl.get("video") or {}
1405
1593
  W = int(_vp.get("out_w") or K.W)
1406
1594
  H = int(_vp.get("out_h") or K.H)
@@ -1464,7 +1652,7 @@ def _qa_gates(lg, rep, deep, tl, work):
1464
1652
  else:
1465
1653
  lg.ok("G8", "首尾之外无长黑帧", "0 段")
1466
1654
 
1467
- # ---- G10 词级时间戳(v0.8):覆盖率 / 单调 / 末词偏差 ----
1655
+ # ---- G10 词级时间戳:覆盖率 / 单调 / 末词偏差 ----
1468
1656
  _word_gate(lg, tl, work)
1469
1657
 
1470
1658
  # ---- G9 帧级深检(闪烁 / 空窗 / 音画同步)----
@@ -1476,8 +1664,15 @@ def _qa_gates(lg, rep, deep, tl, work):
1476
1664
  ns = len(deep.get("sync") or [])
1477
1665
  judged = (deep.get("sync_info") or {}).get("judged")
1478
1666
  sync_note = ("同步查了 %s 条" % judged) if judged is not None else "同步未判(没写 visual_at)"
1479
- ok9 = nf == 0 and nb == 0 and ns == 0
1480
- detail9 = "闪烁 %d · 空窗 %d · 同步超差 %d(%s)" % (nf, nb, ns, sync_note)
1667
+ # 门禁要按 **deep.errors** 判,而不是按 flicker 条数。
1668
+ # qa_frames 会把"块内像素有增有减"(文字入场/母题动画/镜头缓推)的候选从
1669
+ # flicker 降级进 warnings(见 FLICKER_AGREE_MIN),此时 flicker 仍非空但已不成问题;
1670
+ # 按条数判会让降级形同虚设(零拷贝首片实测:降级后仍判 fail)。
1671
+ # errors 里同时也包含空窗与同步超差,所以只看 errors 不会漏判。
1672
+ n_err = len([e for e in (deep.get("errors") or []) if e])
1673
+ ok9 = n_err == 0
1674
+ detail9 = "问题 %d 条(闪烁候选 %d 已按符号一致度分档)· 空窗 %d · 同步超差 %d(%s)" % (
1675
+ n_err, nf, nb, ns, sync_note)
1481
1676
  if ok9 and not judged and os.environ.get("QA_REQUIRE_SYNC"):
1482
1677
  # 与 SKILL 的 QA_REQUIRE_SYNC 同义:要求"真的查过",而不是"没报错"
1483
1678
  lg.fail("G9", "帧级深检(闪烁/空窗/同步超差)",
@@ -1492,7 +1687,7 @@ WORD_COVER_MIN = 0.45 # 词时长之和 / 句实测时长:中文有词
1492
1687
 
1493
1688
 
1494
1689
  def _word_gate(lg, tl, work):
1495
- """词级时间戳门禁(v0.8)。
1690
+ """词级时间戳门禁。
1496
1691
 
1497
1692
  为什么值得单独一道闸:词级数据是"字幕按词折行 / cue 词锚点"的依据,
1498
1693
  但它来自一个**逆向接口**(edge-tts)。服务端一改,最可能的表现是"仍返回音频、
@@ -1556,12 +1751,12 @@ def _word_gate(lg, tl, work):
1556
1751
 
1557
1752
 
1558
1753
  # ⚠ 阈值**只有一套**:权威定义在 qavmaf.py(VMAF_PASS=92 / VMAF_WARN=96,SSIM 仅诊断)。
1559
- # 这里刻意不设第二套常量——本仓库的规矩是"同一事实只能有一个权威值"(CHANGELOG v0.4 D 节)。
1754
+ # 这里刻意不设第二套常量——本仓库的规矩是"同一事实只能有一个权威值"。
1560
1755
  # 真实标定(qavmaf 作者实测,参考帧含字幕与片头片尾淡入淡出):
1561
1756
  # 模板片 crf17 VMAF 97.24(pass) / crf30 95.74(pass+warning) / 二次编码坏片 29.45(fail)
1562
1757
  # 想按片收紧或放宽就写 film.json 的 qa.vmaf.pass / qa.vmaf.warn,会透传给 qavmaf。
1563
1758
  def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
1564
- """全片 VMAF/SSIM 门禁(v0.8):参考帧由场景函数实时渲染,与成片逐帧比较。
1759
+ """全片 VMAF/SSIM 门禁:参考帧由场景函数实时渲染,与成片逐帧比较。
1565
1760
 
1566
1761
  为什么值得全片跑(用户明确要求"全片 + 阻断"):锐度比只在 4 个时刻采样,
1567
1762
  能发现"整体发虚",但发现不了"某 30 秒里字幕糊了 / 某段梯度出现色带"。
@@ -1601,8 +1796,19 @@ def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
1601
1796
  if cfg["ssim"].get(ckey) is not None:
1602
1797
  kw[key] = float(cfg["ssim"][ckey])
1603
1798
  print(" [vmaf] 模式=%s(参考帧=场景函数实时渲染,不是成片抽帧)" % m)
1799
+ # qavmaf 是独立解析(它拿不到本进程的 script["_style"]),所以这里把
1800
+ # **冻结设计**一起传下去:参考帧必须与成片同源,否则 VMAF 会掉到个位数。
1801
+ # 实测未修前:design 片被解析成 tech-deep,VMAF 1.50、同源抽查 MAE=0.0384。
1802
+ script_for_vmaf = dict(script or {})
1803
+ dpath = os.path.join(str(work), "design.json")
1804
+ if "design" not in script_for_vmaf and os.path.exists(dpath):
1805
+ try:
1806
+ with open(dpath, encoding="utf-8") as fh:
1807
+ script_for_vmaf["design"] = json.load(fh)
1808
+ except (OSError, json.JSONDecodeError) as e: # noqa: BLE001
1809
+ print(" [WARN][vmaf] design.json 读不出来,参考帧可能不同源:%s" % e)
1604
1810
  try:
1605
- res = qv.report(film, tl, scenes_mod, script, mode=m,
1811
+ res = qv.report(film, tl, scenes_mod, script_for_vmaf, mode=m,
1606
1812
  out_json=os.path.join(str(work), "qa", "vmaf.json"), **kw)
1607
1813
  except Exception as e: # noqa: BLE001
1608
1814
  lg.fail("G11", "全片 VMAF / SSIM 画质", "门禁自身失败:%s" % e)
@@ -1635,7 +1841,7 @@ def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
1635
1841
  return res
1636
1842
 
1637
1843
 
1638
- # v0.8 实测标定(同一部模板片,29 s,faster-whisper small / CPU int8):
1844
+ # 实测标定(同一部模板片,29 s,faster-whisper small / CPU int8):
1639
1845
  # 干净成片:字符召回 0.828(专有名词 "DeepSeek 4.1 Flash" 被听成 DIXX/DeepSeek 变体,
1640
1846
  # 4 段全对上、缺句 0)—— 这不是流水线坏了,是 ASR 对中英混排专名的正常误差。
1641
1847
  # 所以默认线取 0.85 / 0.72:**低于 0.72 才说明"整句丢了/音轨跟剧本不是一套"**,
@@ -1646,7 +1852,7 @@ QA_ASR_RECALL_WARN = 0.72
1646
1852
 
1647
1853
 
1648
1854
  def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
1649
- """ASR 回读门禁(v0.8):把成片音轨转回文字,和剧本逐句对账。
1855
+ """ASR 回读门禁:把成片音轨转回文字,和剧本逐句对账。
1650
1856
 
1651
1857
  为什么值得:容器/响度/帧级质检都默认"音轨里念的就是剧本那几句"。TTS 缓存串了、
1652
1858
  换声线漏句、混音压没了、音轨是旧的——这些从波形和容器上都看不出来,只有转回
@@ -1699,7 +1905,7 @@ def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
1699
1905
 
1700
1906
  def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
1701
1907
  print("== QA")
1702
- th = _theme_for(script) if script is not None else None
1908
+ th = _theme_for(script, work) if script is not None else None
1703
1909
  shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
1704
1910
  fracs = (0.08, 0.3, 0.55, 0.78)
1705
1911
  ref_script = dict(script) if script is not None else None
@@ -1724,7 +1930,8 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
1724
1930
  int(round(sec * tl["fps"])))
1725
1931
  _hold = ref_script.get("sub_max_hold")
1726
1932
  fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, _hold), theme=th,
1727
- words=K.sub_words_at(tl["scenes"], sec, _hold))
1933
+ words=K.sub_words_at(tl["scenes"], sec, _hold),
1934
+ **_sub_kw(ref_script))
1728
1935
  refs.append((k, fr))
1729
1936
  except Exception as e: # noqa: BLE001
1730
1937
  print(" [WARN] 参考帧渲染失败:", e)
@@ -1758,9 +1965,9 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
1758
1965
  print(" [WARN] 锐度比门禁未执行(没有参考帧)")
1759
1966
  safe = K.qa_text_safe(tl["scenes"])
1760
1967
  print(" 字幕安全区:", "OK" if not safe else safe[:2])
1761
- # 字幕安全区 / 缺字形以前只 print,不进 qa_report 的 warnings ——
1762
- # 于是 CI、前端、任何只读 JSON 的人**看不到**这两条("门禁可以保守,但不能静默")。
1763
- # 现在它们进 warnings(仍是 warning 级,不阻断,但能被程序读到)。
1968
+ # 字幕安全区 / 缺字形若只 print、不进 qa_report 的 warnings,
1969
+ # CI、前端、任何只读 JSON 的人都**看不到**这两条("门禁可以保守,但不能静默")。
1970
+ # 所以它们进 warnings(仍是 warning 级,不阻断,但能被程序读到)。
1764
1971
  for _m in (safe or [])[:4]:
1765
1972
  rep["warnings"].append("字幕安全区: %s" % _m)
1766
1973
  _glyph = K.check_glyphs((script or {}).get("lines") or [])
@@ -1770,7 +1977,7 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
1770
1977
  sheet = K.contact_sheet(shots, os.path.join(work, "qa", "sheet.png"))
1771
1978
  if sheet:
1772
1979
  print(" 联系表:", sheet, " <- 必须亲眼看")
1773
- # ---- 帧级深检(v0.3.0):单帧闪烁 / 画面空窗 / 音画同步 / 响度曲线 ----
1980
+ # ---- 帧级深检:单帧闪烁 / 画面空窗 / 音画同步 / 响度曲线 ----
1774
1981
  deep = _qa_deep(film, work, tl, script)
1775
1982
  if deep:
1776
1983
  for w in deep.get("warnings") or []:
@@ -1831,7 +2038,7 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
1831
2038
  print("== Stills")
1832
2039
  script = dict(script)
1833
2040
  script["_total"] = tl["total"]
1834
- script.setdefault("_style", resolve_style(script))
2041
+ script.setdefault("_style", resolve_style(script, work))
1835
2042
  outdir = os.path.join(work, "stills")
1836
2043
  os.makedirs(outdir, exist_ok=True)
1837
2044
  by_id = {s["id"]: s for s in tl["scenes"]}
@@ -1860,7 +2067,8 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
1860
2067
  _hold = script.get("sub_max_hold")
1861
2068
  fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, _hold),
1862
2069
  theme=_theme_for(script),
1863
- words=K.sub_words_at(tl["scenes"], tg, _hold))
2070
+ words=K.sub_words_at(tl["scenes"], tg, _hold),
2071
+ **_sub_kw(script))
1864
2072
  p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
1865
2073
  fr.save(p)
1866
2074
  made.append(p)
@@ -1915,10 +2123,10 @@ def _reload_ledger_from_disk(work, ledger):
1915
2123
  def _report_path(a):
1916
2124
  """QC 报告的唯一落点:**站在成片旁边**(--out 的目录),不猜 work 的兄弟目录。
1917
2125
 
1918
- v0.7.2 修:accept 分支以前用 dirname(abspath(work))/out/QC-REPORT.md,而 all/mux/qa
1919
- 用 dirname(abspath(out))。--out 指到任何非 out/ 的目录(deliver/、中文工作区/成品/…)
2126
+ accept 与 all/mux/qa 必须用同一个函数算路径:若前者用 dirname(abspath(work))/out/、
2127
+ 后者用 dirname(abspath(out)),--out 指到任何非 out/ 的目录(deliver/、中文工作区/成品/…)
1920
2128
  时,签核更新的是另一份没人交付的文件,交付出去的那份仍是"未签核"——正好打穿
1921
- "报告是交付凭据"这条承诺。两处必须走同一个函数。
2129
+ "报告是交付凭据"这条承诺。
1922
2130
  """
1923
2131
  return os.path.join(os.path.dirname(os.path.abspath(str(a.out))), "QC-REPORT.md")
1924
2132
 
@@ -1990,8 +2198,8 @@ def _load_batch(path):
1990
2198
  {"films": [{"script": "a/film.json", "work": "a/work",
1991
2199
  "scenes": "a/scenes.py", "out": "out/a.mp4"}]}
1992
2200
 
1993
- 限制来自 MoneyPrinterTurbo 的工程经验(B 线调研):批量最容易死在"第 7 条跑一半
1994
- 才发现清单写错",所以数量/体积先卡死,并且在**开跑之前**全部预检一遍。
2201
+ 批量最容易死在"第 7 条跑一半才发现清单写错",所以数量/体积先卡死,
2202
+ 并且在**开跑之前**全部预检一遍。
1995
2203
  """
1996
2204
  p = str(path)
1997
2205
  if not os.path.exists(p):
@@ -2122,12 +2330,94 @@ def run_batch(a):
2122
2330
  return 0 if okn == len(results) else 1
2123
2331
 
2124
2332
 
2333
+ # ---------------------------------------------------------------- 提案闸
2334
+ def _proposal_gate(a):
2335
+ """提案闸:frames 之前必须先有"已确认的设计"。
2336
+
2337
+ 本项目的主路径是"先提案、再出片",所以这条闸是语义要求,不是可选装饰。
2338
+ 但它默认只警告(0 退出码),加 --require-proposal 才失败——原因只有一条:
2339
+ --stage frames 是开发期高频命令(改一帧看一帧),硬拦会让调试变得没法用。
2340
+
2341
+ 判定(propose.proposal_state 的几档):
2342
+ absent -> 没有任何提案
2343
+ draft -> 有提案但没确认
2344
+ confirmed -> 已确认且 design.json 指纹与确认时一致(唯一放行态)
2345
+ stale -> 确认过但 design.json 被改过(等于没确认)
2346
+ """
2347
+ try:
2348
+ import propose as PP
2349
+ except ImportError:
2350
+ print("[proposal] 没有 propose.py,跳过提案检查(只警告)")
2351
+ return True
2352
+ try:
2353
+ status, detail = PP.proposal_state(a.work)
2354
+ except SystemExit as e:
2355
+ status, detail = "broken", str(e)
2356
+ except Exception as e: # noqa: BLE001
2357
+ status, detail = "broken", "%s: %s" % (type(e).__name__, e)
2358
+
2359
+ if status == "confirmed":
2360
+ print("[proposal] OK %s" % detail)
2361
+ _LEDGER.ok("proposal", "提案已确认", detail)
2362
+ return True
2363
+
2364
+ msg = "提案闸未通过(%s):%s" % (status, detail)
2365
+ print("[proposal] WARN %s" % msg)
2366
+ if status in ("absent", "draft"):
2367
+ print(" 先跑:python propose.py --brief \"题材/要求\" --script %s --n 3" % a.script)
2368
+ print(" 确认:python propose.py --confirm --candidate A")
2369
+ if bool(getattr(a, "require_proposal", False)):
2370
+ _LEDGER.fail("proposal", "提案未确认", msg)
2371
+ print("\n[提案闸] --require-proposal 下这是失败项:先提案并确认,或去掉该参数")
2372
+ return False
2373
+ _LEDGER.skip("proposal", "提案闸", msg + "(默认只警告;--require-proposal 才失败)")
2374
+ return True
2375
+
2376
+
2377
+ # ---------------------------------------------------------------- 场景模块定位
2378
+ def _builtin_scenes() -> str:
2379
+ """本技能内置的场景绘制模块路径(装成插件后在包内 skills/video-studio/assets/scenes.py)。"""
2380
+ cand = (Path(__file__).resolve().parent.parent / "assets" / "scenes.py")
2381
+ return str(cand) if cand.exists() else ""
2382
+
2383
+
2384
+ def _resolve_scenes(scenes_arg, script_path) -> str:
2385
+ """定位场景模块,按优先级:显式 --scenes > 剧本同目录 scenes.py > 技能内置 scenes.py。
2386
+
2387
+ 为什么要这条回退链:要求用户把 assets/scenes.py 拷进工作目录,
2388
+ 即「提供一份模板代码」——与「不提供任何模板」直接冲突,而且用户每部片都得
2389
+ 重拷一份 98KB 的绘制代码。所以把内置渲染器当**默认能力**:工作目录里没有 scenes.py
2390
+ 时直接用它出片;需要改画面再写自己的 scenes.py(显式指定或用同目录约定)。
2391
+ """
2392
+ if scenes_arg:
2393
+ p = Path(scenes_arg).resolve()
2394
+ if p.exists():
2395
+ return str(p)
2396
+ raise SystemExit(
2397
+ "[缺少场景模块] --scenes 指向的 %s 不存在。\n"
2398
+ " 不写 --scenes 时会自动用剧本同目录的 scenes.py 或技能内置渲染器;"
2399
+
2400
+ "只想用内置的就去掉 --scenes。" % p)
2401
+ cand = Path(script_path).resolve().parent / "scenes.py"
2402
+ if cand.exists():
2403
+ return str(cand)
2404
+ builtin = _builtin_scenes()
2405
+ if builtin:
2406
+ return builtin
2407
+ raise SystemExit(
2408
+ "[缺少场景模块] 找不到 %s,也找不到技能内置渲染器(%s)。\n"
2409
+ " 装成插件时内置渲染器应在 <包>/skills/video-studio/assets/scenes.py;"
2410
+
2411
+ "请检查插件包是否完整,或显式传 --scenes 指向你自己的 scenes.py。" % (cand, _builtin_scenes() or "missing"))
2412
+
2413
+
2125
2414
  # ---------------------------------------------------------------- main
2126
2415
  def main():
2127
2416
  _setup_stdio()
2128
2417
  ap = argparse.ArgumentParser(description="video-studio 编排器")
2129
2418
  ap.add_argument("--script", default="film.json")
2130
- ap.add_argument("--scenes", default="scenes.py")
2419
+ ap.add_argument("--scenes", default="",
2420
+ help="场景绘制模块(默认依次找:<剧本目录>/scenes.py、本技能内置的 scenes.py)")
2131
2421
  ap.add_argument("--out", default="out/film.mp4")
2132
2422
  ap.add_argument("--work", default="work")
2133
2423
  ap.add_argument("--stage", default="all", choices=STAGES)
@@ -2152,9 +2442,11 @@ def main():
2152
2442
  help="过时也照跑(会在 QC 报告里留痕)")
2153
2443
  ap.add_argument("--no-gate", action="store_true",
2154
2444
  help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
2445
+ ap.add_argument("--require-proposal", action="store_true",
2446
+ help="把'提案未确认'当成失败(默认只警告;先跑 propose.py 再出片)")
2155
2447
  ap.add_argument("--cost", action="store_true",
2156
2448
  help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
2157
- # ---- v0.8 ----
2449
+ # ---- 批量 / 画质门禁 ----
2158
2450
  ap.add_argument("--batch", default="",
2159
2451
  help="批量清单(JSON 数组或 {\"films\":[...]},≤100 条):"
2160
2452
  "先全量预检,再逐条渲染,单条失败不阻断其余")
@@ -2183,13 +2475,13 @@ def main():
2183
2475
  if engine not in ("pil", "web"):
2184
2476
  raise SystemExit("engine 只能是 pil 或 web,收到 %r" % engine)
2185
2477
  script["_engine"] = engine
2186
- script["_scenes"] = str(Path(a.scenes).resolve())
2478
+ script["_scenes"] = _resolve_scenes(a.scenes, a.script)
2187
2479
 
2188
- # ---- 门禁账本(v0.6.0)----
2480
+ # ---- 门禁账本 ----
2189
2481
  global _LEDGER
2190
2482
  # engine=web 时把入口页实际引用的同目录页面脚本一起纳入内容指纹:web 的画面逻辑在
2191
- # web/*.js 里,不在 scenes.py 里——不纳入的话改动画后旧签核仍然"有效",正好违背
2192
- # v0.6「签核绑内容指纹,改了画面代码旧签核自动失效」的承诺。
2483
+ # web/*.js 里,不在 scenes.py 里——不纳入的话改动画后旧签核仍然"有效",
2484
+ # 正好违背「签核绑内容指纹,改了画面代码旧签核自动失效」这条承诺。
2193
2485
  web_entry = ""
2194
2486
  if engine == "web":
2195
2487
  try:
@@ -2204,8 +2496,15 @@ def main():
2204
2496
  print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
2205
2497
 
2206
2498
  def _sources():
2207
- """源头输入:剧本与 scenes.py(改它们等于改了所有下游的依据)。"""
2208
- return {"script": a.script, "scenes": a.scenes}
2499
+ """源头输入:剧本、scenes.py、冻结设计(改它们等于改了所有下游的依据)。
2500
+
2501
+ design.json 是第三个源头:它由 propose.py --confirm 写出,
2502
+ 记录"这一版长什么样"。改设计 = 画面必须重渲,所以它必须进 stagewatch 的
2503
+ 源头指纹,否则"改了配色但没重跑 frames"这种事故查不出来。
2504
+ """
2505
+ design = os.path.join(a.work, "design.json")
2506
+ return {"script": a.script, "scenes": a.scenes,
2507
+ "design": design if os.path.exists(design) else ""}
2209
2508
 
2210
2509
  # 本次命令行**计划要跑**的 stage 集合:--stage all 展开成 10 个,单 stage 就是它自己。
2211
2510
  # 用途见 stagewatch.check 的 pending:本阶段马上要用新输入重算,那种"输入变了"不是过时。
@@ -2221,7 +2520,7 @@ def main():
2221
2520
  """stage 开始时查新鲜度并计时。返回 t0。
2222
2521
 
2223
2522
  report() 自己负责打印 "[stale] <stage>: <原因>" 与"最小重跑"两行
2224
- (v0.7.2 修:以前只调不接,用户只看到"最小重跑"不知道是哪个阶段、
2523
+ (必须接它的返回值,否则用户只看到"最小重跑"不知道是哪个阶段、
2225
2524
  为什么过时,得上 --stage explain 才知道)。
2226
2525
  --allow-stale 也要调用:它承诺的是"放行并留痕"(QC 报告里记一条 skipped),
2227
2526
  直接跳过检查连痕都没有——那就成了静默。
@@ -2242,7 +2541,7 @@ def main():
2242
2541
  if errs:
2243
2542
  # --strict-stale 的承诺是"上游产物过时算失败"。stagewatch.report 只把
2244
2543
  # 问题放进返回值(并往账本记一条 fail),它自己不抛异常——所以必须在这里
2245
- # 真的失败。以前丢弃了返回值,于是 --stage layout --strict-stale 报完过时
2544
+ # 真的失败。丢弃返回值的话,--stage layout --strict-stale 报完过时
2246
2545
  # 照跑、退出码 0,help 文本与行为对不上。
2247
2546
  #
2248
2547
  # 选择"立刻退出"而不是"跑完再在收尾处报":过时的输入会把旧产物掺进
@@ -2323,27 +2622,33 @@ def main():
2323
2622
  raise SystemExit("[前置] %s 不是合法 JSON(%s),重新跑 --stage layout" % (tl_p, e)) from e
2324
2623
 
2325
2624
  def load_scenes(strict=True):
2326
- """加载场景模块:strict=False 时失败只 WARN(qa 缺参考帧也要能跑)。"""
2625
+ """加载场景模块:strict=False 时失败只 WARN(qa 缺参考帧也要能跑)。
2626
+
2627
+ 路径用 main() 里解析好的 script["_scenes"](显式 --scenes / 剧本同目录 /
2628
+ 技能内置三档回退),不能用原始 a.scenes —— 缺省时它是空串,会变成
2629
+ `load_module("")` 这种 "NoneType has no attribute loader" 的怪错误。
2630
+ """
2631
+ scenes_path = script.get("_scenes") or _resolve_scenes(a.scenes, a.script)
2327
2632
  try:
2328
- mod = load_module(a.scenes)
2633
+ mod = load_module(scenes_path)
2329
2634
  except Exception as e: # noqa: BLE001
2330
2635
  if strict:
2331
2636
  raise SystemExit("[场景模块加载失败] %s:%s\n"
2332
2637
  " --scenes 要指向定义 draw_frame() 的 scenes.py"
2333
- % (a.scenes, e)) from e
2334
- print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (a.scenes, e))
2638
+ % (scenes_path, e)) from e
2639
+ print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (scenes_path, e))
2335
2640
  return None
2336
2641
  if not hasattr(mod, "draw_frame"):
2337
2642
  if strict:
2338
2643
  raise SystemExit("[场景模块错误] %s 没有 draw_frame(scene, t_local, dur, t_global, script, frame)"
2339
- % a.scenes)
2340
- print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" % a.scenes)
2644
+ % scenes_path)
2645
+ print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" % scenes_path)
2341
2646
  return None
2342
2647
  return mod
2343
2648
 
2344
2649
  stage = a.stage
2345
2650
 
2346
- # ---- preview:前 N 秒试片(v0.8)----
2651
+ # ---- preview:前 N 秒试片 ----
2347
2652
  if a.preview:
2348
2653
  try:
2349
2654
  secs = float(a.preview)
@@ -2362,12 +2667,16 @@ def main():
2362
2667
  print("== Preview(前 %.1fs / %d 帧)" % (n / float(tl["fps"]), n))
2363
2668
  sc2 = dict(script)
2364
2669
  sc2["_total"] = tl["total"]
2365
- sc2.setdefault("_style", resolve_style(script))
2670
+ # 不能写成 setdefault(_style, resolve_style(script)):
2671
+ # 对 sc2 做 setdefault 却传 script 的解析结果 —— script 有 _style 时,
2672
+ # setdefault 的第二参数**仍会被求值**(Python 先算参数),等于多做一次无用解析;
2673
+ # 而 script 没有 _style 时又把 work 漏了,单跑 --preview 会报"找不到设计"。
2674
+ sc2.setdefault("_style", resolve_style(script, a.work))
2366
2675
  _render_range(tl, mod, sc2, 0, n, out_mp4, int(script.get("crf", 18)),
2367
2676
  str(script.get("preset", "medium")), progress=False)
2368
2677
  # 有 audio.wav 就带上声音(没有也能看画面;不阻断)。
2369
2678
  # 注意:**必须先把音轨截到预览时长**——K.mux 会校验音视频时长差 < 0.05s,
2370
- # 直接拿全长 audio.wav 去配 8 秒画面必然被判"音视频时长不一致"(v0.8 开发期实测)。
2679
+ # 直接拿全长 audio.wav 去配 8 秒画面必然被判"音视频时长不一致"(实测)。
2371
2680
  audio = os.path.join(a.work, "audio.wav")
2372
2681
  if os.path.exists(audio):
2373
2682
  tmp = out_mp4 + ".mux.mp4"
@@ -2462,7 +2771,7 @@ def main():
2462
2771
  return 0
2463
2772
 
2464
2773
  if stage in ("all", "style"):
2465
- # --stage all 也要把 style 这一行记进 manifest:以前 _nm 在 all 下是 None,
2774
+ # --stage all 也要把 style 这一行记进 manifest:若 _nm 在 all 下是 None,
2466
2775
  # 而 _stage_begin/_stage_end 对 None 是空操作 -> work/style.json 写出来了、
2467
2776
  # manifest.json 却从来没有 style 记录。于是之后任何一次 `--stage style` 都会
2468
2777
  # 命中「产物在但没有记录」的判据,报成 no-manifest(--stage all 的用户 100% 踩到)。
@@ -2520,8 +2829,8 @@ def main():
2520
2829
  _stage_end("audio", _t0)
2521
2830
 
2522
2831
  if stage == "stills":
2523
- # 母版预览是"上手第一件事",可它需要 timeline。以前干净目录直接 SystemExit,
2524
- # 而文档三步上手就把它排在第 4 步(照抄必失败)。这里自动补前置:
2832
+ # 母版预览是"上手第一件事",可它需要 timeline。干净目录直接 SystemExit 的话,
2833
+ # 文档三步上手就把它排在第 4 步(照抄必失败)。所以这里自动补前置:
2525
2834
  # 缺 tts.json 就真合成一次旁白(要联网),缺 timeline 就排一次轴——
2526
2835
  # 但每一步都**明说**自己在补什么,不偷偷跑。
2527
2836
  # 自动补的这几步也要 _stage_begin/_stage_end:否则 manifest 里只有 stills 一条记录,
@@ -2549,7 +2858,7 @@ def main():
2549
2858
  _stage_end("stills", _t0)
2550
2859
  _LEDGER.add("stills", "母版静帧已产出", GT.OK if made else GT.FAIL,
2551
2860
  "%d 张(要人看过才算过:--stage accept --gate stills)" % len(made or []))
2552
- # 0 张静帧 = fail 门禁:以前这里无条件 return 0,`--stills 写错 id` 会
2861
+ # 0 张静帧 = fail 门禁:无条件 return 0 的话,`--stills 写错 id` 会
2553
2862
  # "全部跳过 + 退出码 0",CI 看不出根本没出图。
2554
2863
  rc = _finish_gates(a)
2555
2864
  return 1 if (rc or _LEDGER.fails()) else 0
@@ -2567,6 +2876,11 @@ def main():
2567
2876
  return 1 if (errs or rc) else 0
2568
2877
 
2569
2878
  if stage in ("all", "frames", "mux"):
2879
+ # ---- 提案闸:出片前必须先有已确认的设计 ----
2880
+ # 放在最前面(before ensure_tl/preflight/st_frames):渲完再拦等于白渲。
2881
+ if stage in ("all", "frames"):
2882
+ if not _proposal_gate(a):
2883
+ return 1
2570
2884
  tl = tl if stage == "all" else ensure_tl()
2571
2885
  audio = os.path.join(a.work, "audio.wav")
2572
2886
  silent = os.path.join(a.work, "silent.mp4")
@@ -2588,7 +2902,7 @@ def main():
2588
2902
  _stage_end("frames", _t0, frames=int(tl["frames"]))
2589
2903
  if stage == "frames":
2590
2904
  _LEDGER.ok("frames", "逐帧渲染", "%d 帧(%s)" % (int(tl["frames"]), engine))
2591
- # 同上:strict-gates 下 skipped 也要反映到退出码(以前这里只看 fails())。
2905
+ # 同上:strict-gates 下 skipped 也要反映到退出码(只看 fails() 会漏掉它)。
2592
2906
  rc = _finish_gates(a)
2593
2907
  return 1 if (rc or _LEDGER.fails()) else 0
2594
2908
  if stage in ("all", "mux"):
@@ -2601,7 +2915,7 @@ def main():
2601
2915
  _check_signoff("mux")
2602
2916
  if stage == "mux":
2603
2917
  _LEDGER.ok("mux", "合成(视频流 copy + loudnorm)", a.out)
2604
- # 同上:strict-gates 下 skipped 也要反映到退出码(以前这里只看 fails())。
2918
+ # 同上:strict-gates 下 skipped 也要反映到退出码(只看 fails() 会漏掉它)。
2605
2919
  rc = _finish_gates(a)
2606
2920
  return 1 if (rc or _LEDGER.fails()) else 0
2607
2921
  if stage == "all":
@@ -2615,8 +2929,8 @@ def main():
2615
2929
  return 1
2616
2930
  return 0
2617
2931
  # 所有 stage 统一收尾:落 gates.json + QC-REPORT.md,并按门禁结果给退出码。
2618
- # v0.7.2 修:以前只有 style/frames/mux/qa 这几条分支自己调 _finish_gates,其余
2619
- # (tts/layout/cues/score/sfx/audio/stills)落在这行 return 0——于是 ledger 里
2932
+ # 只有 style/frames/mux/qa 这几条分支自己调 _finish_gates 时,其余
2933
+ # (tts/layout/cues/score/sfx/audio/stills)会落在这行 return 0——于是 ledger 里
2620
2934
  # 记的 fail 传不到退出码,"门禁失败 -> 非 0"这个承诺按 stage 不同行为不一样。
2621
2935
  # 默认路径不受影响:没有 fail(且没开 --strict-gates)时 _finish_gates 返回 0。
2622
2936
  rc = _finish_gates(a, persist=(stage in _PERSIST_STAGES))