@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.7.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 (38) hide show
  1. package/README.md +93 -18
  2. package/docs/styles/demo-results.json +76 -0
  3. package/docs/styles/sheet-8-styles.png +0 -0
  4. package/docs/styles/sheet-ai.png +0 -0
  5. package/docs/styles/sheet-craft.png +0 -0
  6. package/docs/styles/sheet-night.png +0 -0
  7. package/index.js +20 -7
  8. package/package.json +13 -3
  9. package/schemas/film.schema.json +599 -0
  10. package/skills/video-studio/SKILL.md +252 -28
  11. package/skills/video-studio/assets/film.json +78 -24
  12. package/skills/video-studio/assets/scenes.py +1005 -42
  13. package/skills/video-studio/assets/web/engine.js +18 -1
  14. package/skills/video-studio/assets/web/film.js +29 -4
  15. package/skills/video-studio/assets/web/index.html +5 -0
  16. package/skills/video-studio/references/audio-variation.md +158 -0
  17. package/skills/video-studio/references/audio.md +96 -6
  18. package/skills/video-studio/references/change-impact.md +54 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/style-packs.md +178 -0
  21. package/skills/video-studio/references/web-engine.md +1 -1
  22. package/skills/video-studio/requirements.txt +13 -1
  23. package/skills/video-studio/scripts/filmcheck.py +725 -0
  24. package/skills/video-studio/scripts/filmkit.py +953 -76
  25. package/skills/video-studio/scripts/gates.py +509 -0
  26. package/skills/video-studio/scripts/qa_frames.py +109 -19
  27. package/skills/video-studio/scripts/render.py +942 -45
  28. package/skills/video-studio/scripts/scorekit.py +1887 -27
  29. package/skills/video-studio/scripts/selfcheck.py +648 -2
  30. package/skills/video-studio/scripts/sfxkit.py +132 -30
  31. package/skills/video-studio/scripts/stagewatch.py +405 -0
  32. package/skills/video-studio/scripts/stylekit.py +1012 -0
  33. package/skills/video-studio/scripts/voicekit.py +880 -0
  34. package/skills/video-studio/scripts/webengine.py +68 -14
  35. package/tools/audio_similarity.py +279 -0
  36. package/tools/ci_check.py +450 -0
  37. package/tools/estimate.py +657 -0
  38. package/tools/selftest_noscipy.py +70 -0
@@ -0,0 +1,1012 @@
1
+ # -*- coding: utf-8 -*-
2
+ """stylekit.py — 风格包(Style Packs):一个 id 同时决定画风、配乐、旁白与节奏。
3
+
4
+ v0.5.0 新增。解决的问题是 v0.4 及以前的同质化:画风锁死在 scenes.py 的 THEME 常量、
5
+ 配乐锁死在 scorekit 的 Dm/120,旁白锁死在 render.py 的单个 voice —— 每部片必然长一个样。
6
+
7
+ 设计原则
8
+ --------
9
+ 1. 纯数据 + 纯函数:本模块只依赖标准库,不 import filmkit / scorekit / scenes
10
+ (它们反过来 import 我,成环就炸)。因此可以随便单测、追求确定性。
11
+ 2. 一个风格包决定三件事:主题(配色/字体/材质/HUD)、配乐(BPM 梯度/调池/情绪/音色组)、
12
+ 旁白(声线映射),外加节奏(lead/tail/min_dur/条目落位步长)。不允许互相独立配,
13
+ 否则会出现「极简黑白画面 + 史诗管弦」这种打架组合。
14
+ 3. 确定性:内容指纹只用 sha1 派生,禁 random / time / hash()(str hash 带随机盐)。
15
+ 同一份 film.json 永远解析出同一个风格;换一部片三个指纹大概率都变。
16
+ 4. 向后兼容:不写 style 字段 = legacy,此时 render/scorekit/scenes 走 v0.4 原路径,
17
+ 配乐逐样本一致(selfcheck 有断言)。
18
+
19
+ 三级指纹(专门为了「改个错别字不要换整首曲子」)
20
+ ---------------------------------------------
21
+ full 标题 + 所有台词 -> 决定音色细节 / 噪声纹理(seed 派生)
22
+ tempo 标题 + kind 序列 + 体量桶 -> 决定 BPM 与调性
23
+ style 关键词评分 + 指纹破平 -> 决定风格 id
24
+
25
+ 用法
26
+ ----
27
+ import stylekit as ST
28
+ plan = ST.resolve_script_style(script) # 纯函数,返回可直接落盘的 dict
29
+ th = ST.theme_of(plan["style"]) # 主题字典(元组原样,给 PIL 用)
30
+ ST.theme_lint(th) # 对比度体检,返回问题列表(空 = 过)
31
+
32
+ python stylekit.py --list # 列所有风格
33
+ python stylekit.py --show cinema # 看一套风格的完整字段
34
+ python stylekit.py --lint # 8 套主题全部走对比度门禁
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import hashlib
40
+ import re
41
+ import sys
42
+
43
+ # ============================================================================
44
+ # 一、色彩:WCAG 2.1 对比度(门禁要用,所以放在本模块,纯标准库)
45
+ # ============================================================================
46
+
47
+
48
+ def _srgb_to_lin(v):
49
+ """sRGB 通道(0..255)-> 线性光。"""
50
+ c = float(v) / 255.0
51
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
52
+
53
+
54
+ def luminance(rgb):
55
+ """相对亮度(WCAG 定义,0=黑 1=白)。"""
56
+ r, g, b = rgb[0], rgb[1], rgb[2]
57
+ return 0.2126 * _srgb_to_lin(r) + 0.7152 * _srgb_to_lin(g) + 0.0722 * _srgb_to_lin(b)
58
+
59
+
60
+ def contrast(c1, c2):
61
+ """两色的对比度(1.0 ~ 21.0)。文字门禁用它,比「看截图觉得还行」可靠。"""
62
+ l1, l2 = luminance(c1), luminance(c2)
63
+ hi, lo = (l1, l2) if l1 >= l2 else (l2, l1)
64
+ return (hi + 0.05) / (lo + 0.05)
65
+
66
+
67
+ def midpoint(c1, c2):
68
+ """两色中点(渐变背景的中位亮度,作为文字对比度的参考底)。"""
69
+ return (int(round((c1[0] + c2[0]) / 2.0)),
70
+ int(round((c1[1] + c2[1]) / 2.0)),
71
+ int(round((c1[2] + c2[2]) / 2.0)))
72
+
73
+
74
+ def _rgb(seq):
75
+ t = tuple(int(v) for v in seq[:3])
76
+ if len(t) != 3 or any(v < 0 or v > 255 for v in t):
77
+ raise ValueError("颜色必须是 0..255 的 RGB 三元组,收到 %r" % (seq,))
78
+ return t
79
+
80
+
81
+ _HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
82
+
83
+
84
+ def _color_of(v, key):
85
+ """把 theme_overrides 里的颜色值统一成 (r,g,b):支持 [r,g,b] 和 "#rrggbb" / "#rgb"。
86
+
87
+ 为什么必须支持 hex:schemas/film.schema.json 与 references/style-packs.md 的示例
88
+ 就是 {"accent": "#ff8a3d"},而 JSON 没有元组,照着文档写 hex 是必然的。
89
+ v0.5 旧实现把字符串原样塞进主题,scenes.py 第一次 int("#") 就崩在渲染期
90
+ (实测:ink="#ffffff" -> ValueError: invalid literal for int() with base 10: '#')。
91
+ """
92
+ if isinstance(v, str):
93
+ m = _HEX_RE.match(v.strip())
94
+ if not m:
95
+ raise ValueError("theme_overrides.%s 的颜色写法不认识:%r(用 [r,g,b] 或 \"#rrggbb\")"
96
+ % (key, v))
97
+ h = m.group(1)
98
+ if len(h) == 3:
99
+ h = "".join(ch * 2 for ch in h)
100
+ return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
101
+ if isinstance(v, (list, tuple)) and len(v) == 3:
102
+ return _rgb(v)
103
+ raise ValueError("theme_overrides.%s 必须是颜色([r,g,b] 或 \"#rrggbb\"),收到 %r"
104
+ % (key, v))
105
+
106
+
107
+ # 主题里"颜色字段"的白名单(值是 0..255 的 RGB 元组)。不靠"长度 3 的元组"猜:
108
+ # keywords 也是元组(长度 10),将来若加一个正好 3 项的关键词元组就会被误当颜色。
109
+ _THEME_COLOR_KEYS = frozenset(("bg", "bg2", "surface", "ink", "muted", "dim",
110
+ "accent", "accent2", "good", "bad", "rule", "vignette_tint"))
111
+
112
+
113
+ def apply_theme_overrides(theme, overrides):
114
+ """把 film.json 的 visual.theme_overrides 合并进主题**并做类型校验**。
115
+
116
+ 契约(schemas/film.schema.json:只接受主题里已存在的键;references/style-packs.md §3):
117
+ - 未知键忽略:以后主题加字段时老剧本不因为它不认识就炸;
118
+ - 颜色键(值本来是三色元组的那些)接受 [r,g,b] / "#rrggbb" / "#rgb",统一成元组;
119
+ - 数值键接受数字或数字字符串;字符串键必须是字符串;
120
+ - 嵌套结构(card / scale / qa …)不支持覆盖,静默忽略(与 v0.5 一致,避免破坏老配置);
121
+ 真要改嵌套字段请改风格包。
122
+ 校验只在"值类型不对、旧版会在渲染期崩"的场合抛 ValueError(提前到 --stage style)。
123
+ 返回新的 theme dict(不改入参)。
124
+ """
125
+ out = dict(theme or {})
126
+ for k, v in (overrides or {}).items():
127
+ if k not in out:
128
+ continue
129
+ base = out[k]
130
+ if k in _THEME_COLOR_KEYS:
131
+ out[k] = _color_of(v, k)
132
+ continue
133
+ if isinstance(base, bool):
134
+ if not isinstance(v, bool):
135
+ raise ValueError("theme_overrides.%s 必须是布尔,收到 %r" % (k, v))
136
+ out[k] = bool(v)
137
+ continue
138
+ if isinstance(base, (int, float)):
139
+ if isinstance(v, bool) or not isinstance(v, (int, float, str)):
140
+ raise ValueError("theme_overrides.%s 必须是数字,收到 %r" % (k, v))
141
+ try:
142
+ num = float(v)
143
+ except (TypeError, ValueError):
144
+ raise ValueError("theme_overrides.%s 必须是数字,收到 %r" % (k, v))
145
+ out[k] = int(round(num)) if isinstance(base, int) else float(num)
146
+ continue
147
+ if isinstance(base, dict):
148
+ # 嵌套结构(card / scale / qa)不支持覆盖。这里保持 v0.5 的**静默忽略**:
149
+ # 老配置里如果写了这种覆盖,旧版只是没生效、并不会崩;升级后突然报错是破坏性变更。
150
+ # 想改嵌套字段请改风格包(STYLES/THEMES),见 references/style-packs.md §7。
151
+ continue
152
+ if isinstance(base, str):
153
+ if not isinstance(v, str):
154
+ raise ValueError("theme_overrides.%s 必须是字符串,收到 %r" % (k, v))
155
+ if k == "layout" and v not in LAYOUTS:
156
+ raise ValueError("theme_overrides.layout 未知版式 %r;可用:%s"
157
+ % (v, " / ".join(LAYOUT_IDS)))
158
+ if k == "hud" and v not in ("chip-left", "corner-line", "none"):
159
+ raise ValueError("theme_overrides.hud 未知取值 %r;可用:chip-left / corner-line / none"
160
+ % (v,))
161
+ out[k] = v
162
+ continue
163
+ # 其它类型(None 等):保持原值,不折腾
164
+ return out
165
+
166
+
167
+ def _theme(style_id, label, keywords, palette, material, typ, layout, motion, hud, qa):
168
+ """把主题字段组装成一个扁平 dict(场景代码直接下标取,不嵌套,读起来快)。"""
169
+ p = {k: _rgb(v) for k, v in palette.items()}
170
+ return {
171
+ "id": style_id,
172
+ "label": label,
173
+ "keywords": tuple(keywords),
174
+ # T0 配色
175
+ "bg": p["bg"], "bg2": p["bg2"], "surface": p["surface"],
176
+ "ink": p["ink"], "muted": p["muted"], "dim": p["dim"],
177
+ "accent": p["accent"], "accent2": p["accent2"],
178
+ "good": p["good"], "bad": p["bad"], "rule": p["rule"],
179
+ # T1 材质
180
+ "vignette": float(material["vignette"]),
181
+ "vignette_tint": _rgb(material["vignette_tint"]),
182
+ "grain": float(material["grain"]),
183
+ "grain_freq": float(material["grain_freq"]),
184
+ "texture": str(material["texture"]),
185
+ "texture_alpha": float(material["texture_alpha"]),
186
+ # T2 排版
187
+ "family": str(typ["family"]),
188
+ "scale": dict(typ["scale"]),
189
+ "line_gap": float(typ.get("line_gap", 1.30)),
190
+ "tracking": float(typ.get("tracking", 0.0)),
191
+ # T3 版式
192
+ "layout": str(layout["layout"]),
193
+ "card": dict(layout["card"]),
194
+ "safe_bias": int(layout.get("safe_bias", 0)),
195
+ # T4 动态
196
+ "ease": str(motion["ease"]),
197
+ "entrance_dx": int(motion["entrance_dx"]),
198
+ "entrance_dy": int(motion["entrance_dy"]),
199
+ "push": float(motion["push"]),
200
+ "item_stagger": float(motion["item_stagger"]),
201
+ "transition": str(motion["transition"]),
202
+ # T5 HUD
203
+ "hud": str(hud["style"]),
204
+ # META
205
+ "qa": dict(qa),
206
+ }
207
+
208
+
209
+ THEMES = {}
210
+
211
+ THEMES["tech-deep"] = _theme(
212
+ "tech-deep", "科技深蓝",
213
+ ("AI", "算法", "代码", "模型", "系统", "数据", "架构", "开源", "编程", "大模型"),
214
+ dict(bg=(7, 14, 26), bg2=(17, 33, 54), surface=(26, 42, 64),
215
+ ink=(240, 245, 252), muted=(150, 174, 200), dim=(120, 142, 168),
216
+ accent=(243, 132, 60), accent2=(86, 170, 232),
217
+ good=(108, 178, 110), bad=(226, 96, 92), rule=(255, 255, 255)),
218
+ dict(vignette=0.40, vignette_tint=(0, 0, 0), grain=5.0, grain_freq=0.31,
219
+ texture="dots", texture_alpha=0.045),
220
+ dict(family="sans", scale={"display": (96, "bold"), "heading": (64, "bold"),
221
+ "sub": (38, "light"), "body": (34, "reg"),
222
+ "kicker": (28, "bold"), "chip": (27, "reg"),
223
+ "brand": (23, "reg")}),
224
+ dict(layout="left-stack",
225
+ card={"radius": 14, "fill_a": 16, "outline_a": 28, "pad": 34, "h": 96, "gap": 118}),
226
+ dict(ease="out3", entrance_dx=0, entrance_dy=26, push=0.04, item_stagger=0.32, transition="black"),
227
+ dict(style="chip-left"),
228
+ dict(luma_max=0.030, flicker_thresh=6.0, dark=True),
229
+ )
230
+
231
+ THEMES["magazine"] = _theme(
232
+ "magazine", "杂志暖白",
233
+ ("报告", "复盘", "年度", "总结", "研究", "分析", "调研", "白皮书", "趋势", "发布"),
234
+ dict(bg=(250, 246, 238), bg2=(236, 228, 214), surface=(255, 252, 246),
235
+ ink=(28, 26, 24), muted=(88, 82, 74), dim=(112, 104, 94),
236
+ accent=(176, 48, 40), accent2=(38, 86, 128),
237
+ good=(30, 110, 72), bad=(160, 36, 32), rule=(28, 26, 24)),
238
+ dict(vignette=0.10, vignette_tint=(214, 204, 186), grain=3.0, grain_freq=0.27,
239
+ texture="paper", texture_alpha=0.030),
240
+ dict(family="serif", scale={"display": (92, "bold"), "heading": (60, "bold"),
241
+ "sub": (36, "light"), "body": (33, "reg"),
242
+ "kicker": (27, "bold"), "chip": (26, "reg"),
243
+ "brand": (22, "reg")}, line_gap=1.34, tracking=2.0),
244
+ dict(layout="split-right",
245
+ card={"radius": 8, "fill_a": 26, "outline_a": 40, "pad": 30, "h": 92, "gap": 112},
246
+ safe_bias=-8),
247
+ dict(ease="out5", entrance_dx=18, entrance_dy=20, push=0.06, item_stagger=0.38, transition="wash"),
248
+ dict(style="corner-line"),
249
+ dict(luma_max=0.045, flicker_thresh=6.0, dark=False),
250
+ )
251
+
252
+ THEMES["paper"] = _theme(
253
+ "paper", "手绘纸感",
254
+ ("手工", "自然", "历史", "传统", "木", "田野", "匠", "古", "茶", "手艺", "山河"),
255
+ dict(bg=(240, 232, 212), bg2=(222, 210, 186), surface=(249, 244, 232),
256
+ ink=(46, 38, 30), muted=(92, 80, 64), dim=(112, 98, 80),
257
+ accent=(42, 72, 116), accent2=(124, 72, 32),
258
+ good=(48, 98, 60), bad=(150, 48, 40), rule=(46, 38, 30)),
259
+ dict(vignette=0.14, vignette_tint=(198, 184, 158), grain=9.0, grain_freq=0.22,
260
+ texture="paper", texture_alpha=0.055),
261
+ dict(family="kai", scale={"display": (92, "bold"), "heading": (60, "bold"),
262
+ "sub": (36, "light"), "body": (33, "reg"),
263
+ "kicker": (27, "bold"), "chip": (26, "reg"),
264
+ "brand": (22, "reg")}, line_gap=1.36, tracking=1.0),
265
+ dict(layout="left-stack",
266
+ card={"radius": 4, "fill_a": 30, "outline_a": 52, "pad": 32, "h": 94, "gap": 116}),
267
+ dict(ease="inOut3", entrance_dx=0, entrance_dy=18, push=0.03, item_stagger=0.42, transition="wash"),
268
+ dict(style="corner-line"),
269
+ dict(luma_max=0.045, flicker_thresh=6.0, dark=False),
270
+ )
271
+
272
+ THEMES["vivid"] = _theme(
273
+ "vivid", "活力撞色",
274
+ ("活动", "上新", "招募", "挑战", "年轻", "潮流", "种草", "福利", "开箱", "限量"),
275
+ dict(bg=(20, 18, 34), bg2=(38, 26, 58), surface=(46, 34, 70),
276
+ ink=(255, 252, 246), muted=(206, 196, 224), dim=(168, 158, 190),
277
+ accent=(255, 214, 64), accent2=(120, 224, 255),
278
+ good=(120, 230, 150), bad=(255, 120, 150), rule=(255, 255, 255)),
279
+ dict(vignette=0.24, vignette_tint=(12, 10, 22), grain=4.0, grain_freq=0.35,
280
+ texture="grid", texture_alpha=0.060),
281
+ dict(family="sans", scale={"display": (104, "bold"), "heading": (68, "bold"),
282
+ "sub": (39, "reg"), "body": (35, "reg"),
283
+ "kicker": (30, "bold"), "chip": (27, "reg"),
284
+ "brand": (23, "reg")}, line_gap=1.26, tracking=1.0),
285
+ dict(layout="center-stack",
286
+ card={"radius": 22, "fill_a": 30, "outline_a": 56, "pad": 32, "h": 100, "gap": 122}),
287
+ dict(ease="outBack", entrance_dx=0, entrance_dy=34, push=0.10, item_stagger=0.26, transition="black"),
288
+ dict(style="chip-left"),
289
+ dict(luma_max=0.030, flicker_thresh=8.0, dark=True),
290
+ )
291
+
292
+ THEMES["mono"] = _theme(
293
+ "mono", "极简黑白",
294
+ ("观点", "为什么", "本质", "真相", "极简", "反思", "边界", "定义", "取舍"),
295
+ dict(bg=(246, 246, 244), bg2=(222, 222, 220), surface=(255, 255, 255),
296
+ ink=(16, 16, 16), muted=(68, 68, 68), dim=(104, 104, 104),
297
+ accent=(16, 16, 16), accent2=(80, 80, 80),
298
+ good=(38, 92, 52), bad=(150, 36, 36), rule=(16, 16, 16)),
299
+ dict(vignette=0.06, vignette_tint=(214, 214, 212), grain=0.0, grain_freq=0.30,
300
+ texture="none", texture_alpha=0.0),
301
+ dict(family="sans", scale={"display": (104, "bold"), "heading": (66, "bold"),
302
+ "sub": (36, "light"), "body": (33, "reg"),
303
+ "kicker": (26, "bold"), "chip": (25, "reg"),
304
+ "brand": (21, "reg")}, line_gap=1.40, tracking=6.0),
305
+ dict(layout="center-stack",
306
+ card={"radius": 0, "fill_a": 0, "outline_a": 70, "pad": 28, "h": 90, "gap": 108}),
307
+ dict(ease="inOut3", entrance_dx=0, entrance_dy=12, push=0.02, item_stagger=0.55, transition="none"),
308
+ dict(style="corner-line"),
309
+ dict(luma_max=0.045, flicker_thresh=6.0, dark=False),
310
+ )
311
+
312
+ THEMES["cinema"] = _theme(
313
+ "cinema", "电影青橙",
314
+ ("故事", "记忆", "城市", "深夜", "旅行", "纪实", "情绪", "时代", "变迁", "命运"),
315
+ dict(bg=(8, 14, 16), bg2=(20, 32, 34), surface=(26, 40, 42),
316
+ ink=(238, 234, 226), muted=(164, 176, 170), dim=(128, 140, 136),
317
+ accent=(232, 126, 58), accent2=(104, 196, 190),
318
+ good=(116, 196, 140), bad=(214, 92, 84), rule=(255, 255, 255)),
319
+ dict(vignette=0.55, vignette_tint=(0, 0, 0), grain=6.0, grain_freq=0.29,
320
+ texture="scanlines", texture_alpha=0.035),
321
+ dict(family="sans", scale={"display": (94, "bold"), "heading": (58, "bold"),
322
+ "sub": (36, "light"), "body": (33, "reg"),
323
+ "kicker": (27, "bold"), "chip": (26, "reg"),
324
+ "brand": (22, "reg")}, line_gap=1.38, tracking=3.0),
325
+ dict(layout="center-stack",
326
+ card={"radius": 2, "fill_a": 14, "outline_a": 30, "pad": 30, "h": 92, "gap": 114}),
327
+ dict(ease="inOut3", entrance_dx=0, entrance_dy=16, push=0.12, item_stagger=0.46, transition="black"),
328
+ dict(style="corner-line"),
329
+ dict(luma_max=0.020, flicker_thresh=6.0, dark=True),
330
+ )
331
+
332
+ THEMES["lo-fi"] = _theme(
333
+ "lo-fi", "暖灰薄荷",
334
+ ("深夜", "学习", "效率", "专注", "写作", "整理", "笔记", "书", "日常", "房间"),
335
+ # 冷调灰绿:和 paper(暖牛皮纸)在色相上分开,不靠"亮度差不多"硬撑
336
+ dict(bg=(232, 238, 236), bg2=(210, 220, 216), surface=(244, 248, 246),
337
+ ink=(44, 52, 50), muted=(86, 100, 96), dim=(112, 126, 122),
338
+ accent=(20, 86, 84), accent2=(104, 72, 96),
339
+ good=(40, 100, 72), bad=(146, 52, 50), rule=(44, 52, 50)),
340
+ dict(vignette=0.12, vignette_tint=(206, 200, 190), grain=7.0, grain_freq=0.24,
341
+ texture="paper", texture_alpha=0.048),
342
+ dict(family="sans", scale={"display": (86, "bold"), "heading": (58, "bold"),
343
+ "sub": (35, "light"), "body": (32, "reg"),
344
+ "kicker": (26, "bold"), "chip": (25, "reg"),
345
+ "brand": (21, "reg")}, line_gap=1.36, tracking=1.0),
346
+ dict(layout="left-stack",
347
+ card={"radius": 18, "fill_a": 40, "outline_a": 26, "pad": 34, "h": 98, "gap": 120},
348
+ safe_bias=6),
349
+ dict(ease="out3", entrance_dx=0, entrance_dy=22, push=0.05, item_stagger=0.44, transition="wash"),
350
+ dict(style="chip-left"),
351
+ dict(luma_max=0.045, flicker_thresh=6.0, dark=False),
352
+ )
353
+
354
+ THEMES["epic"] = _theme(
355
+ "epic", "深黑鎏金",
356
+ ("史诗", "爆发", "冲击", "巅峰", "决战", "荣耀", "震撼", "传奇", "极限", "突破"),
357
+ dict(bg=(9, 9, 12), bg2=(24, 20, 16), surface=(32, 28, 22),
358
+ ink=(248, 242, 228), muted=(186, 176, 158), dim=(142, 134, 120),
359
+ accent=(222, 178, 84), accent2=(206, 116, 92),
360
+ good=(140, 200, 140), bad=(222, 96, 84), rule=(255, 255, 255)),
361
+ dict(vignette=0.62, vignette_tint=(0, 0, 0), grain=5.0, grain_freq=0.26,
362
+ texture="scanlines", texture_alpha=0.028),
363
+ dict(family="serif", scale={"display": (108, "bold"), "heading": (70, "bold"),
364
+ "sub": (38, "light"), "body": (34, "reg"),
365
+ "kicker": (29, "bold"), "chip": (27, "reg"),
366
+ "brand": (23, "reg")}, line_gap=1.32, tracking=4.0),
367
+ dict(layout="center-stack",
368
+ card={"radius": 6, "fill_a": 20, "outline_a": 44, "pad": 32, "h": 98, "gap": 120}),
369
+ dict(ease="out5", entrance_dx=0, entrance_dy=24, push=0.14, item_stagger=0.34, transition="black"),
370
+ dict(style="corner-line"),
371
+ dict(luma_max=0.018, flicker_thresh=6.0, dark=True),
372
+ )
373
+
374
+ # ============================================================================
375
+ # 三、版式骨架(LAYOUTS):3 种,scenes.py 按名字取几何参数
376
+ # ============================================================================
377
+ #
378
+ # 这是「每屏都长得一样」的解药:同一部片可以按章节在骨架之间轮换,但不换明暗族、不换 accent,
379
+ # 所以整体仍是一部片。几何全部相对于 W/H 与 SAFE,不写死 1920 里的魔法数。
380
+
381
+ LAYOUTS = {
382
+ "left-stack": {
383
+ "label": "左标题 / 右卡片",
384
+ "title_x": 150, "title_y": 300, "title_w": 820,
385
+ "kicker_y": 250, "sub_y": 420, "sub_w": 820,
386
+ "panel_x": 1030, "panel_y": 240, "panel_w": 740, "panel_h": 96, "panel_gap": 118,
387
+ "center": False,
388
+ },
389
+ "center-stack": {
390
+ "label": "居中大字",
391
+ "title_x": None, "title_y": 330, "title_w": 1560,
392
+ "kicker_y": 268, "sub_y": 0, "sub_w": 1400,
393
+ "panel_x": None, "panel_y": 650, "panel_w": 1320, "panel_h": 92, "panel_gap": 108,
394
+ "center": True,
395
+ },
396
+ "split-right": {
397
+ "label": "右标题 / 左卡片",
398
+ "title_x": None, "title_y": 300, "title_w": 820,
399
+ "kicker_y": 250, "sub_y": 420, "sub_w": 820,
400
+ "panel_x": 150, "panel_y": 240, "panel_w": 740, "panel_h": 96, "panel_gap": 118,
401
+ "center": False,
402
+ },
403
+ }
404
+
405
+ LAYOUT_IDS = tuple(LAYOUTS.keys())
406
+
407
+
408
+ def layout(name):
409
+ """取版式几何;未知名字报错并列出可用值(不静默回落)。"""
410
+ if name not in LAYOUTS:
411
+ raise KeyError("未知版式 %r;可用:%s" % (name, " / ".join(LAYOUT_IDS)))
412
+ return LAYOUTS[name]
413
+
414
+
415
+ # ============================================================================
416
+ # 四、风格包(STYLES):一个 id = 主题 + 配乐 + 旁白 + 节奏
417
+ # ============================================================================
418
+ #
419
+ # music 字段只放「策展层」(BPM 梯度 / 调池 / 情绪 / 音色组偏好),
420
+ # 具体的和弦进行、鼓型、乐器合成在 scorekit 里(合成层)。两边用同一个 style id 对齐。
421
+
422
+ STYLE_IDS = ("tech-deep", "magazine", "paper", "vivid", "mono", "cinema", "lo-fi", "epic")
423
+ DEFAULT_STYLE = "tech-deep"
424
+ LEGACY_STYLE = "legacy" # 显式老行为:v0.4 的画风 + 配乐 + 单一旁白
425
+
426
+ MINOR_KEYS = ("Dm", "Am", "Em", "Cm")
427
+ MAJOR_KEYS = ("C", "G", "D", "F", "A", "Bb")
428
+
429
+ STYLES = {
430
+ "tech-deep": {
431
+ "name": "科技深蓝",
432
+ "tags": ("AI", "工程", "数据", "科普"),
433
+ "theme": "tech-deep",
434
+ "layouts": ("left-stack", "split-right"),
435
+ "music": {"keys": ("Dm", "Am", "Em", "Cm"), "scale": "minor",
436
+ "bpm_ladder": (100, 108, 116, 124), "mood": "steady"},
437
+ "voice": {"body": "narrator_male", "hook": "young_male",
438
+ "quote": "narrator_female", "outro": "narrator_male"},
439
+ "pacing": {"lead": 0.58, "tail": 0.88, "min_dur": 3.0, "hold": 0.0},
440
+ },
441
+ "magazine": {
442
+ "name": "杂志暖白",
443
+ "tags": ("报告", "复盘", "研究", "出版物"),
444
+ "theme": "magazine",
445
+ "layouts": ("left-stack", "split-right"),
446
+ "music": {"keys": ("C", "G", "D", "F"), "scale": "major",
447
+ "bpm_ladder": (88, 92, 96, 100), "mood": "reflective"},
448
+ "voice": {"body": "narrator_female", "hook": "narrator_female",
449
+ "quote": "authority_male", "outro": "narrator_female"},
450
+ "pacing": {"lead": 0.64, "tail": 0.94, "min_dur": 3.2, "hold": 0.2},
451
+ },
452
+ "paper": {
453
+ "name": "手绘纸感",
454
+ "tags": ("手工", "自然", "历史", "人文"),
455
+ "theme": "paper",
456
+ "layouts": ("left-stack", "center-stack"),
457
+ "music": {"keys": ("G", "D", "C"), "scale": "major",
458
+ "bpm_ladder": (84, 88, 92), "mood": "hopeful"},
459
+ "voice": {"body": "warm_female", "hook": "narrator_female",
460
+ "quote": "narrator_male", "outro": "warm_female"},
461
+ "pacing": {"lead": 0.68, "tail": 1.00, "min_dur": 3.4, "hold": 0.3},
462
+ },
463
+ "vivid": {
464
+ "name": "活力撞色",
465
+ "tags": ("活动", "营销", "年轻", "上新"),
466
+ "theme": "vivid",
467
+ "layouts": ("center-stack", "left-stack"),
468
+ "music": {"keys": ("D", "G", "A"), "scale": "major",
469
+ "bpm_ladder": (120, 126, 132), "mood": "uplifting"},
470
+ "voice": {"body": "narrator_female", "hook": "bright_female",
471
+ "quote": "young_male", "outro": "bright_female"},
472
+ "pacing": {"lead": 0.46, "tail": 0.76, "min_dur": 2.8, "hold": 0.0},
473
+ },
474
+ "mono": {
475
+ "name": "极简黑白",
476
+ "tags": ("观点", "演讲", "思辨", "极简"),
477
+ "theme": "mono",
478
+ "layouts": ("center-stack",),
479
+ "music": {"keys": ("C", "F", "Am"), "scale": "major",
480
+ "bpm_ladder": (60, 64, 68), "mood": "calm"},
481
+ "voice": {"body": "narrator_male", "hook": "authority_male",
482
+ "quote": "narrator_female", "outro": "authority_male"},
483
+ "pacing": {"lead": 0.86, "tail": 1.24, "min_dur": 4.0, "hold": 0.6},
484
+ },
485
+ "cinema": {
486
+ "name": "电影青橙",
487
+ "tags": ("纪录", "叙事", "散文", "城市"),
488
+ "theme": "cinema",
489
+ "layouts": ("center-stack", "left-stack"),
490
+ "music": {"keys": ("Dm", "Am"), "scale": "minor",
491
+ "bpm_ladder": (76, 80, 84), "mood": "reflective"},
492
+ "voice": {"body": "narrator_male", "hook": "authority_male",
493
+ "quote": "narrator_female", "outro": "narrator_male"},
494
+ "pacing": {"lead": 0.76, "tail": 1.10, "min_dur": 3.6, "hold": 0.4},
495
+ },
496
+ "lo-fi": {
497
+ "name": "暖灰薄荷",
498
+ "tags": ("日常", "学习", "专注", "深夜"),
499
+ "theme": "lo-fi",
500
+ "layouts": ("left-stack", "split-right"),
501
+ "music": {"keys": ("C", "F", "G"), "scale": "major",
502
+ "bpm_ladder": (72, 78, 84), "mood": "calm"},
503
+ "voice": {"body": "narrator_female", "hook": "warm_female",
504
+ "quote": "narrator_male", "outro": "narrator_female"},
505
+ "pacing": {"lead": 0.72, "tail": 1.06, "min_dur": 3.4, "hold": 0.4},
506
+ },
507
+ "epic": {
508
+ "name": "深黑鎏金",
509
+ "tags": ("史诗", "宣传", "高潮", "荣耀"),
510
+ "theme": "epic",
511
+ "layouts": ("center-stack",),
512
+ "music": {"keys": ("Cm", "Dm"), "scale": "minor",
513
+ "bpm_ladder": (88, 92, 96), "mood": "driving"},
514
+ "voice": {"body": "authority_male", "hook": "narrator_male",
515
+ "quote": "narrator_female", "outro": "authority_male"},
516
+ "pacing": {"lead": 0.62, "tail": 0.96, "min_dur": 3.2, "hold": 0.2},
517
+ },
518
+ }
519
+
520
+ # 老行为(不写 style 时):画风 = v0.4 的深蓝橙,配乐 = v0.4 的 Dm/120/seed7,旁白 = script.voice
521
+ STYLES[LEGACY_STYLE] = {
522
+ "name": "v0.4 兼容",
523
+ "tags": ("兼容", "老项目"),
524
+ "theme": "tech-deep",
525
+ "layouts": ("left-stack",),
526
+ "music": {"keys": ("Dm", "Am", "Cm", "Em"), "scale": "minor",
527
+ "bpm_ladder": (120,), "mood": "steady", "legacy": True},
528
+ "voice": {"body": None, "hook": None, "quote": None, "outro": None},
529
+ # 必须是 render.py v0.4 的函数默认值(lead=0.55 / tail=0.85 / min_dur=3.0),
530
+ # 否则"没写 lead 字段的老剧本"会悄悄换掉节奏 —— legacy 的含义就是一个字节都不变。
531
+ "pacing": {"lead": 0.55, "tail": 0.85, "min_dur": 3.0, "hold": 0.0},
532
+ }
533
+
534
+ ALL_STYLE_IDS = (LEGACY_STYLE,) + STYLE_IDS
535
+
536
+
537
+ def style_ids(include_legacy=True):
538
+ return list(ALL_STYLE_IDS) if include_legacy else list(STYLE_IDS)
539
+
540
+
541
+ def get(style_id):
542
+ """取风格包;未知 id 报错并列出可用值(不静默回落,v0.4 的教训)。"""
543
+ sid = str(style_id or "").strip()
544
+ if sid not in STYLES:
545
+ raise KeyError("未知风格 %r;可用:%s" % (style_id, " / ".join(ALL_STYLE_IDS)))
546
+ return STYLES[sid]
547
+
548
+
549
+ def theme_of(style_id):
550
+ """取风格对应的主题字典(元组颜色原样返回,给 PIL 直接下标)。"""
551
+ return THEMES[get(style_id)["theme"]]
552
+
553
+
554
+ def is_legacy(style_id):
555
+ return str(style_id or LEGACY_STYLE) == LEGACY_STYLE
556
+
557
+
558
+ # ============================================================================
559
+ # 五、内容指纹与自动选风格
560
+ # ============================================================================
561
+
562
+ FP_VERSION = "style-fp-v1"
563
+
564
+
565
+ def _digest(*parts):
566
+ """sha1 派生的稳定 32 位无符号整数(禁 hash():str hash 每次进程带随机盐)。"""
567
+ h = hashlib.sha1(("|".join(str(p) for p in parts)).encode("utf-8")).hexdigest()
568
+ return int(h[:8], 16)
569
+
570
+
571
+ def _text_of(script):
572
+ """把剧本里所有可读文字摊平(标题权重最高)。"""
573
+ title = str(script.get("title") or "")
574
+ chunks = [title, title] # 标题 x2
575
+ for ln in script.get("lines") or []:
576
+ chunks.append(str(ln.get("chapter") or ln.get("kicker") or ""))
577
+ chunks.append(str(ln.get("text") or ""))
578
+ chunks.append(str(ln.get("sub") or ""))
579
+ for it in ln.get("items") or []:
580
+ if isinstance(it, dict):
581
+ chunks.append(str(it.get("text") or ""))
582
+ else:
583
+ chunks.append(str(it))
584
+ return "".join(chunks)
585
+
586
+
587
+ def fingerprint(script):
588
+ """三级内容指纹。同剧本 -> 同指纹;换一部片 -> 三个都变。"""
589
+ title = str(script.get("title") or "")
590
+ lines = script.get("lines") or []
591
+ texts = [str(ln.get("text") or "") for ln in lines]
592
+ kinds = [str(ln.get("kind") or "body") for ln in lines]
593
+ dur_bucket = int(len("".join(texts)) // 120) # 体量桶:改几个字不动它
594
+ return {
595
+ "version": FP_VERSION,
596
+ "full": "%08x" % _digest(FP_VERSION, "full", title, *texts),
597
+ "tempo": "%08x" % _digest(FP_VERSION, "tempo", title, *kinds, dur_bucket),
598
+ "style": "%08x" % _digest(FP_VERSION, "style", title),
599
+ }
600
+
601
+
602
+ def _kind_profile(script):
603
+ """kind 分布画像:用于给风格打分(题材相近的片子 kind 结构也像)。"""
604
+ kinds = [str(ln.get("kind") or "body") for ln in script.get("lines") or []]
605
+ n = max(1, len(kinds))
606
+ return {
607
+ "hook": kinds.count("hook") / n,
608
+ "body": kinds.count("body") / n,
609
+ "outro": kinds.count("outro") / n,
610
+ "items": sum(1 for ln in script.get("lines") or [] if ln.get("items")) / n,
611
+ "avg_len": sum(len(str(ln.get("text") or "")) for ln in script.get("lines") or []) / n,
612
+ }
613
+
614
+
615
+ def score_styles(script):
616
+ """给所有风格打分,返回 [(style_id, score, hits)],按分数降序(同分按 STYLE_IDS 顺序)。"""
617
+ text = _text_of(script)
618
+ prof = _kind_profile(script)
619
+ fp = fingerprint(script)
620
+ rows = []
621
+ for sid in STYLE_IDS:
622
+ th = THEMES[STYLES[sid]["theme"]]
623
+ hits = []
624
+ score = 0.0
625
+ for kw in th["keywords"]:
626
+ c = text.count(str(kw))
627
+ if c:
628
+ score += 3.0 * c
629
+ hits.append(str(kw))
630
+ if prof["items"] >= 0.5:
631
+ score += 1.5 if sid in ("tech-deep", "magazine", "vivid") else 0.0
632
+ if prof["avg_len"] >= 28:
633
+ score += 1.0 if sid in ("cinema", "paper", "mono") else 0.0
634
+ if prof["hook"] > 0 and prof["outro"] > 0:
635
+ score += 0.5 if sid in ("tech-deep", "vivid", "epic") else 0.0
636
+ score += (int(fp["style"][:4], 16) % 7) * 0.0001 # 指纹破平
637
+ rows.append((sid, round(score, 4), hits))
638
+ rows.sort(key=lambda r: (-r[1], STYLE_IDS.index(r[0])))
639
+ return rows
640
+
641
+
642
+ def pick_style(script):
643
+ """内容驱动选风格。返回 (style_id, reason[list[str], 给用户看的理由])。"""
644
+ rows = score_styles(script)
645
+ best_id, best_score, best_hits = rows[0]
646
+ fp = fingerprint(script)
647
+ if best_score >= 1.0:
648
+ reason = ["关键词命中:%s" % "、".join(dict.fromkeys(best_hits))[:80]]
649
+ else:
650
+ pool = list(STYLE_IDS)
651
+ best_id = pool[int(fp["tempo"], 16) % len(pool)]
652
+ reason = ["无关键词命中,按内容指纹落桶(%s)" % fp["tempo"][:8]]
653
+ return best_id, reason
654
+
655
+
656
+ # ============================================================================
657
+ # 六、解析:把 script 里所有显式覆盖 + 自动结果合成一份「最终计划」
658
+ # ============================================================================
659
+
660
+
661
+ def _pick_from_pool(pool, fp_hex, salt=""):
662
+ """从池里按指纹稳定取一个。
663
+
664
+ salt 用来把"风格 id"混进选择:不同风格即便指纹相同,也会落到池里不同位置。
665
+ 没有 salt 时会出现 paper 与 lo-fi 都选到 84BPM / G 调这种"风格不同但基础参数撞车",
666
+ 听感上就打了折扣 —— 这正是 v0.4 同质化的另一种形态。
667
+ """
668
+ seq = [p for p in pool if p]
669
+ if not seq:
670
+ return None
671
+ v = int(fp_hex, 16)
672
+ if salt:
673
+ v += _digest("pick", salt)
674
+ return seq[v % len(seq)]
675
+
676
+
677
+ def _as_bool(v, default=False):
678
+ if v is None:
679
+ return default
680
+ if isinstance(v, str):
681
+ return v.strip().lower() not in ("0", "false", "no", "off", "")
682
+ return bool(v)
683
+
684
+
685
+ def resolve_script_style(script):
686
+ """解析整份剧本的最终风格计划(纯函数、确定性、可直接 json.dump)。
687
+
688
+ 优先级(从高到低):
689
+ 1. script["style"] 全片锁定(不是 "auto" 就生效)
690
+ 2. script["visual"]["theme"] 等价于锁 style(主题与风格 1:1)
691
+ 3. 自动:关键词 + 指纹
692
+ 配乐 BPM / 调性 / 情绪、旁白声线映射、节奏都能被 film.json 单独覆盖。
693
+ """
694
+ fp = fingerprint(script)
695
+ vis = script.get("visual") if isinstance(script.get("visual"), dict) else {}
696
+ mus = script.get("music") if isinstance(script.get("music"), dict) else {}
697
+ voce = script.get("voice") if isinstance(script.get("voice"), dict) else {}
698
+
699
+ has_style_field = "style" in script
700
+ raw = script.get("style")
701
+ raw_s = str(raw).strip().lower() if raw is not None else ""
702
+ if not has_style_field and not vis.get("theme") and not mus.get("style"):
703
+ # 老项目(film.json 里根本没有 style 字段)-> legacy:配乐逐样本一致,v0.4 原路径
704
+ sid, source, reason = LEGACY_STYLE, "legacy", ["film.json 没有 style 字段,走 v0.4 兼容路径"]
705
+ elif raw_s in ("", "auto", "default"):
706
+ if vis.get("theme"):
707
+ sid = str(vis["theme"])
708
+ source = "visual.theme"
709
+ reason = ["film.json visual.theme 指定"]
710
+ else:
711
+ sid, reason = pick_style(script)
712
+ source = "auto"
713
+ else:
714
+ sid = str(raw).strip()
715
+ source = "explicit"
716
+ reason = ["film.json style 指定"]
717
+ get(sid) # 未知 id 在这里就炸,不要等到渲染 300 帧后
718
+ pack = STYLES[sid]
719
+
720
+ # ---- 配乐:风格 / BPM / 调性 / 情绪 ----
721
+ music_style = str(mus.get("style") or "").strip().lower()
722
+ if music_style in ("", "auto", "default"):
723
+ music_style = sid if not is_legacy(sid) else LEGACY_STYLE
724
+ mpack = pack["music"]
725
+ ladder = tuple(mpack["bpm_ladder"])
726
+ bpm_raw = mus.get("bpm", script.get("bpm"))
727
+ if isinstance(bpm_raw, str) and bpm_raw.strip().lower() == "auto":
728
+ bpm_raw = "auto"
729
+ if bpm_raw in (None, "", "auto"):
730
+ if is_legacy(sid):
731
+ bpm, bpm_source = 120.0, "legacy" # v0.4 的缺省值,必须是 120
732
+ else:
733
+ bpm = float(_pick_from_pool(ladder, fp["tempo"], salt="bpm:" + sid))
734
+ bpm_source = "style_ladder"
735
+ else:
736
+ bpm, bpm_source = float(bpm_raw), "explicit"
737
+
738
+ key_raw = mus.get("key")
739
+ if key_raw in (None, "", "auto"):
740
+ if is_legacy(sid):
741
+ key, key_source = "Dm", "legacy" # v0.4 的缺省值,必须是 Dm
742
+ else:
743
+ key = _pick_from_pool(mpack["keys"], fp["tempo"], salt="key:" + sid) or "Dm"
744
+ key_source = "style_pool"
745
+ else:
746
+ key, key_source = str(key_raw), "explicit"
747
+
748
+ mood = str(mus.get("mood") or "").strip().lower()
749
+ if mood in ("", "auto", "default"):
750
+ mood, mood_source = str(mpack.get("mood") or "steady"), "style"
751
+ else:
752
+ mood_source = "explicit"
753
+
754
+ seed_raw = mus.get("seed", script.get("seed", 7))
755
+ try:
756
+ seed = abs(int(seed_raw if seed_raw is not None else 7))
757
+ except (TypeError, ValueError):
758
+ seed = 7
759
+ seed_from_content = _as_bool(mus.get("seed_from_content"), default=(not is_legacy(sid)))
760
+ if seed_from_content:
761
+ seed_effective = (seed * 1000003 + int(fp["full"], 16)) % (2 ** 31 - 1)
762
+ else:
763
+ seed_effective = seed
764
+
765
+ # ---- 旁白:声线映射 + 策略 ----
766
+ style_voice = pack["voice"]
767
+ voice_map = {}
768
+ if not is_legacy(sid):
769
+ for kind in ("hook", "body", "quote", "outro"):
770
+ dv = style_voice.get(kind)
771
+ if dv:
772
+ voice_map[kind] = dv
773
+ explicit_map = voce.get("map") if isinstance(voce.get("map"), dict) else {}
774
+ voice_map.update({str(k): str(v) for k, v in explicit_map.items() if v})
775
+ policy = {
776
+ "auto": _as_bool(voce.get("auto"), default=(not is_legacy(sid))),
777
+ "jitter": float(voce.get("jitter", 0.0 if is_legacy(sid) else 0.35)),
778
+ "max_distinct": int(voce.get("max_distinct", 2 if is_legacy(sid) else 3)),
779
+ "rate_range": (-15, 12),
780
+ "pitch_range": (-8, 8),
781
+ "cps_band": (3.6, 5.6),
782
+ }
783
+ if isinstance(voce.get("rate_range"), (list, tuple)) and len(voce["rate_range"]) == 2:
784
+ policy["rate_range"] = (int(voce["rate_range"][0]), int(voce["rate_range"][1]))
785
+ if isinstance(voce.get("cps_band"), (list, tuple)) and len(voce["cps_band"]) == 2:
786
+ policy["cps_band"] = (float(voce["cps_band"][0]), float(voce["cps_band"][1]))
787
+
788
+ # ---- 节奏 ----
789
+ sp = pack["pacing"]
790
+ pacing = {
791
+ "lead": float(script.get("lead", sp["lead"])),
792
+ "tail": float(script.get("tail", sp["tail"])),
793
+ "min_dur": float(script.get("min_dur", sp["min_dur"])),
794
+ "hold": float(script.get("hold", sp["hold"])),
795
+ "item_stagger": float(THEMES[pack["theme"]]["item_stagger"]),
796
+ }
797
+ explicit_pace = script.get("pacing") if isinstance(script.get("pacing"), dict) else {}
798
+ for k in ("lead", "tail", "min_dur", "hold", "item_stagger"):
799
+ if explicit_pace.get(k) is not None:
800
+ pacing[k] = float(explicit_pace[k])
801
+
802
+ # ---- 版式轮换:按 chapter 轮换骨架(同一章节内不变,避免每句都跳) ----
803
+ layouts = list(pack["layouts"]) or ["left-stack"]
804
+ chapters = []
805
+ for ln in script.get("lines") or []:
806
+ ch = str(ln.get("chapter") or "")
807
+ if ch not in chapters:
808
+ chapters.append(ch)
809
+ explicit_layout = str(vis.get("layout") or "").strip()
810
+ if explicit_layout:
811
+ layout(explicit_layout)
812
+ chap_layout = {ch: explicit_layout for ch in chapters}
813
+ else:
814
+ chap_layout = {ch: layouts[i % len(layouts)] for i, ch in enumerate(chapters)}
815
+
816
+ # theme_overrides 走带校验的合并(颜色支持 "#rrggbb",非法值在这里就报错,
817
+ # 不要等到 scenes.py 渲到第 300 帧才 int("#") 崩——见 apply_theme_overrides)
818
+ theme = apply_theme_overrides(THEMES[pack["theme"]], vis.get("theme_overrides"))
819
+
820
+ return {
821
+ "version": 1,
822
+ "generator": "stylekit 0.5.0",
823
+ "style": sid,
824
+ "style_name": pack["name"],
825
+ "style_source": source,
826
+ "style_reason": list(reason),
827
+ "legacy": bool(is_legacy(sid)),
828
+ "fingerprint": fp,
829
+ "theme_id": pack["theme"],
830
+ "theme_label": THEMES[pack["theme"]]["label"],
831
+ # 主题字典随计划一起返回:scenes.py 的 _scene_theme 优先用它(含 theme_overrides),
832
+ # 也方便 render.py 直接传给 draw_subtitle/write_ass,不必再按 id 查一次。
833
+ "theme": theme,
834
+ "layout_default": layouts[0],
835
+ "chapter_layout": chap_layout,
836
+ "music": {
837
+ "style": music_style,
838
+ "bpm": bpm, "bpm_source": bpm_source,
839
+ "key": key, "key_source": key_source,
840
+ "mood": mood, "mood_source": mood_source,
841
+ "seed": int(seed), "seed_effective": int(seed_effective),
842
+ "seed_from_content": bool(seed_from_content),
843
+ },
844
+ "voice": {
845
+ "map": voice_map,
846
+ "default": voce.get("default"),
847
+ "policy": policy,
848
+ },
849
+ "pacing": pacing,
850
+ }
851
+
852
+
853
+ # ============================================================================
854
+ # 七、门禁:主题体检(对比度 / 明暗 / 结构)
855
+ # ============================================================================
856
+ #
857
+ # 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(ink 16.3 / muted 7.8 / accent 7.0)。
858
+
859
+ LINT_LIMITS = {
860
+ "ink": 7.0, # 正文级(WCAG AAA 正文线)
861
+ "muted": 4.5, # 次级正文(WCAG AA)
862
+ "dim": 3.0, # 装饰级(HUD 小字、页脚)
863
+ "accent": 4.5, # 强调色常作文字
864
+ "accent2": 4.5, # 第二色相也常作文字
865
+ "good": 3.0, # 只承载图形(圆点靠位置+颜色双编码)
866
+ "bad": 3.0,
867
+ "lum_gap": 0.002, # bg 与 bg2 的亮度差(防纯色底)
868
+ }
869
+
870
+
871
+ def theme_lint(th):
872
+ """主题体检,返回问题列表(空 = 过)。坏主题在 load 阶段就拦,别等渲完 300 帧。"""
873
+ if not isinstance(th, dict):
874
+ return ["主题不是 dict:%r" % (th,)]
875
+ bad = []
876
+ bgs = midpoint(th["bg"], th["bg2"])
877
+ for role, need in (("ink", LINT_LIMITS["ink"]), ("muted", LINT_LIMITS["muted"]),
878
+ ("dim", LINT_LIMITS["dim"]), ("accent", LINT_LIMITS["accent"]),
879
+ ("accent2", LINT_LIMITS["accent2"])):
880
+ got = contrast(th[role], bgs)
881
+ if got < need:
882
+ bad.append("%s 对背景对比度 %.2f < %.1f" % (role, got, need))
883
+ for role in ("good", "bad"):
884
+ got = contrast(th[role], th["surface"])
885
+ if got < LINT_LIMITS[role]:
886
+ bad.append("%s 对卡片底色对比度 %.2f < %.1f" % (role, got, LINT_LIMITS[role]))
887
+ gap = abs(luminance(th["bg"]) - luminance(th["bg2"]))
888
+ if gap < LINT_LIMITS["lum_gap"]:
889
+ bad.append("bg/bg2 亮度差 %.4f 太小(会像纯色底)" % gap)
890
+ if luminance(th["bg"]) < 0.02 and float(th.get("qa", {}).get("luma_max", 0.045)) > 0.03:
891
+ bad.append("暗色主题 bg 亮度 %.3f 贴空窗阈值,请把 qa.luma_max 降到 0.03 以下"
892
+ % luminance(th["bg"]))
893
+ if th.get("layout") not in LAYOUTS:
894
+ bad.append("layout=%r 不在 LAYOUTS 里" % th.get("layout"))
895
+ if th.get("hud") not in ("chip-left", "corner-line", "none"):
896
+ bad.append("hud=%r 不认识" % th.get("hud"))
897
+ if not isinstance(th.get("item_stagger"), (int, float)) or th["item_stagger"] <= 0:
898
+ bad.append("item_stagger 必须是正数")
899
+ return bad
900
+
901
+
902
+ def lint_all():
903
+ """全部内置主题体检,返回 {style_id: [问题...]}(只有有问题的才在里面)。"""
904
+ out = {}
905
+ for sid in STYLE_IDS:
906
+ bad = theme_lint(THEMES[STYLES[sid]["theme"]])
907
+ if bad:
908
+ out[sid] = bad
909
+ return out
910
+
911
+
912
+ # ============================================================================
913
+ # 八、片内一致性:明暗族判定(段界不许跨族,否则必触发闪烁门禁)
914
+ # ============================================================================
915
+
916
+
917
+ def is_dark(th):
918
+ return luminance(th["bg"]) < 0.35
919
+
920
+
921
+ def family_compatible(a, b):
922
+ """两主题能否出现在同一部片里(同明暗族)。跨族=两部片,不是两段。"""
923
+ return is_dark(a) == is_dark(b)
924
+
925
+
926
+ def blend_theme(a, b, p):
927
+ """按 p in [0,1] 线性插值两套主题(同族时用于段界缓变,避免整片换底触发闪烁门禁)。"""
928
+ if not family_compatible(a, b):
929
+ raise ValueError("不能混合明暗族不同的主题:%s / %s" % (a.get("id"), b.get("id")))
930
+ q = max(0.0, min(1.0, float(p)))
931
+ out = dict(a)
932
+ for k, va in a.items():
933
+ vb = b.get(k, va)
934
+ if isinstance(va, tuple) and len(va) == 3 and isinstance(vb, tuple) and len(vb) == 3:
935
+ out[k] = tuple(int(round(va[i] * (1 - q) + vb[i] * q)) for i in range(3))
936
+ elif (isinstance(va, (int, float)) and not isinstance(va, bool)
937
+ and isinstance(vb, (int, float))):
938
+ out[k] = type(va)(round(va * (1 - q) + vb * q, 4))
939
+ return out
940
+
941
+
942
+ # ============================================================================
943
+ # 九、CLI:看风格、看主题、跑门禁
944
+ # ============================================================================
945
+
946
+
947
+ def _describe(sid):
948
+ pack = STYLES[sid]
949
+ th = THEMES[pack["theme"]]
950
+ bgs = midpoint(th["bg"], th["bg2"])
951
+ lines = [
952
+ "[%s] %s (theme=%s / layouts=%s)" % (sid, pack["name"], pack["theme"],
953
+ ",".join(pack["layouts"])),
954
+ " tags : %s" % "、".join(pack["tags"]),
955
+ " keywords : %s" % "、".join(th["keywords"]),
956
+ " music : %s 调池=%s BPM 梯度=%s mood=%s"
957
+ % (pack["music"]["scale"], ",".join(pack["music"]["keys"]),
958
+ ",".join(str(b) for b in pack["music"]["bpm_ladder"]), pack["music"]["mood"]),
959
+ " voice : %s" % ", ".join("%s=%s" % (k, v) for k, v in pack["voice"].items() if v),
960
+ " pacing : lead=%.2f tail=%.2f min_dur=%.1f"
961
+ % (pack["pacing"]["lead"], pack["pacing"]["tail"], pack["pacing"]["min_dur"]),
962
+ " palette : bg=%s bg2=%s ink=%s accent=%s"
963
+ % (th["bg"], th["bg2"], th["ink"], th["accent"]),
964
+ " contrast : ink %.2f / muted %.2f / dim %.2f / accent %.2f"
965
+ % (contrast(th["ink"], bgs), contrast(th["muted"], bgs),
966
+ contrast(th["dim"], bgs), contrast(th["accent"], bgs)),
967
+ " font : family=%s display=%spx hud=%s"
968
+ % (th["family"], th["scale"]["display"][0], th["hud"]),
969
+ ]
970
+ bad = theme_lint(th)
971
+ lines.append(" lint : %s" % ("PASS" if not bad else "FAIL -> " + ";".join(bad)))
972
+ return "\n".join(lines)
973
+
974
+
975
+ def main(argv=None):
976
+ ap = argparse.ArgumentParser(description="风格包:列出 / 查看 / 体检")
977
+ ap.add_argument("--list", action="store_true", help="列出所有风格")
978
+ ap.add_argument("--show", metavar="ID", help="查看某个风格的完整字段")
979
+ ap.add_argument("--lint", action="store_true", help="全部内置主题走对比度门禁")
980
+ ap.add_argument("--json", action="store_true", help="配合 --show 输出 JSON")
981
+ a = ap.parse_args(argv)
982
+ try:
983
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
984
+ except Exception: # noqa: BLE001
985
+ pass
986
+ if a.show:
987
+ # 未知 id 以前是 KeyError traceback,现在给可用清单 + exit 2(与 render.py 的口径一致)
988
+ if a.show not in STYLES:
989
+ print("[未知风格] %r;可用:%s" % (a.show, " / ".join(ALL_STYLE_IDS)), file=sys.stderr)
990
+ return 2
991
+ if a.json:
992
+ import json
993
+ print(json.dumps({"style": STYLES[a.show],
994
+ "theme": THEMES[STYLES[a.show]["theme"]]},
995
+ ensure_ascii=False, indent=1, default=str))
996
+ else:
997
+ print(_describe(a.show))
998
+ return 0
999
+ if a.lint:
1000
+ bad = lint_all()
1001
+ for sid in STYLE_IDS:
1002
+ print(("FAIL " if sid in bad else "PASS ") + sid
1003
+ + ("" if sid not in bad else " " + ";".join(bad[sid])))
1004
+ return 1 if bad else 0
1005
+ for sid in ALL_STYLE_IDS:
1006
+ print(_describe(sid))
1007
+ print("")
1008
+ return 0
1009
+
1010
+
1011
+ if __name__ == "__main__":
1012
+ sys.exit(main())