@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.
- package/CHANGELOG.md +582 -0
- package/README.md +287 -304
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/package.json +11 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +171 -15
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1316 -1066
- 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 +12 -9
- package/skills/video-studio/assets/scenes.py +2207 -1190
- 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 +2 -2
- package/skills/video-studio/references/scene-grammar.md +311 -0
- package/skills/video-studio/references/sfx-design.md +1 -1
- package/skills/video-studio/references/style-packs.md +107 -168
- 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 +104 -33
- package/skills/video-studio/scripts/filmkit.py +153 -78
- package/skills/video-studio/scripts/grammar.py +619 -0
- 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 +505 -142
- package/skills/video-studio/scripts/scorekit.py +50 -1748
- package/skills/video-studio/scripts/selfcheck.py +851 -115
- package/skills/video-studio/scripts/stagewatch.py +21 -5
- package/skills/video-studio/scripts/stylekit.py +293 -570
- 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 +9 -1
- package/tools/estimate.py +14 -8
- package/tools/golden_frames.py +56 -11
- package/tools/silhouette_qa.py +1263 -0
- 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-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- 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
|
|
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
|
-
|
|
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 = {"
|
|
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
|
-
|
|
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:
|