@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
@@ -82,7 +82,7 @@ KEY_ALIASES = {
82
82
  "cue": "cues", "sfx_cues": "cues", "sound": "sfx", "bgm": "music",
83
83
  }
84
84
 
85
- # 不是 film.json 的其他产物(例如 assets/score_demo.json 是 scorekit 报告)——
85
+ # 不是 film.json 的其他产物(例如配乐引擎落盘的 score.json 报告)——
86
86
  # 交给本脚本前先认出来,直接 SKIP 而不是按剧本报"缺 title"的错误。
87
87
  OTHER_ARTIFACT_KEYS = ("generator", "meas", "reproduce", "sections", "beats",
88
88
  "transitions", "sr", "duration", "score")
@@ -322,7 +322,11 @@ SCRIPT_SPEC = {
322
322
  "props": {
323
323
  "title": {"t": "str", "nonempty_warn": True},
324
324
  "end_text": {"t": "str"},
325
- "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),这里写任何值都不生效"},
326
330
  "engine": {"t": "str", "enum": ENGINES},
327
331
  "voice": {"t": ("str", "dict"), "open": "dict"},
328
332
  "rate": {"t": "str", "pattern": RATE_RE, "example": "+6%"},
@@ -470,22 +474,6 @@ def _check(val, rule, path, rep, ctx):
470
474
  if rule.get("inert"):
471
475
  rep.warn(path, "这个键**当前不生效**:%s" % rule["inert"], "inert_key")
472
476
 
473
- if rule.get("enum_dynamic") == "style":
474
- styles = ctx.get("styles")
475
- if styles is None:
476
- if not ctx.get("style_warned"):
477
- ctx["style_warned"] = True
478
- rep.warn(path, "stylekit.STYLES 不可用(找不到 %s/stylekit.py),"
479
- "跳过 style 枚举校验——只提示,不判错" % str(HERE), "stylekit_missing")
480
- else:
481
- enum = ["auto"] + list(styles)
482
- if enum and isinstance(val, str) and val not in enum:
483
- near = _suggest(val, [e for e in enum if isinstance(e, str)])
484
- hint = (";是不是想写 %r?" % near) if near else ""
485
- rep.err(path, "枚举错误:%r 不是可用值(可用:%s)%s"
486
- % (val, " / ".join(str(e) for e in enum), hint), "bad_enum")
487
- return
488
-
489
477
  if isinstance(val, (int, float)) and not isinstance(val, bool):
490
478
  if rule.get("positive") and val <= 0:
491
479
  rep.err(path, "必须大于 0(收到 %r;bpm 这类字段也可以写 \"auto\")" % val, "bad_range")
@@ -813,19 +801,6 @@ def _fail(msg, as_json, code=2):
813
801
  raise SystemExit(code)
814
802
 
815
803
 
816
- def _load_styles():
817
- """从 stylekit.STYLES 单向读取风格 id;拿不到返回 None(调用方降级成只 WARN)。"""
818
- try:
819
- if str(HERE) not in sys.path:
820
- sys.path.insert(0, str(HERE))
821
- import stylekit # noqa: F401 (只读枚举,不复制)
822
- # 优先用公开的 STYLE_IDS(8 套),没有就退回 STYLES 的键(含内部 legacy)
823
- ids = list(getattr(stylekit, "STYLE_IDS", ()) or getattr(stylekit, "STYLES", {}).keys())
824
- return ids or None
825
- except Exception: # noqa: BLE001
826
- return None
827
-
828
-
829
804
  def _load_personas():
830
805
  """从 voicekit 单向读取 persona 名与 tone id;拿不到返回 (None, None) 降级成不查。
831
806
 
@@ -891,7 +866,7 @@ def main(argv=None):
891
866
 
892
867
  kind = _classify(data)
893
868
  rep = Report()
894
- ctx = {"styles": _load_styles(), "style_warned": False, "timeline": None}
869
+ ctx = {"timeline": None}
895
870
  skipped = False
896
871
  if kind == "artifact":
897
872
  skipped = True
@@ -911,7 +886,6 @@ def main(argv=None):
911
886
  "errors": rep.errors,
912
887
  "warnings": rep.warnings,
913
888
  "counts": {"errors": len(rep.errors), "warnings": len(rep.warnings)},
914
- "style_ids": ctx.get("styles"),
915
889
  "timeline": ctx.get("timeline"),
916
890
  }
917
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:
@@ -468,8 +468,21 @@ def plan_scenes(scenes, script=None, plan=None):
468
468
  """给 timeline 的每一段补 scene_type / scene_variant / scene_decor / scene_layout / scene_reason。
469
469
 
470
470
  scenes: K.build_timeline 产出的段列表(有 id/kind/start/end/dur 与剧本原字段)
471
- plan: stylekit.resolve_script_style 的结果(取 chapter_layout 与 style)
471
+ plan: stylekit.resolve_script_style / designkit.design_to_plan 的结果
472
+ (取 chapter_layout / layout_cycle / layout_default 与 style)
472
473
  返回 storyboard dict(可直接 json.dump 审计),并把字段就地写进 scenes。
474
+
475
+ scene_layout 的取法(先命中先赢):
476
+ ① plan["chapter_layout"] 显式指定(按章名查,查不到用 layout_default);
477
+ ② plan["layout_cycle"](≥2 种版式且没有显式 chapter_layout):按章节首次
478
+ 出现的顺序循环取位,**同一章内版式固定**——防止"全片一张版式";
479
+ ③ 都没有:layout_default(与 v1.0.1 完全一致)。
480
+
481
+ **没有 chapter 的剧本**:按"每段自成一章"处理(轮换照常推进)。
482
+ 为什么不把空 chapter 当成"全片同一章":那样 layout_cycle 在常见的不写 chapter
483
+ 的剧本上会退化成"永远 cycle[0]",功能写了却完全不生效——正是本仓库反复踩过的
484
+ "号称支持实则失效"。空 chapter 本来就没有"章"的身份可言,逐段轮换既满足
485
+ "同章内稳定"(每段自成一章,平凡成立),又真的防住同骨架。
473
486
  """
474
487
  script = script or {}
475
488
  plan = plan or {}
@@ -479,6 +492,22 @@ def plan_scenes(scenes, script=None, plan=None):
479
492
  prev_variant = {}
480
493
  chapter_layout = plan.get("chapter_layout") if isinstance(plan.get("chapter_layout"), dict) else {}
481
494
  default_layout = plan.get("layout_default") or "left-stack"
495
+ # ---- 片内版式轮换(v1.0.2):design_to_plan 会派生 layout_cycle ----
496
+ # 为什么需要:没有它时一章只能用一种版式(layout_default),8 段片子 8 屏同骨架 ——
497
+ # 正好是用户原则 5 说的"同一视频内部大面积/长时间重复"。
498
+ # 生效条件(两条都不满足就与过去逐字节一致,金帧/既有自检不漂):
499
+ # ① plan 没有显式 chapter_layout(显式指定优先,不能被轮换覆盖);
500
+ # ② layout_cycle 至少 2 种版式(只有一种等于不轮换)。
501
+ # 同一章内必须稳定(观众视角一章是一段整体),换章才换版式:
502
+ # 按「章节首次出现的顺序」依次取 cycle[0], cycle[1], … 循环。
503
+ cycle = plan.get("layout_cycle")
504
+ if isinstance(cycle, (list, tuple)):
505
+ cycle = [str(x) for x in cycle if str(x or "").strip()]
506
+ else:
507
+ cycle = []
508
+ use_cycle = (not chapter_layout) and len(cycle) >= 2
509
+ # key -> 版式(保证同一章内版式不变;无 chapter 时每段自成一章,轮换照常推进)
510
+ cycle_of = {}
482
511
  counter = {}
483
512
  rows = []
484
513
  for i, s in enumerate(scenes):
@@ -494,7 +523,16 @@ def plan_scenes(scenes, script=None, plan=None):
494
523
  variant = variant_of(stype, chapter, k, prev_variant.get(stype))
495
524
  prev_variant[stype] = variant
496
525
  decor = decor_of(stype, chapter, k)
497
- layout = chapter_layout.get(chapter) or default_layout
526
+ if use_cycle:
527
+ # 空 chapter 用段号做键:每段自成一"章",轮换才真的推进
528
+ # (否则最常见的不写 chapter 的剧本会永远停在 cycle[0],功能形同虚设)。
529
+ key = chapter if chapter else "\x00scene:%d" % i
530
+ if key not in cycle_of:
531
+ # len(cycle_of) = 这是第几个"不同章节"(0 起),按它取轮换位
532
+ cycle_of[key] = cycle[len(cycle_of) % len(cycle)]
533
+ layout = cycle_of[key]
534
+ else:
535
+ layout = chapter_layout.get(chapter) or default_layout
498
536
  s["scene_type"] = stype
499
537
  s["scene_variant"] = int(variant)
500
538
  s["scene_decor"] = int(decor)
@@ -527,17 +565,39 @@ def describe(storyboard):
527
565
  return "%d 段 / %s" % (len(storyboard.get("scenes") or []), " ".join(parts))
528
566
 
529
567
 
530
- def _cli():
531
- """自测入口:python grammar.py [film.json] —— 只读,不渲染。"""
568
+ def _cli(argv=None):
569
+ """自测入口:python grammar.py [film.json] —— 只读,不渲染。
570
+
571
+ v1.0.2:加 argparse(在此之前 `--help` 会被当成 film.json 路径,FileNotFoundError,
572
+ 与仓库里其它 17 个脚本的 CLI 约定不一致)。不传 film.json 时仍打印用法与 10 类构图,
573
+ 退出码 0(保持 v0.9 的行为,便于老 CI 探活)。
574
+ """
575
+ import argparse
532
576
  import json
533
- import sys
534
577
 
535
- if len(sys.argv) < 2:
578
+ ap = argparse.ArgumentParser(
579
+ prog="grammar.py",
580
+ description="场景语法:按内容推导每段的构图类型(只读,不渲染、不需要 timeline)。",
581
+ epilog="不给 film.json 时只打印用法与 10 类构图说明。")
582
+ ap.add_argument("film", nargs="?", default=None,
583
+ help="film.json 路径;给出时打印该剧本的分镜选型(时长按字数粗估)")
584
+ a = ap.parse_args(argv)
585
+
586
+ if not a.film:
536
587
  print("用法: python grammar.py film.json # 打印该剧本的分镜选型(不需要 timeline)")
537
588
  print("场景类型:", " / ".join("%s(%s)" % (t, SCENE_LABELS[t]) for t in SCENE_TYPES))
538
589
  return 0
539
- script = json.load(open(sys.argv[1], encoding="utf-8"))
590
+
591
+ try:
592
+ with open(a.film, encoding="utf-8") as fp:
593
+ script = json.load(fp)
594
+ except OSError as e:
595
+ ap.error("读不了 film.json:%s(%s)" % (a.film, e))
596
+ if not isinstance(script, dict):
597
+ ap.error("film.json 顶层必须是对象:%s" % a.film)
540
598
  lines = script.get("lines") or []
599
+ if not isinstance(lines, list):
600
+ ap.error("film.json 的 lines 必须是数组:%s" % a.film)
541
601
  # 没跑 TTS 时按字数粗估时长(只用于看选型,不是时间轴权威)
542
602
  scenes = []
543
603
  t = 0.0