@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.
- package/CHANGELOG.md +424 -0
- package/README.md +287 -327
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/package.json +9 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +4 -15
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1316 -1123
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +1 -2
- package/skills/video-studio/assets/golden_frames.json +11 -8
- package/skills/video-studio/assets/scenes.py +67 -22
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/audio-variation.md +15 -11
- package/skills/video-studio/references/audio.md +36 -34
- package/skills/video-studio/references/change-impact.md +3 -3
- package/skills/video-studio/references/frame-qa.md +2 -2
- package/skills/video-studio/references/proposal.md +177 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/scene-grammar.md +28 -23
- package/skills/video-studio/references/sfx-design.md +1 -1
- package/skills/video-studio/references/style-packs.md +95 -257
- package/skills/video-studio/references/v08-quality.md +8 -8
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/references/word-timeline.md +2 -2
- package/skills/video-studio/scripts/designkit.py +2120 -0
- package/skills/video-studio/scripts/filmcheck.py +7 -33
- package/skills/video-studio/scripts/filmkit.py +153 -78
- package/skills/video-studio/scripts/grammar.py +67 -7
- package/skills/video-studio/scripts/musicgen.py +2043 -0
- package/skills/video-studio/scripts/preflight.py +10 -3
- package/skills/video-studio/scripts/previewkit.py +849 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +45 -10
- package/skills/video-studio/scripts/qavmaf.py +77 -11
- package/skills/video-studio/scripts/render.py +472 -154
- package/skills/video-studio/scripts/scorekit.py +50 -1748
- package/skills/video-studio/scripts/selfcheck.py +658 -131
- package/skills/video-studio/scripts/stagewatch.py +21 -5
- package/skills/video-studio/scripts/stylekit.py +265 -741
- package/skills/video-studio/scripts/voicekit.py +502 -380
- package/skills/video-studio/scripts/webengine.py +15 -14
- package/tools/audio_similarity.py +57 -30
- package/tools/ci_check.py +7 -1
- package/tools/estimate.py +14 -8
- package/tools/golden_frames.py +56 -11
- package/tools/silhouette_qa.py +521 -168
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- 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
|
|
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
|
-
|
|
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 = {"
|
|
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
|
-
|
|
626
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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
|
-
|
|
640
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
#
|
|
1452
|
-
#
|
|
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
|
|
1470
|
-
return "score", "music.mode=score
|
|
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
|
|
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
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
"""
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|