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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/README.md +287 -327
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +4 -15
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1316 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +1 -2
  11. package/skills/video-studio/assets/golden_frames.json +11 -8
  12. package/skills/video-studio/assets/scenes.py +67 -22
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/audio-variation.md +15 -11
  15. package/skills/video-studio/references/audio.md +36 -34
  16. package/skills/video-studio/references/change-impact.md +3 -3
  17. package/skills/video-studio/references/frame-qa.md +2 -2
  18. package/skills/video-studio/references/proposal.md +177 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/scene-grammar.md +28 -23
  21. package/skills/video-studio/references/sfx-design.md +1 -1
  22. package/skills/video-studio/references/style-packs.md +95 -257
  23. package/skills/video-studio/references/v08-quality.md +8 -8
  24. package/skills/video-studio/references/web-engine.md +1 -1
  25. package/skills/video-studio/references/word-timeline.md +2 -2
  26. package/skills/video-studio/scripts/designkit.py +2120 -0
  27. package/skills/video-studio/scripts/filmcheck.py +7 -33
  28. package/skills/video-studio/scripts/filmkit.py +153 -78
  29. package/skills/video-studio/scripts/grammar.py +67 -7
  30. package/skills/video-studio/scripts/musicgen.py +2043 -0
  31. package/skills/video-studio/scripts/preflight.py +10 -3
  32. package/skills/video-studio/scripts/previewkit.py +849 -0
  33. package/skills/video-studio/scripts/propose.py +825 -0
  34. package/skills/video-studio/scripts/qa_frames.py +45 -10
  35. package/skills/video-studio/scripts/qavmaf.py +77 -11
  36. package/skills/video-studio/scripts/render.py +472 -154
  37. package/skills/video-studio/scripts/scorekit.py +50 -1748
  38. package/skills/video-studio/scripts/selfcheck.py +658 -131
  39. package/skills/video-studio/scripts/stagewatch.py +21 -5
  40. package/skills/video-studio/scripts/stylekit.py +265 -741
  41. package/skills/video-studio/scripts/voicekit.py +502 -380
  42. package/skills/video-studio/scripts/webengine.py +15 -14
  43. package/tools/audio_similarity.py +57 -30
  44. package/tools/ci_check.py +7 -1
  45. package/tools/estimate.py +14 -8
  46. package/tools/golden_frames.py +56 -11
  47. package/tools/silhouette_qa.py +521 -168
  48. package/docs/demo-pil-sheet.png +0 -0
  49. package/docs/styles/demo-results.json +0 -76
  50. package/docs/styles/sheet-8-styles.png +0 -0
  51. package/docs/styles/sheet-ai.png +0 -0
  52. package/docs/styles/sheet-before-after.png +0 -0
  53. package/docs/styles/sheet-craft.png +0 -0
  54. package/docs/styles/sheet-night.png +0 -0
  55. package/skills/video-studio/assets/score_demo.json +0 -307
@@ -8,7 +8,7 @@
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;分块并行、断点续跑)
@@ -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:
@@ -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
 
@@ -296,53 +299,131 @@ def _stylekit():
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
+ """v1.0:如果 work/design.json 存在(用户已确认提案),**以它为准**解析计划。
308
+
309
+ D3 之后这是唯一主路径:design.json 是"这一版长什么样"的冻结点,剧本只提供台词。
310
+ 返回 None 表示没有 design(调用方回落到 stylekit 的旧解析,P3 会删掉那条路)。
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 冒充"渲染器坏了"(v0.9 之前这种误报踩过多次)。
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 **缺省位**(v1.0.2)。
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
+ 优先级(v1.0):
384
+ 1. script["_style"] 已存在(同进程内各 stage 复用)
385
+ 2. work/design.json(用户已确认的提案)—— 这是正式主路径
386
+ 3. stylekit 旧解析(P3 之后会删除;现在保留是为了让未迁移的项目还能跑)
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
+ # v1.0.1:**没有第三优先级了**。以前这里会回落到 stylekit 的"按关键词自动选风格",
404
+ # 那是模板库的核心;删掉模板后这条路径没有意义,必须明确报错。
405
+ raise SystemExit(
406
+ "[style] 找不到设计(work/design.json):\n"
407
+ " v1.0.1 起本技能不提供任何内置风格,每一版外观都必须现场生成并由人确认。\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(= v0.4 的硬编码外观,逐字节兼容)。
417
+
418
+ work **必须传**:design 路径的身份在 work/design.json 里。v1.0.1 之前不传会回落到
419
+ stylekit 的 auto 分支(已删除),现在不传就直接 SystemExit —— 但"传错 work"仍然值得防:
420
+ work 指到别的目录时会读到**另一份** design.json,参考帧与成片不同源
421
+ (历史实测:--stage qa 单独跑 VMAF 1.07,全部内联跑 97.15)。
422
+ """
342
423
  plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
343
424
  if plan is not None and plan.get("legacy"):
344
425
  return None
345
- plan = resolve_style(script)
426
+ plan = resolve_style(script, work)
346
427
  if plan.get("legacy"):
347
428
  return None
348
429
  # plan["theme"] 是 stylekit 合并过 film.json 的 visual.theme_overrides 之后的最终主题,
@@ -353,7 +434,22 @@ def _theme_for(script):
353
434
  th = plan.get("theme")
354
435
  if isinstance(th, dict):
355
436
  return th
356
- return _stylekit().THEMES[plan["theme_id"]]
437
+ # v1.0:**先看 plan["theme"]**(designkit 生成的计划自带完整 35 字段主题),
438
+ # 只有老计划(stylekit 解析、没有 theme 字段)才回查内置表。
439
+ # 早先无条件 THEMES[plan["theme_id"]],于是设计路径的 theme_id(如 "a-44957fb5")
440
+ # 在表里查不到 -> KeyError,整条出片链路在 layout 阶段崩掉。
441
+ th = plan.get("theme")
442
+ if isinstance(th, dict) and th:
443
+ return th
444
+ # v1.0.1:内置主题表已删除,没有 theme 计划就是"这份 script 没走过 design/propose"。
445
+ # 明确报错并给迁移指引,不静默回落(静默回落 = 用一套不存在的默认外观出片)。
446
+ raise SystemExit(
447
+ "[style] 这份剧本没有可用的主题:\n"
448
+ " 计划里缺 theme 字段,而内置主题表已在本版删除。\n"
449
+ " 正确做法:python propose.py --brief \"题材\" --script film.json # 出方案\n"
450
+ " python propose.py --confirm --candidate A # 冻结 design.json\n"
451
+ " python render.py --script film.json --stage all\n"
452
+ " (theme_id=%r)" % (plan.get("theme_id"),))
357
453
 
358
454
 
359
455
  def _print_style(plan):
@@ -377,8 +473,24 @@ def _print_style(plan):
377
473
  % (mf.get("kind"), tr.get("kind"), float(tr.get("dur") or 0.0),
378
474
  float(ty.get("tracking") or 0.0), ty.get("number"),
379
475
  float(bi.get("x") or 0.0), float(bi.get("y") or 0.0)))
476
+ # v1.0.2 修:这里以前无条件读 m["style"] —— 而 designkit 自 v1.0.1 起明确不再写
477
+ # 这个键(配乐风格表已删),于是公开命令 `--stage style` 100% KeyError: "style"。
478
+ # 现在按「参数化配乐」的真实身份打印:调式 + BPM + 调性 + mood。
479
+ _music_id = m.get("arranger") or m.get("style") or ("%s %gBPM" % (m.get("scale", "?"), m.get("bpm", 0)))
380
480
  print(" 配乐: %s BPM %g(%s) 调性 %s(%s) mood %s"
381
- % (m["style"], m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
481
+ % (_music_id, m["bpm"], m["bpm_source"], m["key"], m["key_source"], m["mood"]))
482
+ _sub = plan.get("subtitle") if isinstance(plan.get("subtitle"), dict) else None
483
+ if _sub:
484
+ _y = _sub.get("y")
485
+ print(" 字幕: %s %dpx 对齐=%s 背板α=%d 圆角=%d 最多%d行"
486
+ % ("y=%s" % _y if _y is not None else "底部安全区",
487
+ int(_sub.get("size") or 46), _sub.get("align") or "center",
488
+ int(_sub.get("plate_alpha") if _sub.get("plate_alpha") is not None else 170),
489
+ int(_sub.get("radius") if _sub.get("radius") is not None else 10),
490
+ int(_sub.get("max_lines") or 2)))
491
+ _cyc = plan.get("layout_cycle") or []
492
+ if len(_cyc) >= 2:
493
+ print(" 版式轮换: %s(按章节轮换,防全片一张版式)" % " -> ".join(map(str, _cyc)))
382
494
  if v["map"]:
383
495
  print(" 旁白: %s" % " ".join("%s=%s" % kv for kv in sorted(v["map"].items())))
384
496
  p = plan["pacing"]
@@ -559,7 +671,9 @@ def st_layout(script, work, lines=None):
559
671
  json.dump(tl, open(os.path.join(work, "timeline.json"), "w", encoding="utf-8"),
560
672
  ensure_ascii=False, indent=1)
561
673
  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)
674
+ # 同上:设计计划自带 theme,不能假定 theme_id 一定在内置表里(见 _theme_for 的注释)
675
+ # v1.0.1:没有内置主题表了,theme 必须由计划自带(design 路径一定有)
676
+ theme = (plan.get("theme") if not plan["legacy"] else None)
563
677
  n_ass = K.write_ass(tl["scenes"], os.path.join(work, "sub.ass"), theme=theme)
564
678
  print(" 总时长 %.2fs / %d 帧 / %d 段 / srt %d 条 / ass %d 条"
565
679
  % (tl["total"], tl["frames"], len(tl["scenes"]), n_srt, n_ass))
@@ -575,6 +689,26 @@ def st_layout(script, work, lines=None):
575
689
  print(" %-6s %-7s %r" % (lid, fld, chars))
576
690
  for s in tl["scenes"]:
577
691
  print(" %-8s %7.2f-%7.2f %s" % (s["id"], s["start"], s["end"], s["text"][:40]))
692
+ # ---- 目标时长对账(v1.0.2)----
693
+ # brief 里写的「2 分钟 / 60 秒」过去只影响 design.structure.segments 与 target_sec,
694
+ # 而成片时长由 TTS 实测决定 —— 用户以为"我要求了时长",实际拿到 23s。
695
+ # 不改时长(截断旁白或拉长留白都是坏做法),但**必须当场说出来差多少**,
696
+ # 并给出可执行的做法(加台词 / 调 hold / 减台词)。
697
+ target = (plan or {}).get("target_sec")
698
+ if target:
699
+ try:
700
+ target = float(target)
701
+ except (TypeError, ValueError):
702
+ target = None
703
+ if target and target > 0:
704
+ diff = float(tl["total"]) - target
705
+ pct = diff / target * 100.0
706
+ print(" 目标时长 %.1fs / 实际 %.2fs(%+.1fs,%+.0f%%)"
707
+ % (target, tl["total"], diff, pct))
708
+ if abs(pct) > 15.0:
709
+ print(" [WARN] 与目标时长差超过 15%:成片时长由**实测旁白**决定,不收口就要改文案。"
710
+ "\n 想变长:加台词或调大 film.json 的 hold / lead / tail;"
711
+ "\n 想变短:删减台词(hold 只会加留白,不会缩短旁白)。")
578
712
  return tl
579
713
 
580
714
 
@@ -614,6 +748,15 @@ def st_cues(script, work, tl, engine="pil"):
614
748
  # scenes.py 里的 cues() 钩子:允许直接返回 [{at,type,...}] 或调用 bus.add(...)
615
749
  n_hook = 0
616
750
  mod = None
751
+ # v1.0.2 修:scenes.py 的 cues() 常要读主题/视觉语言(assets 版就调 resolve_script_style)。
752
+ # 单跑 `--stage cues` 时前面没有 stage 把 script["_style"] 挂好,钩子就会去解析"设计"
753
+ # 并直接 raise —— 公开命令 100% 崩,报错还指向"先去提案"(其实设计已确认)。
754
+ # 这里在调用钩子前解析一次(幂等,已挂 _style 时直接复用)。
755
+ if script.get("_scenes") and os.path.exists(script["_scenes"]) and not isinstance(script.get("_style"), dict):
756
+ try:
757
+ script["_style"] = resolve_style(script, work)
758
+ except SystemExit:
759
+ pass # 没设计:cues 阶段不因它失败,钩子里真需要会自己报错
617
760
  if script.get("_scenes") and os.path.exists(script["_scenes"]):
618
761
  try:
619
762
  mod = load_module(script["_scenes"])
@@ -683,7 +826,7 @@ def _st_cues_web(script, work):
683
826
  tl = json.load(open(tp, encoding="utf-8"))
684
827
  except Exception: # noqa: BLE001
685
828
  tl = {}
686
- style = _web_style_payload(we, script, tl)
829
+ style = _web_style_payload(we, script, tl, work)
687
830
  vp = (tl.get("video") or {})
688
831
  data = we.export_cues(entry, style=style,
689
832
  width=int(vp.get("out_w") or K.W),
@@ -693,33 +836,19 @@ def _st_cues_web(script, work):
693
836
 
694
837
 
695
838
  # ---------------------------------------------------------------- 配乐 / 音效
696
- def st_score(script, work, tl):
697
- """程序化配乐 -> work/music.wav(scorekit 不在就跳过,不阻断出片)。
839
+ def _score_with_musicgen(script, work, tl, plan_s, mus):
840
+ """用参数化编排器出成片配乐(与 previewkit 的样音同一个引擎、同一组参数)。
698
841
 
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 兼容)。
842
+ 为什么不复用 scorekit 那条路:scorekit 的 9 套 MUSIC_STYLES 是**风格表**,
843
+ 而本技能 v1.0.1 起不再提供任何模板 —— 配乐必须由设计里的参数现场生成。
844
+ musicgen 的六个参数(progressions / motif / drums / voices / mood / sections)
845
+ 全部参与事件展开,实测参数相关性 seed 0.16 / mood 0.32 / voices 0.22,无一是死旋钮。
705
846
  """
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"])
847
+ print("== Score(musicgen 参数化编排)")
848
+ mg = _try_module("musicgen")
849
+ mp = plan_s["music"]
720
850
  sections = mus.get("sections")
721
851
  if sections:
722
- # film.json 允许用 id 表述分段起点,换成绝对秒
723
852
  fixed = []
724
853
  for s in sections:
725
854
  s2 = dict(s)
@@ -727,76 +856,104 @@ def st_score(script, work, tl):
727
856
  s2["start"] = K.cue_time(s2.pop("at"), tl["scenes"])
728
857
  fixed.append(s2)
729
858
  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),
859
+ kwargs = dict(
860
+ bpm=float(mp["bpm"]),
861
+ key=str(mp["key"] or "Dm"),
862
+ scale=str(mp.get("scale") or "minor"),
863
+ mood=str(mp.get("mood") or "steady"),
864
+ # seed_effective 是"内容指纹掺进去之后"的 32 位大数;musicgen 内部按
865
+ # sha1(seed) 派生,直接给大整数没问题,但统一夹到 2^31-1 内可以保证
866
+ # 与 scorekit 时代的取模口径一致(老 work/ 目录里的 score.json 能对上)。
867
+ seed=int(mp.get("seed_effective") or mp.get("seed") or 0) % (2 ** 31 - 1),
868
+ sections=sections,
762
869
  )
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")))
870
+ # 设计里显式给出的编排参数原样透传(progressions/motif/drums/voices/energy_curve)。
871
+ # 只在键存在时传:musicgen 对 None 与缺省的处理不同(缺省=自己按 seed 派生)。
872
+ for key in ("progressions", "motif", "drums", "voices", "energy_curve"):
873
+ if mp.get(key) is not None:
874
+ kwargs[key] = mp[key]
875
+ t0 = time.time()
876
+ try:
877
+ samples, report = mg.render(float(tl["total"]), **kwargs)
878
+ except Exception as e: # noqa: BLE001
879
+ raise SystemExit(
880
+ "[score] musicgen.render() 失败:%s\n"
881
+ " 参数:bpm=%s key=%s scale=%s mood=%s seed=%s\n"
882
+ " 这是配乐生成的真错误(不是签名不兼容)——先把参数改对,别改成静默跳过。"
883
+ % (e, kwargs.get("bpm"), kwargs.get("key"), kwargs.get("scale"),
884
+ kwargs.get("mood"), kwargs.get("seed"))) from e
782
885
  out = os.path.join(work, "music.wav")
783
- sk.save(out, samples)
886
+ mg.save(out, samples)
784
887
  if report:
785
888
  json.dump(report, open(os.path.join(work, "score.json"), "w", encoding="utf-8"),
786
889
  ensure_ascii=False, indent=1)
890
+ print(" musicgen: %s %s / %s BPM / %s / mood %s / seed %s"
891
+ % (report.get("key_name") or report.get("key"), report.get("scale"),
892
+ report.get("bpm"), "", report.get("mood"), report.get("seed")))
787
893
  samples_read, _sr = K.wav_read(out)
788
894
  print(" music.wav %.2fs 峰值 %.3f %.1fs" % (
789
895
  K.wav_dur(out), float(np.max(np.abs(samples_read))), time.time() - t0))
790
896
  return out
791
897
 
792
898
 
793
- def _sfx_seed_context(script):
899
+ def st_score(script, work, tl):
900
+ """参数化配乐 -> work/music.wav(musicgen 现场编排,没有模板可回退)。
901
+
902
+ 配乐来源由 K.music_plan 统一判定(与 st_audio 共用同一份语义):
903
+ - mode=off/none 或 enabled=false:不出配乐,也不删已存在的 music.wav
904
+ (删文件会误删"外部提供的配乐",而 off 的语义只是"本片不要配乐");
905
+ - mode=bed:走 v0.2 和弦床,不生成 musicgen 产物;
906
+ - mode=file:外部 work/music.wav,原样交给 audio 阶段;
907
+ - 缺省:有 bpm 就生成,没有 bpm 沿用已有 music.wav / 和弦床(v0.2 兼容)。
908
+
909
+ 需要真的生成配乐时,**只有 musicgen 一条路**:legacy 计划或 musicgen.py 缺失都会
910
+ 明确报错并给迁移指引,不再用 scorekit 的配乐模板兜底(v1.0.1 零模板)。
911
+ """
912
+ print("== Score")
913
+ mus = script.get("music") or {}
914
+ music_path = os.path.join(work, "music.wav")
915
+ plan, why = K.music_plan(script, has_music_file=os.path.exists(music_path))
916
+ print(" " + why)
917
+ if plan in ("off", "bed", "file", "none"):
918
+ return None
919
+ if plan in ("missing-file", "missing-bpm", "unknown-mode"):
920
+ raise SystemExit("[score] " + why)
921
+ # v1.0.1:**配乐只有 musicgen 一条路**(参数化编排器,没有风格表)。
922
+ #
923
+ # 为什么必须换:v1.0 里 previewkit 用 musicgen 出 12 秒样音,而 st_score 用 scorekit
924
+ # 出成片音乐 —— 两者是**两个不同的作曲器**,实测同一设计下相关系数 -0.0057(就是两首
925
+ # 不同的曲子)。用户看样音选方案,成片音乐完全不同,等于"确认"这个动作在音频上失效。
926
+ #
927
+ # v1.0.1 把 scorekit 的 9 套 MUSIC_STYLES 风格表整个删了(零模板),它的 render()
928
+ # 只剩 legacy 逐样本兼容路径。所以这里**不再有 scorekit 回退**:scorekit 一旦回来
929
+ # 当兜底,成片音乐就又变成一套固定模板,与"每条视频都不一样"直接冲突。
930
+ plan_s0 = resolve_style(script, work)
931
+ if plan_s0.get("legacy"):
932
+ raise SystemExit(
933
+ "[score] legacy 配乐路径已移除(v1.0.1 零模板):\n"
934
+ " scorekit 的 9 套配乐风格表已删除,legacy 回退会把成片配乐锁死成一套固定模板。\n"
935
+ " 正确做法(两步):\n"
936
+ " 1) python propose.py --brief \"题材/要求\" --script film.json # 出 3 个方案\n"
937
+ " 2) python propose.py --confirm --candidate A # 冻结 work/design.json\n"
938
+ " 3) python render.py --script film.json --stage score # 用 musicgen 现场编排\n"
939
+ " (想手写配乐参数:film.json 的 music.bpm / key / mood / sections 仍然有效)")
940
+ if _try_module("musicgen") is None:
941
+ raise SystemExit(
942
+ "[score] 找不到 musicgen.py,无法生成配乐:\n"
943
+ " v1.0.1 起配乐只有参数化编排器一条路(scorekit 的风格模板已删除,不再兜底)。\n"
944
+ " 检查 <SKILL_DIR>/scripts/musicgen.py 是否被拷到工作目录——\n"
945
+ " quickstart 的 cp <SKILL_DIR>/scripts/*.py . 会把它一起拷过来;\n"
946
+ " 只需要旁白/音效时把 film.json 的 music.mode 设为 off 或 file。")
947
+ return _score_with_musicgen(script, work, tl, plan_s0, mus)
948
+
949
+
950
+ def _sfx_seed_context(script, work=None):
794
951
  """v0.7:算音效的内容派生上下文 (style, content_fp, key, enabled)。
795
952
 
796
953
  legacy(没写 style 字段)时返回全 None -> CueBus 走旧的 seed_base + i 路径,
797
954
  v0.4 及以前的片子逐样本不变。参考 references/audio-variation.md §3.2 / §4。
798
955
  """
799
- plan = resolve_style(script)
956
+ plan = resolve_style(script, work)
800
957
  sfx_cfg = script.get("sfx") or {}
801
958
  want_content = bool(sfx_cfg.get("content_seed", True))
802
959
  if plan.get("legacy") or not want_content:
@@ -809,7 +966,7 @@ def _sfx_seed_context(script):
809
966
  def _duck_transition_sfx(samples, cues, scenes, sr=K.SR, depth_db=-3.0, window=2.2):
810
967
  """段落边界前的转场音效自动让位(v0.7,契约 §4)。
811
968
 
812
- 配乐在段落交界自己已经有一段过门(scorekit 的 swell / riser / sub_boom),
969
+ 配乐在段落交界自己已经有一段过门(编排器写的 swell / riser / sub_boom),
813
970
  sfx 轨再在同一时刻放 whoosh / riser,同一频段叠两层就是"配乐和音效听起来像同一套"。
814
971
  做法:对位于 [边界-window, 边界] 内的转场类 cue,在它的时长内乘一个 dB 增益包络。
815
972
  只压转场类,不碰 impact / 提示音(那是"该响"的语义重音)。
@@ -877,7 +1034,7 @@ def st_sfx(script, work, tl):
877
1034
  print(" cues.json 里没有提示点,跳过")
878
1035
  return None
879
1036
  sfx_cfg = script.get("sfx") or {}
880
- style, content_fp, key = _sfx_seed_context(script)
1037
+ style, content_fp, key = _sfx_seed_context(script, work)
881
1038
  try:
882
1039
  samples = bus.build_audio(total=tl["total"],
883
1040
  base_db=sfx_cfg.get("base_db"),
@@ -919,7 +1076,7 @@ def _voice_track(tl, script):
919
1076
 
920
1077
 
921
1078
  def _legacy_music(tl, script):
922
- """v0.2 的和弦床(没有 scorekit / music.wav 时的兜底)。"""
1079
+ """v0.2 的和弦床(没有 music.wav 时的兜底)。"""
923
1080
  mus = script.get("music") or {}
924
1081
  if not (mus.get("enabled", True) and mus.get("chords")):
925
1082
  return None
@@ -969,7 +1126,7 @@ def st_audio(tl, work, script):
969
1126
  print(" 配乐: " + why)
970
1127
  if plan == "score" and music is None:
971
1128
  # 单独跑 --stage audio 时 score 还没跑过:不混配乐,但必须说清楚为什么
972
- print(" [WARN] music 用 scorekit 但 %s 不存在(先跑 --stage score);本次无配乐"
1129
+ print(" [WARN] music.mode=score 但 %s 不存在(先跑 --stage score);本次无配乐"
973
1130
  % music_path)
974
1131
 
975
1132
  sfx = None
@@ -988,7 +1145,7 @@ def st_audio(tl, work, script):
988
1145
  sfx = bus.bus[: int(round(total * K.SR))].copy()
989
1146
  print(" 音效: 旧版转场 whoosh(没有 cues.json)")
990
1147
 
991
- plan_s = resolve_style(script)
1148
+ plan_s = resolve_style(script, work)
992
1149
  # 风格化的 duck 深度:轻快/极简风格的配乐更"薄",让位可以浅一点;
993
1150
  # 电影/史诗要压得更狠。用户显式写 music.duck_db 时永远以用户为准。
994
1151
  # v0.7:整体 +1 dB —— 旧值(2.0/2.5/3.0/4.5)下配乐只比音效低 1.5 dB、duck 仅 3 dB,
@@ -1071,6 +1228,29 @@ def _run_preflight(script, work, engine, tl=None, skip=False):
1071
1228
  return True
1072
1229
 
1073
1230
 
1231
+ # ---------------------------------------------------------------- 字幕样式(v1.0.2)
1232
+ _SUB_CACHE = {}
1233
+
1234
+
1235
+ def _sub_kw(script):
1236
+ """从已解析的 plan 里取字幕参数(缓存按 script 对象身份,连续帧不重复解析)。
1237
+
1238
+ v1.0.2:字幕样式由 design.json 的 visual.subtitle 决定;参考帧渲染路径
1239
+ (qavmaf._draw_ref_frame)必须调用同一个 filmkit.subtitle_kwargs(plan),
1240
+ 否则两帧字幕参数不同会把同源好片的 VMAF 拉低。
1241
+ """
1242
+ key = id(script)
1243
+ cached = _SUB_CACHE.get(key)
1244
+ if cached is not None:
1245
+ return cached
1246
+ plan = script.get("_style") if isinstance(script.get("_style"), dict) else None
1247
+ kw = K.subtitle_kwargs(plan)
1248
+ if len(_SUB_CACHE) > 8: # 批量渲染会跨很多 script:别无限长
1249
+ _SUB_CACHE.clear()
1250
+ _SUB_CACHE[key] = kw
1251
+ return kw
1252
+
1253
+
1074
1254
  # ---------------------------------------------------------------- 渲染
1075
1255
  def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True):
1076
1256
  """渲染 [f0, f1) 帧区间到 out_path。纯函数:同一帧号永远画出同一张图。"""
@@ -1096,7 +1276,8 @@ def _render_range(tl, mod, script, f0, f1, out_path, crf, preset, progress=True)
1096
1276
  _THEME_CACHE.update(k=id(script), th=th)
1097
1277
  _hold = script.get("sub_max_hold")
1098
1278
  frame = K.draw_subtitle(frame, K.sub_at(scenes, t, _hold), theme=th,
1099
- words=K.sub_words_at(scenes, t, _hold))
1279
+ words=K.sub_words_at(scenes, t, _hold),
1280
+ **_sub_kw(script))
1100
1281
  fade = 1.0
1101
1282
  if t < 0.5:
1102
1283
  fade *= K.ease_out(t / 0.5)
@@ -1148,7 +1329,8 @@ def _seg_usable(path, frames):
1148
1329
 
1149
1330
  def _render_chunk_worker(args):
1150
1331
  """分块渲染工作单元(供 multiprocessing 调用;函数必须模块级可 pickle)。"""
1151
- (tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args
1332
+ (tl_path, scenes_path, script_path, f0, f1, out_path, crf, preset, part_path) = args[:9]
1333
+ work = args[9] if len(args) > 9 else ""
1152
1334
  tl = json.load(open(tl_path, encoding="utf-8"))
1153
1335
  script = json.load(open(script_path, encoding="utf-8"))
1154
1336
  script["_total"] = tl["total"]
@@ -1156,9 +1338,15 @@ def _render_chunk_worker(args):
1156
1338
  # scenes._bg() 拿 th["bg"] 当缓存键 -> TypeError: unhashable type: "list",
1157
1339
  # 于是 --jobs >1 整条并行路径直接崩。计划是**纯函数**,这里按剧本重新解一次,
1158
1340
  # 既拿回元组,也保证子进程的主题与父进程逐字段一致。
1341
+ #
1342
+ # v1.0 修:重解时**必须带上 work**。design 路径的身份在 work/design.json 里,
1343
+ # 不带 work 就只能走 stylekit 的 auto 分支 -> 画出一套完全不同的主题。
1344
+ # 实测后果:--jobs 4 的成片是 tech-deep(偏蓝),而 --jobs 1 / QA 参考帧是
1345
+ # 设计本身的近黑金;两者 MAE 0.059、VMAF 直接掉到 1.49。
1346
+ # 这种"并行度不同就出不同片子"的 bug 最隐蔽——单线程跑一遍永远看不见。
1159
1347
  script.pop("_style", None)
1160
1348
  try:
1161
- script["_style"] = resolve_style(script, verbose=False)
1349
+ script["_style"] = resolve_style(script, work or None, verbose=False)
1162
1350
  except Exception as e: # noqa: BLE001
1163
1351
  print(" [WARN] 子进程重解风格失败,回退到快照里的 _style:%s" % e)
1164
1352
  script["_style"] = json.load(open(script_path, encoding="utf-8")).get("_style")
@@ -1179,7 +1367,7 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
1179
1367
  script["_total"] = tl["total"] # 场景算全局进度条要用
1180
1368
  # 分块 worker 是 spawn 出来的新进程:主题必须随快照走,否则子进程算出的
1181
1369
  # _theme_for 与父进程不一致 -> 拼接处出现"半部片换了配色"。
1182
- script.setdefault("_style", resolve_style(script))
1370
+ script.setdefault("_style", resolve_style(script, work))
1183
1371
  crf = int(script.get("crf", 18))
1184
1372
  preset = script.get("preset", "medium")
1185
1373
  total_frames = tl["frames"]
@@ -1256,7 +1444,8 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
1256
1444
  except OSError:
1257
1445
  pass
1258
1446
  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))
1447
+ todo.append((tl_path, scenes_path, script_path, f0, f1, seg, crf, preset, part,
1448
+ str(work)))
1260
1449
 
1261
1450
  # 旧计划留下的分块不会参与拼接,但会一直占盘:说出来,不静默
1262
1451
  stale = [n for n in os.listdir(seg_dir)
@@ -1298,15 +1487,18 @@ def st_frames(tl, work, scenes_mod, script, jobs=1, resume=True, engine="pil"):
1298
1487
  return silent
1299
1488
 
1300
1489
 
1301
- def _web_style_payload(we, script, tl=None):
1490
+ def _web_style_payload(we, script, tl=None, work=None):
1302
1491
  """把风格包解析成 webengine 能吃的 __STYLE__ 载荷(v0.8)。
1303
1492
 
1304
1493
  style=None(legacy 项目 / 解析失败)时返回 None —— webengine 收到 None 连
1305
1494
  add_init_script 都不调,页面行为与 v0.7 逐字节一致(这是兼容性硬要求)。
1306
1495
  """
1307
1496
  try:
1308
- style_plan = resolve_style(script, verbose=False)
1309
- except Exception as e: # noqa: BLE001
1497
+ style_plan = resolve_style(script, work, verbose=False)
1498
+ # 必须连 SystemExit 一起接:resolve_style 在"没有设计/主题不过体检"时用 SystemExit
1499
+ # 报错(这是它向 CLI 报用法错误的方式),而 SystemExit 继承自 BaseException,
1500
+ # 只 catch Exception 接不住 —— web 页面会拿着半截载荷渲染,或者整个进程直接退出。
1501
+ except (Exception, SystemExit) as e: # noqa: BLE001
1310
1502
  print(" [WARN] web 风格包解析失败,页面用内置配色:%s" % e)
1311
1503
  return None
1312
1504
  if not style_plan or style_plan.get("legacy"):
@@ -1335,7 +1527,7 @@ def _st_frames_web(tl, work, script, jobs=1, resume=True):
1335
1527
  entry = _web_entry(script)
1336
1528
  silent = os.path.join(work, "silent.mp4")
1337
1529
  web = script.get("web") or {}
1338
- style = _web_style_payload(we, script, tl)
1530
+ style = _web_style_payload(we, script, tl, work)
1339
1531
  vp = tl.get("video") or {}
1340
1532
  canvas_w = int(vp.get("out_w") or K.W)
1341
1533
  canvas_h = int(vp.get("out_h") or K.H)
@@ -1476,8 +1668,15 @@ def _qa_gates(lg, rep, deep, tl, work):
1476
1668
  ns = len(deep.get("sync") or [])
1477
1669
  judged = (deep.get("sync_info") or {}).get("judged")
1478
1670
  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)
1671
+ # v1.0.2:门禁要按 **deep.errors** 判,而不是按 flicker 条数。
1672
+ # qa_frames 会把"块内像素有增有减"(文字入场/母题动画/镜头缓推)的候选从
1673
+ # flicker 降级进 warnings(见 FLICKER_AGREE_MIN),此时 flicker 仍非空但已不成问题;
1674
+ # 按条数判会让降级形同虚设(零拷贝首片实测:降级后仍判 fail)。
1675
+ # errors 里同时也包含空窗与同步超差,所以只看 errors 不会漏判。
1676
+ n_err = len([e for e in (deep.get("errors") or []) if e])
1677
+ ok9 = n_err == 0
1678
+ detail9 = "问题 %d 条(闪烁候选 %d 已按符号一致度分档)· 空窗 %d · 同步超差 %d(%s)" % (
1679
+ n_err, nf, nb, ns, sync_note)
1481
1680
  if ok9 and not judged and os.environ.get("QA_REQUIRE_SYNC"):
1482
1681
  # 与 SKILL 的 QA_REQUIRE_SYNC 同义:要求"真的查过",而不是"没报错"
1483
1682
  lg.fail("G9", "帧级深检(闪烁/空窗/同步超差)",
@@ -1601,8 +1800,19 @@ def _qa_vmaf_gate(lg, film, tl, work, scenes_mod, script, mode=""):
1601
1800
  if cfg["ssim"].get(ckey) is not None:
1602
1801
  kw[key] = float(cfg["ssim"][ckey])
1603
1802
  print(" [vmaf] 模式=%s(参考帧=场景函数实时渲染,不是成片抽帧)" % m)
1803
+ # qavmaf 是独立解析(它拿不到本进程的 script["_style"]),所以这里把
1804
+ # **冻结设计**一起传下去:参考帧必须与成片同源,否则 VMAF 会掉到个位数。
1805
+ # 实测未修前:design 片被解析成 tech-deep,VMAF 1.50、同源抽查 MAE=0.0384。
1806
+ script_for_vmaf = dict(script or {})
1807
+ dpath = os.path.join(str(work), "design.json")
1808
+ if "design" not in script_for_vmaf and os.path.exists(dpath):
1809
+ try:
1810
+ with open(dpath, encoding="utf-8") as fh:
1811
+ script_for_vmaf["design"] = json.load(fh)
1812
+ except (OSError, json.JSONDecodeError) as e: # noqa: BLE001
1813
+ print(" [WARN][vmaf] design.json 读不出来,参考帧可能不同源:%s" % e)
1604
1814
  try:
1605
- res = qv.report(film, tl, scenes_mod, script, mode=m,
1815
+ res = qv.report(film, tl, scenes_mod, script_for_vmaf, mode=m,
1606
1816
  out_json=os.path.join(str(work), "qa", "vmaf.json"), **kw)
1607
1817
  except Exception as e: # noqa: BLE001
1608
1818
  lg.fail("G11", "全片 VMAF / SSIM 画质", "门禁自身失败:%s" % e)
@@ -1699,7 +1909,7 @@ def _qa_asr_gate(lg, film, work, script, tl=None, cfg=None):
1699
1909
 
1700
1910
  def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
1701
1911
  print("== QA")
1702
- th = _theme_for(script) if script is not None else None
1912
+ th = _theme_for(script, work) if script is not None else None
1703
1913
  shots, refs = [], [] # refs: [(frac 下标, PIL 原图)]
1704
1914
  fracs = (0.08, 0.3, 0.55, 0.78)
1705
1915
  ref_script = dict(script) if script is not None else None
@@ -1724,7 +1934,8 @@ def st_qa(film, tl, work, scenes_mod=None, script=None, vmaf_mode=""):
1724
1934
  int(round(sec * tl["fps"])))
1725
1935
  _hold = ref_script.get("sub_max_hold")
1726
1936
  fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], sec, _hold), theme=th,
1727
- words=K.sub_words_at(tl["scenes"], sec, _hold))
1937
+ words=K.sub_words_at(tl["scenes"], sec, _hold),
1938
+ **_sub_kw(ref_script))
1728
1939
  refs.append((k, fr))
1729
1940
  except Exception as e: # noqa: BLE001
1730
1941
  print(" [WARN] 参考帧渲染失败:", e)
@@ -1831,7 +2042,7 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
1831
2042
  print("== Stills")
1832
2043
  script = dict(script)
1833
2044
  script["_total"] = tl["total"]
1834
- script.setdefault("_style", resolve_style(script))
2045
+ script.setdefault("_style", resolve_style(script, work))
1835
2046
  outdir = os.path.join(work, "stills")
1836
2047
  os.makedirs(outdir, exist_ok=True)
1837
2048
  by_id = {s["id"]: s for s in tl["scenes"]}
@@ -1860,7 +2071,8 @@ def st_stills(tl, work, scenes_mod, script, specs=None):
1860
2071
  _hold = script.get("sub_max_hold")
1861
2072
  fr = K.draw_subtitle(fr, K.sub_at(tl["scenes"], tg, _hold),
1862
2073
  theme=_theme_for(script),
1863
- words=K.sub_words_at(tl["scenes"], tg, _hold))
2074
+ words=K.sub_words_at(tl["scenes"], tg, _hold),
2075
+ **_sub_kw(script))
1864
2076
  p = os.path.join(outdir, "%s_%05.1f.png" % (sid, float(lt)))
1865
2077
  fr.save(p)
1866
2078
  made.append(p)
@@ -2122,12 +2334,94 @@ def run_batch(a):
2122
2334
  return 0 if okn == len(results) else 1
2123
2335
 
2124
2336
 
2337
+ # ---------------------------------------------------------------- 提案闸(v1.0)
2338
+ def _proposal_gate(a):
2339
+ """提案闸:frames 之前必须先有"已确认的设计"。
2340
+
2341
+ D3 之后本项目的主路径是"先提案、再出片",所以这条闸是语义要求,不是可选装饰。
2342
+ 但它默认只警告(0 退出码),加 --require-proposal 才失败——原因只有一条:
2343
+ --stage frames 是开发期高频命令(改一帧看一帧),硬拦会让调试变得没法用。
2344
+
2345
+ 判定(propose.proposal_state 的几档):
2346
+ absent -> 没有任何提案
2347
+ draft -> 有提案但没确认
2348
+ confirmed -> 已确认且 design.json 指纹与确认时一致(唯一放行态)
2349
+ stale -> 确认过但 design.json 被改过(等于没确认)
2350
+ """
2351
+ try:
2352
+ import propose as PP
2353
+ except ImportError:
2354
+ print("[proposal] 没有 propose.py,跳过提案检查(只警告)")
2355
+ return True
2356
+ try:
2357
+ status, detail = PP.proposal_state(a.work)
2358
+ except SystemExit as e:
2359
+ status, detail = "broken", str(e)
2360
+ except Exception as e: # noqa: BLE001
2361
+ status, detail = "broken", "%s: %s" % (type(e).__name__, e)
2362
+
2363
+ if status == "confirmed":
2364
+ print("[proposal] OK %s" % detail)
2365
+ _LEDGER.ok("proposal", "提案已确认", detail)
2366
+ return True
2367
+
2368
+ msg = "提案闸未通过(%s):%s" % (status, detail)
2369
+ print("[proposal] WARN %s" % msg)
2370
+ if status in ("absent", "draft"):
2371
+ print(" 先跑:python propose.py --brief \"题材/要求\" --script %s --n 3" % a.script)
2372
+ print(" 确认:python propose.py --confirm --candidate A")
2373
+ if bool(getattr(a, "require_proposal", False)):
2374
+ _LEDGER.fail("proposal", "提案未确认", msg)
2375
+ print("\n[提案闸] --require-proposal 下这是失败项:先提案并确认,或去掉该参数")
2376
+ return False
2377
+ _LEDGER.skip("proposal", "提案闸", msg + "(默认只警告;--require-proposal 才失败)")
2378
+ return True
2379
+
2380
+
2381
+ # ---------------------------------------------------------------- 场景模块定位
2382
+ def _builtin_scenes() -> str:
2383
+ """本技能内置的场景绘制模块路径(装成插件后在包内 skills/video-studio/assets/scenes.py)。"""
2384
+ cand = (Path(__file__).resolve().parent.parent / "assets" / "scenes.py")
2385
+ return str(cand) if cand.exists() else ""
2386
+
2387
+
2388
+ def _resolve_scenes(scenes_arg, script_path) -> str:
2389
+ """定位场景模块,按优先级:显式 --scenes > 剧本同目录 scenes.py > 技能内置 scenes.py。
2390
+
2391
+ 为什么要这条回退链(v1.0.2):旧流程要求用户把 assets/scenes.py 拷进工作目录,
2392
+ 即「提供一份模板代码」——与 v1.0.1「不提供任何模板」直接冲突,而且用户每部片都得
2393
+ 重拷一份 98KB 的绘制代码。现在把内置渲染器当**默认能力**:工作目录里没有 scenes.py
2394
+ 时直接用它出片;需要改画面再写自己的 scenes.py(显式指定或用同目录约定)。
2395
+ """
2396
+ if scenes_arg:
2397
+ p = Path(scenes_arg).resolve()
2398
+ if p.exists():
2399
+ return str(p)
2400
+ raise SystemExit(
2401
+ "[缺少场景模块] --scenes 指向的 %s 不存在。\n"
2402
+ " 不写 --scenes 时会自动用剧本同目录的 scenes.py 或技能内置渲染器;"
2403
+
2404
+ "只想用内置的就去掉 --scenes。" % p)
2405
+ cand = Path(script_path).resolve().parent / "scenes.py"
2406
+ if cand.exists():
2407
+ return str(cand)
2408
+ builtin = _builtin_scenes()
2409
+ if builtin:
2410
+ return builtin
2411
+ raise SystemExit(
2412
+ "[缺少场景模块] 找不到 %s,也找不到技能内置渲染器(%s)。\n"
2413
+ " 装成插件时内置渲染器应在 <包>/skills/video-studio/assets/scenes.py;"
2414
+
2415
+ "请检查插件包是否完整,或显式传 --scenes 指向你自己的 scenes.py。" % (cand, _builtin_scenes() or "missing"))
2416
+
2417
+
2125
2418
  # ---------------------------------------------------------------- main
2126
2419
  def main():
2127
2420
  _setup_stdio()
2128
2421
  ap = argparse.ArgumentParser(description="video-studio 编排器")
2129
2422
  ap.add_argument("--script", default="film.json")
2130
- ap.add_argument("--scenes", default="scenes.py")
2423
+ ap.add_argument("--scenes", default="",
2424
+ help="场景绘制模块(默认依次找:<剧本目录>/scenes.py、本技能内置的 scenes.py)")
2131
2425
  ap.add_argument("--out", default="out/film.mp4")
2132
2426
  ap.add_argument("--work", default="work")
2133
2427
  ap.add_argument("--stage", default="all", choices=STAGES)
@@ -2152,6 +2446,8 @@ def main():
2152
2446
  help="过时也照跑(会在 QC 报告里留痕)")
2153
2447
  ap.add_argument("--no-gate", action="store_true",
2154
2448
  help="跳过人工签核检查(会在 QC 报告里留痕,不算通过)")
2449
+ ap.add_argument("--require-proposal", action="store_true",
2450
+ help="把'提案未确认'当成失败(默认只警告;先跑 propose.py 再出片)")
2155
2451
  ap.add_argument("--cost", action="store_true",
2156
2452
  help="每个 stage 往 work/cost.jsonl 记一行实测耗时")
2157
2453
  # ---- v0.8 ----
@@ -2183,7 +2479,7 @@ def main():
2183
2479
  if engine not in ("pil", "web"):
2184
2480
  raise SystemExit("engine 只能是 pil 或 web,收到 %r" % engine)
2185
2481
  script["_engine"] = engine
2186
- script["_scenes"] = str(Path(a.scenes).resolve())
2482
+ script["_scenes"] = _resolve_scenes(a.scenes, a.script)
2187
2483
 
2188
2484
  # ---- 门禁账本(v0.6.0)----
2189
2485
  global _LEDGER
@@ -2204,8 +2500,15 @@ def main():
2204
2500
  print("[gate] --no-gate:跳过人工签核检查(会在 QC 报告里留痕)")
2205
2501
 
2206
2502
  def _sources():
2207
- """源头输入:剧本与 scenes.py(改它们等于改了所有下游的依据)。"""
2208
- return {"script": a.script, "scenes": a.scenes}
2503
+ """源头输入:剧本、scenes.py、冻结设计(改它们等于改了所有下游的依据)。
2504
+
2505
+ design.json 是 v1.0 新增的第三个源头:它由 propose.py --confirm 写出,
2506
+ 记录"这一版长什么样"。改设计 = 画面必须重渲,所以它必须进 stagewatch 的
2507
+ 源头指纹,否则"改了配色但没重跑 frames"这种事故查不出来。
2508
+ """
2509
+ design = os.path.join(a.work, "design.json")
2510
+ return {"script": a.script, "scenes": a.scenes,
2511
+ "design": design if os.path.exists(design) else ""}
2209
2512
 
2210
2513
  # 本次命令行**计划要跑**的 stage 集合:--stage all 展开成 10 个,单 stage 就是它自己。
2211
2514
  # 用途见 stagewatch.check 的 pending:本阶段马上要用新输入重算,那种"输入变了"不是过时。
@@ -2323,21 +2626,27 @@ def main():
2323
2626
  raise SystemExit("[前置] %s 不是合法 JSON(%s),重新跑 --stage layout" % (tl_p, e)) from e
2324
2627
 
2325
2628
  def load_scenes(strict=True):
2326
- """加载场景模块:strict=False 时失败只 WARN(qa 缺参考帧也要能跑)。"""
2629
+ """加载场景模块:strict=False 时失败只 WARN(qa 缺参考帧也要能跑)。
2630
+
2631
+ v1.0.2:路径用 main() 里解析好的 script["_scenes"](显式 --scenes / 剧本同目录 /
2632
+ 技能内置三档回退),不能再用原始 a.scenes —— 缺省时它是空串,会变成
2633
+ `load_module("")` 这种 "NoneType has no attribute loader" 的怪错误。
2634
+ """
2635
+ scenes_path = script.get("_scenes") or _resolve_scenes(a.scenes, a.script)
2327
2636
  try:
2328
- mod = load_module(a.scenes)
2637
+ mod = load_module(scenes_path)
2329
2638
  except Exception as e: # noqa: BLE001
2330
2639
  if strict:
2331
2640
  raise SystemExit("[场景模块加载失败] %s:%s\n"
2332
2641
  " --scenes 要指向定义 draw_frame() 的 scenes.py"
2333
- % (a.scenes, e)) from e
2334
- print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (a.scenes, e))
2642
+ % (scenes_path, e)) from e
2643
+ print(" [WARN] 场景模块加载失败(%s),跳过场景函数参考帧: %s" % (scenes_path, e))
2335
2644
  return None
2336
2645
  if not hasattr(mod, "draw_frame"):
2337
2646
  if strict:
2338
2647
  raise SystemExit("[场景模块错误] %s 没有 draw_frame(scene, t_local, dur, t_global, script, frame)"
2339
- % a.scenes)
2340
- print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" % a.scenes)
2648
+ % scenes_path)
2649
+ print(" [WARN] %s 没有 draw_frame(),跳过场景函数参考帧" % scenes_path)
2341
2650
  return None
2342
2651
  return mod
2343
2652
 
@@ -2362,7 +2671,11 @@ def main():
2362
2671
  print("== Preview(前 %.1fs / %d 帧)" % (n / float(tl["fps"]), n))
2363
2672
  sc2 = dict(script)
2364
2673
  sc2["_total"] = tl["total"]
2365
- sc2.setdefault("_style", resolve_style(script))
2674
+ # 注意这里之前写成 setdefault(_style, resolve_style(script)):
2675
+ # 对 sc2 做 setdefault 却传 script 的解析结果 —— script 有 _style 时,
2676
+ # setdefault 的第二参数**仍会被求值**(Python 先算参数),等于多做一次无用解析;
2677
+ # 而 script 没有 _style 时又把 work 漏了,单跑 --preview 会报"找不到设计"。
2678
+ sc2.setdefault("_style", resolve_style(script, a.work))
2366
2679
  _render_range(tl, mod, sc2, 0, n, out_mp4, int(script.get("crf", 18)),
2367
2680
  str(script.get("preset", "medium")), progress=False)
2368
2681
  # 有 audio.wav 就带上声音(没有也能看画面;不阻断)。
@@ -2567,6 +2880,11 @@ def main():
2567
2880
  return 1 if (errs or rc) else 0
2568
2881
 
2569
2882
  if stage in ("all", "frames", "mux"):
2883
+ # ---- 提案闸(v1.0):出片前必须先有已确认的设计 ----
2884
+ # 放在最前面(before ensure_tl/preflight/st_frames):渲完再拦等于白渲。
2885
+ if stage in ("all", "frames"):
2886
+ if not _proposal_gate(a):
2887
+ return 1
2570
2888
  tl = tl if stage == "all" else ensure_tl()
2571
2889
  audio = os.path.join(a.work, "audio.wav")
2572
2890
  silent = os.path.join(a.work, "silent.mp4")