@dsh-gis/dsh-video-studio-skill 0.8.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 (54) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/README.md +287 -304
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/package.json +11 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +171 -15
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1316 -1066
  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 +12 -9
  12. package/skills/video-studio/assets/scenes.py +2207 -1190
  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 +2 -2
  20. package/skills/video-studio/references/scene-grammar.md +311 -0
  21. package/skills/video-studio/references/sfx-design.md +1 -1
  22. package/skills/video-studio/references/style-packs.md +107 -168
  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 +104 -33
  28. package/skills/video-studio/scripts/filmkit.py +153 -78
  29. package/skills/video-studio/scripts/grammar.py +619 -0
  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 +505 -142
  37. package/skills/video-studio/scripts/scorekit.py +50 -1748
  38. package/skills/video-studio/scripts/selfcheck.py +851 -115
  39. package/skills/video-studio/scripts/stagewatch.py +21 -5
  40. package/skills/video-studio/scripts/stylekit.py +293 -570
  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 +9 -1
  45. package/tools/estimate.py +14 -8
  46. package/tools/golden_frames.py +56 -11
  47. package/tools/silhouette_qa.py +1263 -0
  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-craft.png +0 -0
  53. package/docs/styles/sheet-night.png +0 -0
  54. package/skills/video-studio/assets/score_demo.json +0 -307
@@ -42,6 +42,17 @@ HERE = Path(__file__).resolve().parent
42
42
  # ============================================================================
43
43
 
44
44
  KINDS = ("hook", "body", "quote", "outro", "emphasis", "calm") # 也是 voicekit 的语气 id
45
+
46
+ # 视觉场景类型(v0.9):决定"这段画面长成什么构图",与叙事 kind **正交**——
47
+ # kind 管声线/语气/配乐能量(voicekit / scorekit 读它);
48
+ # type 只管构图(assets/scenes.py 的 _DRAWERS 读它)。
49
+ # 所以**不新增 kind 值**:hero/stat/... 不是语气,硬塞进 KINDS 会撞 voicekit 的 tone id
50
+ # (TONE_IDS 与 KINDS 是同一组 6 个)。缺省(不写 lines[].type)= 由 grammar.py 按内容推导。
51
+ SCENE_TYPES = ("hero", "statement", "list", "stat", "compare", "process",
52
+ "quote", "chapter", "chart", "outro")
53
+ # 与 assets/scenes.py 的 _DRAWERS 键对齐,改一边必须改另一边。
54
+ # (grammar.SCENE_TYPES 是同一组 10 个值,但那份顺序用于"平手取位";这里只做成员校验,
55
+ # 本元组的顺序只影响报错信息里的枚举列举,不影响判定。三处不要再各写一份新名单。)
45
56
  PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast",
46
57
  "medium", "slow", "slower", "veryslow")
47
58
  MUSIC_MODES = ("score", "bed", "file", "off")
@@ -71,7 +82,7 @@ KEY_ALIASES = {
71
82
  "cue": "cues", "sfx_cues": "cues", "sound": "sfx", "bgm": "music",
72
83
  }
73
84
 
74
- # 不是 film.json 的其他产物(例如 assets/score_demo.json 是 scorekit 报告)——
85
+ # 不是 film.json 的其他产物(例如配乐引擎落盘的 score.json 报告)——
75
86
  # 交给本脚本前先认出来,直接 SKIP 而不是按剧本报"缺 title"的错误。
76
87
  OTHER_ARTIFACT_KEYS = ("generator", "meas", "reproduce", "sections", "beats",
77
88
  "transitions", "sr", "duration", "score")
@@ -200,6 +211,59 @@ LINE_ITEM_SPEC = {
200
211
  },
201
212
  }
202
213
 
214
+ # ---- v0.9 视觉场景字段的嵌套结构(写法照抄 LINE_ITEM_SPEC)----
215
+ # 这些对象的键由 scenes.py 的 10 个绘制器读:拼错就是"写了不生效",所以**不**用
216
+ # open=True(那是留给 sfx 配方参数那种真·透传的)。每层列全 props,才能把拼错报成
217
+ # unknown_key(WARN + "是不是想写 X"),把类型写错报成 ERROR。
218
+ # required 与 schema 表里的问号标记一致:必填 = 绘制器没有它就没法画。
219
+ LINE_STAT_SPEC = { # type=stat 的数字卡
220
+ "t": "dict", "required": ["value"],
221
+ "props": {
222
+ # 数字或字符串("92"、">3x"、"1/2" 这类写法只能当字符串)
223
+ "value": {"t": ("num", "str"), "nonempty": True},
224
+ "unit": {"t": "str"},
225
+ "label": {"t": "str"},
226
+ "note": {"t": "str"},
227
+ },
228
+ }
229
+
230
+ LINE_SERIES_SPEC = { # type=chart 的数据条
231
+ "t": "dict", "required": ["label", "value"],
232
+ "props": {
233
+ "label": {"t": "str", "nonempty": True},
234
+ "value": {"t": ("num", "str"), "nonempty": True},
235
+ "unit": {"t": "str"},
236
+ },
237
+ }
238
+
239
+ LINE_STEP_SPEC = { # type=process 的流程节点
240
+ "t": "dict", "required": ["label"],
241
+ "props": {
242
+ "label": {"t": "str", "nonempty": True},
243
+ "note": {"t": "str"},
244
+ },
245
+ }
246
+
247
+ # 对比卡的一侧:items 每项可以写字符串,也可以写 {text}(短条目直接写字符串更省事,两种都认)
248
+ LINE_SIDE_SPEC = {
249
+ "t": "dict",
250
+ "props": {
251
+ "head": {"t": "str"},
252
+ "items": {"t": "list", "item": {
253
+ "t": ("str", "dict"), "required": ["text"],
254
+ "props": {"text": {"t": "str", "nonempty": True}},
255
+ }},
256
+ },
257
+ }
258
+
259
+ LINE_SIDES_SPEC = { # type=compare 的左右对比(left/right 都可省略)
260
+ "t": "dict",
261
+ "props": {
262
+ "left": LINE_SIDE_SPEC,
263
+ "right": LINE_SIDE_SPEC,
264
+ },
265
+ }
266
+
203
267
  LINE_SPEC = {
204
268
  "t": "dict", "required": ["text"],
205
269
  "props": {
@@ -218,6 +282,16 @@ LINE_SPEC = {
218
282
  "persona": {"t": "str"},
219
283
  "tone": {"t": "str", "enum": KINDS},
220
284
  "voice": {"t": "str"},
285
+ # ---- v0.9 视觉场景字段(7 个全可选)----
286
+ # type 缺省由 grammar.py 按内容推导;其余字段是给绘制器的原料,不写就不画。
287
+ # 注意这些字段与 kind **正交**,不是新 kind:kind 仍只有上面 6 个(voicekit 的语气 id)。
288
+ "type": {"t": "str", "enum": SCENE_TYPES},
289
+ "quote": {"t": "str"},
290
+ "by": {"t": "str"},
291
+ "stats": {"t": "list", "item": LINE_STAT_SPEC},
292
+ "series": {"t": "list", "item": LINE_SERIES_SPEC},
293
+ "steps": {"t": "list", "item": LINE_STEP_SPEC},
294
+ "sides": LINE_SIDES_SPEC,
221
295
  },
222
296
  }
223
297
 
@@ -248,7 +322,11 @@ SCRIPT_SPEC = {
248
322
  "props": {
249
323
  "title": {"t": "str", "nonempty_warn": True},
250
324
  "end_text": {"t": "str"},
251
- "style": {"t": "str", "enum_dynamic": "style"},
325
+ # v1.0.2: style is deprecated since v1.0.1 (appearance comes from work/design.json).
326
+ # It used to use enum_dynamic -> _load_styles(); STYLE_IDS is now empty, so it always
327
+ # emitted a misleading WARN saying stylekit.py was missing (the file was there).
328
+ # Now it is an inert key: accepted (old scripts must not hard-fail) but reported.
329
+ "style": {"t": "str", "inert": "v1.0.1 起已废弃:外观由 work/design.json 决定(python propose.py --brief ... 再 --confirm),这里写任何值都不生效"},
252
330
  "engine": {"t": "str", "enum": ENGINES},
253
331
  "voice": {"t": ("str", "dict"), "open": "dict"},
254
332
  "rate": {"t": "str", "pattern": RATE_RE, "example": "+6%"},
@@ -396,22 +474,6 @@ def _check(val, rule, path, rep, ctx):
396
474
  if rule.get("inert"):
397
475
  rep.warn(path, "这个键**当前不生效**:%s" % rule["inert"], "inert_key")
398
476
 
399
- if rule.get("enum_dynamic") == "style":
400
- styles = ctx.get("styles")
401
- if styles is None:
402
- if not ctx.get("style_warned"):
403
- ctx["style_warned"] = True
404
- rep.warn(path, "stylekit.STYLES 不可用(找不到 %s/stylekit.py),"
405
- "跳过 style 枚举校验——只提示,不判错" % str(HERE), "stylekit_missing")
406
- else:
407
- enum = ["auto"] + list(styles)
408
- if enum and isinstance(val, str) and val not in enum:
409
- near = _suggest(val, [e for e in enum if isinstance(e, str)])
410
- hint = (";是不是想写 %r?" % near) if near else ""
411
- rep.err(path, "枚举错误:%r 不是可用值(可用:%s)%s"
412
- % (val, " / ".join(str(e) for e in enum), hint), "bad_enum")
413
- return
414
-
415
477
  if isinstance(val, (int, float)) and not isinstance(val, bool):
416
478
  if rule.get("positive") and val <= 0:
417
479
  rep.err(path, "必须大于 0(收到 %r;bpm 这类字段也可以写 \"auto\")" % val, "bad_range")
@@ -571,6 +633,29 @@ def check_cross(script, script_path, rep, ctx):
571
633
  "tail_map)。例如 %s: 1.4" % lid),
572
634
  "inert_line_tail")
573
635
 
636
+ # ---- v0.9 场景语法:绘制器的容量上限(超了会被静默截断) ----
637
+ # 这是本项目最忌讳的一类坑:作者写对了、跑得通、但画面上少了几条,而没有任何提示。
638
+ # 容量由 assets/scenes.py 的实现决定(stats 取前 3 条、series 前 6 条、steps 前 5 条、
639
+ # sides 每侧前 3 条),改那边要同步改这里。
640
+ _CAP = (("stats", 3), ("series", 6), ("steps", 5))
641
+ for i, ln in enumerate(lines):
642
+ for fld, cap in _CAP:
643
+ v = ln.get(fld)
644
+ if isinstance(v, list) and len(v) > cap:
645
+ rep.warn("lines[%d].%s" % (i, fld),
646
+ "%s 有 %d 条,但 %s 构图只画前 %d 条(其余不会出现在画面上)。"
647
+ "要全画请拆成多段,或减到 %d 条以内"
648
+ % (fld, len(v), fld, cap, cap), "scene_capacity")
649
+ sides = ln.get("sides")
650
+ if isinstance(sides, dict):
651
+ for side in ("left", "right"):
652
+ blk = sides.get(side)
653
+ its = blk.get("items") if isinstance(blk, dict) else None
654
+ if isinstance(its, list) and len(its) > 3:
655
+ rep.warn("lines[%d].sides.%s.items" % (i, side),
656
+ "sides.%s 有 %d 条,但左右对比构图每侧只画前 3 条"
657
+ % (side, len(its)), "scene_capacity")
658
+
574
659
  # ---- v0.8 video 块:只支持 16:9 横屏 + 下采样输出 ----
575
660
  # **不要在这里重写一遍规则**:允许列表 / 宽高比 / 上采样拒绝的权威实现在
576
661
  # filmkit.video_plan()。以前这里是第二套手写判据,于是"1600x900"能过 filmcheck
@@ -716,19 +801,6 @@ def _fail(msg, as_json, code=2):
716
801
  raise SystemExit(code)
717
802
 
718
803
 
719
- def _load_styles():
720
- """从 stylekit.STYLES 单向读取风格 id;拿不到返回 None(调用方降级成只 WARN)。"""
721
- try:
722
- if str(HERE) not in sys.path:
723
- sys.path.insert(0, str(HERE))
724
- import stylekit # noqa: F401 (只读枚举,不复制)
725
- # 优先用公开的 STYLE_IDS(8 套),没有就退回 STYLES 的键(含内部 legacy)
726
- ids = list(getattr(stylekit, "STYLE_IDS", ()) or getattr(stylekit, "STYLES", {}).keys())
727
- return ids or None
728
- except Exception: # noqa: BLE001
729
- return None
730
-
731
-
732
804
  def _load_personas():
733
805
  """从 voicekit 单向读取 persona 名与 tone id;拿不到返回 (None, None) 降级成不查。
734
806
 
@@ -794,7 +866,7 @@ def main(argv=None):
794
866
 
795
867
  kind = _classify(data)
796
868
  rep = Report()
797
- ctx = {"styles": _load_styles(), "style_warned": False, "timeline": None}
869
+ ctx = {"timeline": None}
798
870
  skipped = False
799
871
  if kind == "artifact":
800
872
  skipped = True
@@ -814,7 +886,6 @@ def main(argv=None):
814
886
  "errors": rep.errors,
815
887
  "warnings": rep.warnings,
816
888
  "counts": {"errors": len(rep.errors), "warnings": len(rep.warnings)},
817
- "style_ids": ctx.get("styles"),
818
889
  "timeline": ctx.get("timeline"),
819
890
  }
820
891
 
@@ -612,40 +612,101 @@ def fit_sub_lines(text: str, max_lines=2, max_w=1560, size=46, weight="reg",
612
612
  return wrap_cn(text, f, max_w), f
613
613
 
614
614
 
615
+ def subtitle_kwargs(plan):
616
+ """从已解析的计划里取字幕样式参数(v1.0.2)。
617
+
618
+ 返回可直接 ``draw_subtitle(..., **kw)`` 的 dict;计划里没有 subtitle 时返回空 dict
619
+ (= 模块默认值 = 过去写死的行为,老项目逐像素不变)。
620
+
621
+ 为什么放在 filmkit 而不是 render:**参考帧渲染路径**(qavmaf 的 _draw_ref_frame)
622
+ 与主渲染路径必须用同一份字幕参数,否则同源的两帧字幕字号不同,VMAF 会被
623
+ 系统性拉低(实测:dv 变的 46px 默认 vs 52px 设计,同源参考帧 VMAF 91.3,
624
+ 换了正确参数后回到 96+)。一份实现放这里,两条路径都调它。
625
+ """
626
+ sub = plan.get("subtitle") if isinstance(plan, dict) else None
627
+ if not isinstance(sub, dict):
628
+ return {}
629
+ kw = {}
630
+ if sub.get("size") is not None:
631
+ kw["size"] = int(sub["size"])
632
+ if sub.get("y") is not None:
633
+ kw["y"] = int(sub["y"])
634
+ if sub.get("align") in ("center", "left"):
635
+ kw["align"] = sub["align"]
636
+ if sub.get("plate_alpha") is not None:
637
+ kw["plate_alpha"] = int(sub["plate_alpha"])
638
+ if sub.get("radius") is not None:
639
+ kw["radius"] = int(sub["radius"])
640
+ if sub.get("color") is not None:
641
+ kw["color"] = sub["color"]
642
+ if sub.get("max_lines") is not None:
643
+ kw["max_lines"] = int(sub["max_lines"])
644
+ if sub.get("family"):
645
+ kw["family"] = sub["family"]
646
+ return kw
647
+
615
648
  def draw_subtitle(img, text: str, alpha=1.0, size=46, y=None, band_w=None,
616
- theme=None, family=None, words=None):
649
+ theme=None, family=None, words=None, align="center",
650
+ plate_alpha=None, radius=10, color=None, max_lines=2):
617
651
  """烧字幕。v0.5.0 加 theme(stylekit 主题)与 family(字体族);
618
- theme=None 时与 v0.4 逐像素一致。v0.8 加 words(按词边界折行)。"""
652
+ theme=None 时与 v0.4 逐像素一致。v0.8 加 words(按词边界折行)。
653
+
654
+ v1.0.2 加 align / plate_alpha / radius / color / max_lines(全部可选,
655
+ 默认值 = 过去写死的行为,所以老调用逐像素不变)。为什么加:字幕样式过去是
656
+ **模块常量**,任何两条片子的字幕带都一模一样 —— 这正是用户说的"大面积重复"。
657
+ 现在这些参数由 design.json 的 visual.subtitle 决定(见 designkit.subtitle_of)。
658
+ """
619
659
  if not text or alpha <= 0.01:
620
660
  return img
621
661
  if band_w is None:
622
662
  band_w = SUB_BAND[2] - SUB_BAND[0]
623
663
  th = theme if isinstance(theme, dict) else None
624
664
  fam = family or (th or {}).get("family") or "sans"
625
- lines, f = fit_sub_lines(text, max_w=band_w, size=size, family=fam, words=words)
626
- lines = lines[:2] # 硬约束:最多两行
665
+ try:
666
+ max_lines = max(1, min(3, int(max_lines)))
667
+ except (TypeError, ValueError):
668
+ max_lines = 2
669
+ lines, f = fit_sub_lines(text, max_lines=max_lines, max_w=band_w, size=size,
670
+ family=fam, words=words)
671
+ lines = lines[:max_lines]
627
672
  lh = int(f.size * 1.32)
628
- cx = (SUB_BAND[0] + SUB_BAND[2]) // 2
629
673
  total_h = lh * len(lines)
674
+ if align == "left":
675
+ cx = SUB_BAND[0] + 34
676
+ else:
677
+ cx = (SUB_BAND[0] + SUB_BAND[2]) // 2
630
678
  base_y = SUB_BAND[3] - total_h if y is None else y
631
679
  base_y = max(SUB_BAND[1], min(base_y, SUB_BAND[3] - total_h))
632
680
  d = ImageDraw.Draw(img, "RGBA")
633
681
  widest = max(text_w(l, f) for l in lines)
634
- bx0 = cx - widest / 2 - 34
635
- bx1 = cx + widest / 2 + 34
682
+ if align == "left":
683
+ bx0 = cx - 34
684
+ bx1 = cx + widest + 34
685
+ else:
686
+ bx0 = cx - widest / 2 - 34
687
+ bx1 = cx + widest / 2 + 34
636
688
  by0 = base_y - 16
637
689
  by1 = base_y + total_h + 16
690
+ if color is not None:
691
+ ink_rgb = tuple(int(x) for x in color)[:3]
692
+ elif th is None:
693
+ ink_rgb = (245, 248, 252)
694
+ else:
695
+ ink_rgb = tuple(th["ink"])
696
+ ink = tuple(ink_rgb) + (int(255 * alpha),)
638
697
  if th is None:
639
- plate = (0, 0, 0, int(150 * alpha))
640
- ink = (245, 248, 252, int(255 * alpha))
698
+ _pa = 150 if plate_alpha is None else int(plate_alpha)
699
+ plate = (0, 0, 0, int(_pa * alpha))
641
700
  else:
642
701
  # 浅色主题不能"黑底白字":背板取 bg2 半透,字取 ink
643
- plate = tuple(th["bg2"]) + (int(170 * alpha),)
644
- ink = tuple(th["ink"]) + (int(255 * alpha),)
645
- d.rounded_rectangle([bx0, by0, bx1, by1], radius=10, fill=plate)
702
+ _pa = 170 if plate_alpha is None else int(plate_alpha)
703
+ plate = tuple(th["bg2"]) + (int(_pa * alpha),)
704
+ if plate[3] > 0:
705
+ d.rounded_rectangle([bx0, by0, bx1, by1], radius=int(radius), fill=plate)
646
706
  for i, ln in enumerate(lines):
647
707
  lw = text_w(ln, f)
648
- d.text((cx - lw / 2, base_y + i * lh), ln, font=f, fill=ink)
708
+ x = (cx - 34 + 34) if align == "left" else (cx - lw / 2)
709
+ d.text((x, base_y + i * lh), ln, font=f, fill=ink)
649
710
  return img
650
711
 
651
712
 
@@ -691,7 +752,7 @@ def wav_write(path, samples, sr=SR, channels=None):
691
752
  def wav_read(path):
692
753
  """读 PCM WAV(16 / 24 / 32-bit),返回 float32 数组:单声道 (n,) / 立体声 (n,2)。
693
754
 
694
- v0.3.0 起支持 24-bit:scorekit 落盘默认 PCM_24(多 8 dB 动态余量),
755
+ v0.3.0 起支持 24-bit:musicgen / scorekit 落盘都用 PCM_24(多 8 dB 动态余量),
695
756
  而 v0.2 的 wav_write 只写 16-bit。读取端必须两种都吃。
696
757
  """
697
758
  with wave.open(str(path), "rb") as w:
@@ -947,7 +1008,7 @@ def cue_time(at, scenes=None, total=None):
947
1008
 
948
1009
 
949
1010
  def scenes_anchors(scenes):
950
- """给 scorekit 分段用的锚点:[{id, kind, start, end}]。"""
1011
+ """给配乐引擎分段用的锚点:[{id, kind, start, end}]。"""
951
1012
  return [dict(id=s.get("id"), kind=s.get("kind") or "body",
952
1013
  start=float(s["start"]), end=float(s["end"])) for s in scenes]
953
1014
 
@@ -1434,7 +1495,7 @@ def music_plan(script, has_music_file: bool = False):
1434
1495
  现在 score 与 audio 两个 stage 都用这一份判定,口径不会再打架。
1435
1496
 
1436
1497
  plan 取值:
1437
- score scorekit 程序化配乐(work/music.wav)
1498
+ score 现场编排配乐(musicgen -> work/music.wav);scorekit 仅剩 legacy 原语
1438
1499
  file 外部提供 / 上一轮留下的 work/music.wav
1439
1500
  bed v0.2 和弦床(music.chords)
1440
1501
  off 明确不要配乐(mode=off/none 或 music.enabled=false)
@@ -1448,8 +1509,21 @@ def music_plan(script, has_music_file: bool = False):
1448
1509
  """
1449
1510
  mus = script.get("music") or {}
1450
1511
  raw = str(mus.get("mode") or "").strip().lower()
1451
- # v0.5.0:bpm 可以是 "auto"(由 stylekit 按风格梯度和内容指纹决定)。
1452
- # 这里只判断"有没有 bpm 这个概念",具体数值由 st_score 解析后传入 scorekit。
1512
+ # v1.0.1 修:设计路径的配乐参数在 script["_style"]["music"] 里(designkit 生成),
1513
+ # 而 film.json 顶层通常**没有** music 块。早先只看顶层,于是"有设计但没写 music"
1514
+ # 被判成 none(本片无配乐)—— 实测设计里 bpm=122/mood=driving,配乐却整段没出。
1515
+ # 现在顶层与设计计划合并:显式写 music 的字段优先(用户能覆盖设计),没写的用设计值。
1516
+ plan_mus = ((script.get("_style") or {}).get("music") or {})
1517
+ if plan_mus:
1518
+ merged = dict(plan_mus)
1519
+ merged.update({k: v for k, v in mus.items() if v is not None})
1520
+ mus = merged
1521
+ if not raw:
1522
+ raw = str(mus.get("mode") or "params").strip().lower()
1523
+ if raw == "params":
1524
+ raw = "" # params == 默认"按参数现场生成"
1525
+ # v0.5.0:bpm 可以是 "auto"(由风格梯度和内容指纹决定)。
1526
+ # 这里只判断"有没有 bpm 这个概念",具体数值由 st_score 传给配乐引擎。
1453
1527
  bpm = mus.get("bpm", script.get("bpm"))
1454
1528
  if raw == "auto":
1455
1529
  raw = ""
@@ -1466,14 +1540,14 @@ def music_plan(script, has_music_file: bool = False):
1466
1540
  "(把外部配乐拷成 work/music.wav,或删掉 mode=file)")
1467
1541
  if raw in ("score", "programmatic"):
1468
1542
  if bpm in (None, ""):
1469
- return "missing-bpm", "music.mode=score 但没有 bpm(scorekit 需要 bpm)"
1470
- return "score", "music.mode=score:scorekit 程序化配乐 bpm=%s" % bpm
1543
+ return "missing-bpm", "music.mode=score 但没有 bpm(配乐引擎需要 bpm)"
1544
+ return "score", "music.mode=score:现场编排配乐 bpm=%s" % bpm
1471
1545
  if raw:
1472
1546
  return "unknown-mode", ("music.mode=%r 不认识(可用:score / bed / file / off,"
1473
1547
  "或不写 mode 走兼容链)" % mus.get("mode"))
1474
1548
  # 缺省(v0.2 兼容链)
1475
1549
  if bpm not in (None, ""):
1476
- return "score", "缺省配乐:有 bpm=%s,用 scorekit 程序化配乐" % bpm
1550
+ return "score", "缺省配乐:有 bpm=%s,现场编排(musicgen)" % bpm
1477
1551
  if has_music_file:
1478
1552
  return "file", "缺省配乐:沿用已有 work/music.wav(外部提供的配乐)"
1479
1553
  if mus.get("chords"):
@@ -1498,63 +1572,42 @@ def resolve_theme(script, scene=None):
1498
1572
  优先级:scene["theme"](同明暗族才允许) > script 里解析出的风格主题。
1499
1573
  stylekit 不在时退回内置 tech-deep(只为了让老工作目录少一个文件也能跑)。
1500
1574
  """
1501
- try:
1502
- ST = _stylekit()
1503
- plan = script.get("_style")
1504
- if not isinstance(plan, dict):
1505
- plan = ST.resolve_script_style(script)
1506
- th = dict(ST.THEMES[plan["theme_id"]])
1507
- sid = (scene or {}).get("theme")
1508
- if sid and sid in ST.THEMES:
1509
- alt = ST.THEMES[sid]
1510
- if ST.family_compatible(th, alt):
1511
- th = dict(alt)
1512
- return th
1513
- except Exception: # noqa: BLE001
1514
- return _FALLBACK_THEME
1575
+ plan = script.get("_style") if isinstance(script, dict) else None
1576
+ if not isinstance(plan, dict):
1577
+ raise ValueError(
1578
+ "resolve_theme:剧本里没有已解析的设计计划(script[_style])。\n"
1579
+ " v1.0.1 起本技能不提供任何内置主题:每一版外观都必须现场生成并由人确认。\n"
1580
+ " 请用 python propose.py --brief \"题材\" --script <film.json> --n 3,"
1581
+ "\n 再 --confirm --candidate A 冻结设计;渲染侧走 render.resolve_style(script, work)。")
1582
+ # 设计路径的计划自带完整主题;v1.0.1 起没有内置主题表可回查。
1583
+ th = plan.get("theme")
1584
+ if not isinstance(th, dict) or not th:
1585
+ raise ValueError("resolve_theme:计划里没有 theme 字段(plan 来自 %r)——"
1586
+ "它不是 designkit/render 产出的计划,取不到主题"
1587
+ % (plan.get("generator") or plan.get("style"),))
1588
+ # scene["theme"] 覆盖("这一段换一套命名主题")已随内置主题表一起消失:
1589
+ # 可选装饰字段,静默忽略(它不是契约字段,为此报错会让老项目无法渲染)。
1590
+ return dict(th)
1515
1591
 
1516
1592
 
1517
1593
  def style_plan(script):
1518
- """解析剧本的风格计划(含 legacy 判定)。stylekit 缺失时返回 legacy 计划。"""
1519
- try:
1520
- ST = _stylekit()
1521
- plan = script.get("_style")
1522
- return plan if isinstance(plan, dict) else ST.resolve_script_style(script)
1523
- except Exception: # noqa: BLE001
1524
- return {"legacy": True, "style": "legacy", "theme_id": "tech-deep",
1525
- "music": {"style": "legacy", "bpm": script.get("bpm") or 120.0,
1526
- "key": (script.get("music") or {}).get("key") or "Dm",
1527
- "mood": "steady",
1528
- "seed_effective": int((script.get("music") or {}).get("seed", 7) or 7)},
1529
- "voice": {"map": {}, "policy": {"auto": False, "jitter": 0.0, "max_distinct": 2}},
1530
- "pacing": {"lead": float(script.get("lead", 0.62)),
1531
- "tail": float(script.get("tail", 0.92)),
1532
- "min_dur": float(script.get("min_dur", 3.2)),
1533
- "hold": float(script.get("hold", 0.0)),
1534
- "item_stagger": 0.32}}
1535
-
1536
-
1537
- # v0.4 的深蓝橙,作为"没装 stylekit 也能出片"的兜底(与 assets/scenes.py 的老 THEME 同值)
1538
- _FALLBACK_THEME = {
1539
- "id": "tech-deep", "label": "科技深蓝", "keywords": (),
1540
- "bg": (7, 14, 26), "bg2": (17, 33, 54), "surface": (26, 42, 64),
1541
- "ink": (240, 245, 252), "muted": (150, 174, 200), "dim": (120, 142, 168),
1542
- "accent": (243, 132, 60), "accent2": (86, 170, 232),
1543
- "good": (108, 178, 110), "bad": (226, 96, 92), "rule": (255, 255, 255),
1544
- "vignette": 0.40, "vignette_tint": (0, 0, 0), "grain": 5.0, "grain_freq": 0.31,
1545
- "texture": "dots", "texture_alpha": 0.045,
1546
- "family": "sans",
1547
- "scale": {"display": (96, "bold"), "heading": (64, "bold"), "sub": (38, "light"),
1548
- "body": (34, "reg"), "kicker": (28, "bold"), "chip": (27, "reg"),
1549
- "brand": (23, "reg")},
1550
- "line_gap": 1.30, "tracking": 0.0,
1551
- "layout": "left-stack",
1552
- "card": {"radius": 14, "fill_a": 16, "outline_a": 28, "pad": 34, "h": 96, "gap": 118},
1553
- "safe_bias": 0,
1554
- "ease": "out3", "entrance_dx": 0, "entrance_dy": 26, "push": 0.04,
1555
- "item_stagger": 0.32, "transition": "black", "hud": "chip-left",
1556
- "qa": {"luma_max": 0.030, "flicker_thresh": 6.0, "dark": True},
1557
- }
1594
+ """取已解析的风格计划;**不做 legacy 回落**(v1.0.2 起 fail-closed)。
1595
+
1596
+ 以前这里 except Exception 后返回一份手写的 legacy 计划(120BPM / Dm / 固定节奏),
1597
+ 等于把"没有设计"伪装成"老项目正常出片",也是模板残留(v1.0.1 原则:不提供任何模板)。
1598
+ 现在只认一种情况:script["_style"] 已经是计划(render.resolve_style 挂好的);
1599
+ 否则明确报错并给两步迁移指引。
1600
+ """
1601
+ plan = script.get("_style") if isinstance(script, dict) else None
1602
+ if isinstance(plan, dict):
1603
+ return plan
1604
+ raise ValueError(
1605
+ "style_plan:剧本里没有已解析的风格计划(script[_style])。\n"
1606
+ " v1.0.1 起没有「不写设计就走老外观」这条兼容路径了。\n"
1607
+ " 正确用法:\n"
1608
+ " python propose.py --brief \"题材\" --script <film.json> --n 3\n"
1609
+ " python propose.py --confirm --candidate A\n"
1610
+ " python render.py --script <film.json> --stage all")
1558
1611
 
1559
1612
 
1560
1613
  # ---------------------------------------------------------------- 程序化音源
@@ -2373,14 +2426,24 @@ def loudness(path):
2373
2426
  return out
2374
2427
 
2375
2428
 
2376
- def detect_black(path, dur=0.4, head=1.2, tail=None):
2429
+ def detect_black(path, dur=0.4, head=1.2, tail=None, pix_th=None):
2377
2430
  """长黑帧检测。head/tail 秒内是刻意的淡入淡出,不算问题。
2378
2431
 
2379
2432
  返回 None 表示**检测根本没跑成**(ffmpeg 打不开文件 / blackdetect 失败)。
2380
2433
  这一点必须可区分:旧实现把失败也返回 0,qa 门禁于是把"没查"记成"0 段黑帧 = 通过"。
2434
+
2435
+ pix_th:多"暗"算黑(0..1)。**必须跟着主题底色走**,否则暗色主题会被整片误判。
2436
+ blackdetect 的默认 pix_th 是 0.10,意思是"亮度低于 10% 的像素算黑";
2437
+ 而本项目的近黑金主题底色是 (8,8,10) ≈ 0.031 —— 全片 98% 的像素都在"黑"的阈值以下,
2438
+ 于是 blackdetect 把整段正常画面报成"4 段长黑帧"。实测:真实亮度全程 0.05–0.07,
2439
+ 一段真正的黑屏都没有,但门禁报了 4 段。
2381
2440
  """
2441
+ if pix_th is None:
2442
+ # 缺省仍取 ffmpeg 自带值,保证老调用方行为不变
2443
+ pix_th = 0.10
2444
+ vf = "blackdetect=d=%s:pic_th=0.98:pix_th=%.4f" % (dur, float(pix_th))
2382
2445
  p = _run([FFMPEG, "-hide_banner", "-nostats", "-i", str(path), "-vf",
2383
- "blackdetect=d=%s:pic_th=0.98" % dur, "-f", "null", "-"])
2446
+ vf, "-f", "null", "-"])
2384
2447
  if p.returncode != 0:
2385
2448
  return None
2386
2449
  total = media_dur(path)
@@ -2853,7 +2916,19 @@ def qa_report(path, expect_frames=None, ref_png=None, ref_sec=None, ref_img=None
2853
2916
  warns.append("真峰 %.1f dBTP 余量不足(AAC 编码后可能更高)" % lo["TP"])
2854
2917
  else:
2855
2918
  warns.append("未能测到真峰(ebur128 解析失败),责任门禁缺一项")
2856
- nb = detect_black(path)
2919
+ # 黑帧阈值跟着主题底色走(见 detect_black 的注释):底色亮度越低,阈值要越低,
2920
+ # 否则"深色主题的正常画面"会被当成整片黑屏。取底色亮度的 0.45 倍,
2921
+ # 下限 0.012(比纯黑略高,仍能抓到真黑),上限 0.10(ffmpeg 默认)。
2922
+ _pix_th = 0.10
2923
+ try:
2924
+ import stylekit as _ST
2925
+ _th = (theme or {}).get("bg") if isinstance(theme, dict) else None
2926
+ if isinstance(_th, (list, tuple)) and len(_th) >= 3:
2927
+ _luma = _ST.luminance(tuple(int(x) for x in _th[:3]))
2928
+ _pix_th = max(0.012, min(0.10, _luma * 0.45))
2929
+ except Exception: # noqa: BLE001
2930
+ _pix_th = 0.10
2931
+ nb = detect_black(path, pix_th=_pix_th)
2857
2932
  if nb is None:
2858
2933
  warns.append("长黑帧检测未执行(ffmpeg blackdetect 失败),本项不能当作通过")
2859
2934
  elif nb > 0: