@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.4
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 +473 -0
- package/README.md +263 -327
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/package.json +9 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +31 -42
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1216 -1123
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +2 -4
- package/skills/video-studio/assets/golden_frames.json +13 -10
- package/skills/video-studio/assets/scenes.py +153 -65
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/appearance.md +123 -0
- package/skills/video-studio/references/audio-variation.md +74 -93
- package/skills/video-studio/references/audio.md +64 -66
- package/skills/video-studio/references/change-impact.md +5 -5
- package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
- package/skills/video-studio/references/frame-qa.md +48 -16
- package/skills/video-studio/references/pipeline.md +4 -4
- package/skills/video-studio/references/pitfalls.md +22 -3
- package/skills/video-studio/references/proposal.md +160 -0
- package/skills/video-studio/references/quality-gates.md +11 -11
- package/skills/video-studio/references/scene-authoring.md +2 -2
- package/skills/video-studio/references/scene-grammar.md +40 -42
- package/skills/video-studio/references/sfx-design.md +8 -6
- package/skills/video-studio/references/web-engine.md +8 -8
- package/skills/video-studio/references/word-timeline.md +9 -9
- package/skills/video-studio/requirements.txt +9 -13
- package/skills/video-studio/scripts/designkit.py +2115 -0
- package/skills/video-studio/scripts/filmcheck.py +22 -48
- package/skills/video-studio/scripts/filmkit.py +2980 -2904
- package/skills/video-studio/scripts/gates.py +4 -4
- package/skills/video-studio/scripts/grammar.py +73 -13
- package/skills/video-studio/scripts/musicgen.py +2042 -0
- package/skills/video-studio/scripts/preflight.py +14 -7
- package/skills/video-studio/scripts/previewkit.py +848 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +73 -39
- package/skills/video-studio/scripts/qaasr.py +26 -7
- package/skills/video-studio/scripts/qavmaf.py +89 -23
- package/skills/video-studio/scripts/render.py +545 -231
- package/skills/video-studio/scripts/scorekit.py +55 -1755
- package/skills/video-studio/scripts/selfcheck.py +815 -215
- package/skills/video-studio/scripts/sfxkit.py +27 -26
- package/skills/video-studio/scripts/stagewatch.py +24 -8
- package/skills/video-studio/scripts/stylekit.py +207 -888
- package/skills/video-studio/scripts/voicekit.py +508 -387
- package/skills/video-studio/scripts/webengine.py +71 -72
- package/tools/audio_similarity.py +64 -37
- package/tools/ci_check.py +20 -14
- package/tools/estimate.py +34 -29
- package/tools/golden_frames.py +72 -28
- package/tools/silhouette_qa.py +520 -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
- package/skills/video-studio/references/style-packs.md +0 -283
|
@@ -1,42 +1,34 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""stylekit.py —
|
|
2
|
+
"""stylekit.py — 视觉/音频原语库:颜色数学、版式几何、主题体检、视觉语言体检。
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
本模块是**纯数据 + 纯函数**的技术底座,不提供任何外观成品。
|
|
5
|
+
外观从哪来只有一条路:`work/design.json`——由 `propose.py` 按 brief 现场采样、
|
|
6
|
+
用户 `--confirm` 之后冻结,再由 `designkit.design_to_plan()` 转成同形的计划字典。
|
|
6
7
|
|
|
7
8
|
设计原则
|
|
8
9
|
--------
|
|
9
|
-
1.
|
|
10
|
-
|
|
11
|
-
2.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
4. 向后兼容:不写 style 字段 = legacy,此时 render/scorekit/scenes 走 v0.4 原路径,
|
|
17
|
-
配乐逐样本一致(selfcheck 有断言)。
|
|
18
|
-
|
|
19
|
-
三级指纹(专门为了「改个错别字不要换整首曲子」)
|
|
20
|
-
---------------------------------------------
|
|
21
|
-
full 标题 + 所有台词 -> 决定音色细节 / 噪声纹理(seed 派生)
|
|
22
|
-
tempo 标题 + kind 序列 + 体量桶 -> 决定 BPM 与调性
|
|
23
|
-
style 关键词评分 + 指纹破平 -> 决定风格 id
|
|
10
|
+
1. 只依赖标准库,不 import filmkit / scorekit / scenes(它们反过来 import 本模块,
|
|
11
|
+
成环就炸)。因此可以单独测、结果可复现。
|
|
12
|
+
2. 阈值只有一份:对比度门槛(`LINT_LIMITS`)、视觉语言边界都在这里,
|
|
13
|
+
`designkit` 直接复用,不抄第二份。
|
|
14
|
+
3. 确定性:只用 sha1 派生,禁 random / time / hash()(str hash 每次进程带随机盐)。
|
|
15
|
+
4. 模板不存在:`STYLES` / `THEMES` / `VISUAL_LANGUAGES` 恒为空表,
|
|
16
|
+
写任何 style id 都会被明确拒绝并给出迁移指引(见 `resolve_script_style`)。
|
|
24
17
|
|
|
25
18
|
用法
|
|
26
19
|
----
|
|
27
20
|
import stylekit as ST
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
ST.
|
|
21
|
+
ST.theme_lint(theme) # 对比度/明暗/结构体检,返回问题列表(空 = 过)
|
|
22
|
+
ST.visual_lint_language(lang) # 视觉语言包的数值边界体检
|
|
23
|
+
ST.LAYOUTS["left-stack"] # 版式几何(相对 W/H 与 SAFE,不写死魔法数)
|
|
24
|
+
ST.is_dark(theme) # 明暗族判定(同片不许跨族)
|
|
31
25
|
|
|
32
|
-
python stylekit.py
|
|
33
|
-
python stylekit.py --
|
|
34
|
-
python stylekit.py --lint # 8 套主题全部走对比度门禁
|
|
26
|
+
python stylekit.py # 打印本模块的职责说明
|
|
27
|
+
python stylekit.py --list # 已移除:没有可选外观清单,exit 2
|
|
35
28
|
"""
|
|
36
29
|
from __future__ import annotations
|
|
37
30
|
|
|
38
31
|
import argparse
|
|
39
|
-
import hashlib
|
|
40
32
|
import re
|
|
41
33
|
import sys
|
|
42
34
|
|
|
@@ -45,6 +37,50 @@ import sys
|
|
|
45
37
|
# ============================================================================
|
|
46
38
|
|
|
47
39
|
|
|
40
|
+
def hsv_to_rgb(h, s, v):
|
|
41
|
+
"""HSV(0..360, 0..1, 0..1) -> (r,g,b) 整数 0..255。
|
|
42
|
+
|
|
43
|
+
技术原语:让"配色"可以是一个**连续空间**而不是一份命名清单。
|
|
44
|
+
有了它,第 9 种外观 = 把色相转到 200°,不需要有人先写一个模板文件。
|
|
45
|
+
"""
|
|
46
|
+
h = float(h) % 360.0
|
|
47
|
+
s = max(0.0, min(1.0, float(s)))
|
|
48
|
+
v = max(0.0, min(1.0, float(v)))
|
|
49
|
+
c = v * s
|
|
50
|
+
x = c * (1.0 - abs((h / 60.0) % 2.0 - 1.0))
|
|
51
|
+
m = v - c
|
|
52
|
+
if h < 60:
|
|
53
|
+
rgb = (c, x, 0.0)
|
|
54
|
+
elif h < 120:
|
|
55
|
+
rgb = (x, c, 0.0)
|
|
56
|
+
elif h < 180:
|
|
57
|
+
rgb = (0.0, c, x)
|
|
58
|
+
elif h < 240:
|
|
59
|
+
rgb = (0.0, x, c)
|
|
60
|
+
elif h < 300:
|
|
61
|
+
rgb = (x, 0.0, c)
|
|
62
|
+
else:
|
|
63
|
+
rgb = (c, 0.0, x)
|
|
64
|
+
return tuple(int(round(max(0.0, min(1.0, ch + m)) * 255.0)) for ch in rgb)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rgb_to_hsv(rgb):
|
|
68
|
+
"""(r,g,b) 0..255 -> (h 0..360, s 0..1, v 0..1)。与 hsv_to_rgb 互逆。"""
|
|
69
|
+
r, g, b = (max(0.0, min(1.0, float(c) / 255.0)) for c in tuple(rgb)[:3])
|
|
70
|
+
mx, mn = max(r, g, b), min(r, g, b)
|
|
71
|
+
d = mx - mn
|
|
72
|
+
if d < 1e-12:
|
|
73
|
+
return 0.0, 0.0, mx
|
|
74
|
+
if mx == r:
|
|
75
|
+
h = (60.0 * ((g - b) / d)) % 360.0
|
|
76
|
+
elif mx == g:
|
|
77
|
+
h = 60.0 * ((b - r) / d) + 120.0
|
|
78
|
+
else:
|
|
79
|
+
h = 60.0 * ((r - g) / d) + 240.0
|
|
80
|
+
s = 0.0 if mx < 1e-12 else d / mx
|
|
81
|
+
return h, s, mx
|
|
82
|
+
|
|
83
|
+
|
|
48
84
|
def _srgb_to_lin(v):
|
|
49
85
|
"""sRGB 通道(0..255)-> 线性光。"""
|
|
50
86
|
c = float(v) / 255.0
|
|
@@ -84,9 +120,9 @@ _HEX_RE = re.compile(r"^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$")
|
|
|
84
120
|
def _color_of(v, key):
|
|
85
121
|
"""把 theme_overrides 里的颜色值统一成 (r,g,b):支持 [r,g,b] 和 "#rrggbb" / "#rgb"。
|
|
86
122
|
|
|
87
|
-
为什么必须支持 hex:schemas/film.schema.json 与 references/
|
|
123
|
+
为什么必须支持 hex:schemas/film.schema.json 与 references/appearance.md 的示例
|
|
88
124
|
就是 {"accent": "#ff8a3d"},而 JSON 没有元组,照着文档写 hex 是必然的。
|
|
89
|
-
|
|
125
|
+
字符串必须在**这里**转成元组:原样塞进主题的话,scenes.py 会在渲染期崩
|
|
90
126
|
(实测:ink="#ffffff" -> ValueError: invalid literal for int() with base 10: '#')。
|
|
91
127
|
"""
|
|
92
128
|
if isinstance(v, str):
|
|
@@ -113,13 +149,13 @@ _THEME_COLOR_KEYS = frozenset(("bg", "bg2", "surface", "ink", "muted", "dim",
|
|
|
113
149
|
def apply_theme_overrides(theme, overrides):
|
|
114
150
|
"""把 film.json 的 visual.theme_overrides 合并进主题**并做类型校验**。
|
|
115
151
|
|
|
116
|
-
契约(schemas/film.schema.json:只接受主题里已存在的键;references/
|
|
152
|
+
契约(schemas/film.schema.json:只接受主题里已存在的键;references/appearance.md §3):
|
|
117
153
|
- 未知键忽略:以后主题加字段时老剧本不因为它不认识就炸;
|
|
118
154
|
- 颜色键(值本来是三色元组的那些)接受 [r,g,b] / "#rrggbb" / "#rgb",统一成元组;
|
|
119
155
|
- 数值键接受数字或数字字符串;字符串键必须是字符串;
|
|
120
|
-
- 嵌套结构(card / scale / qa
|
|
121
|
-
|
|
122
|
-
校验只在"
|
|
156
|
+
- 嵌套结构(card / scale / qa …)不支持覆盖,静默忽略(避免破坏已有配置);
|
|
157
|
+
真要改嵌套字段请在 designkit 里改生成逻辑。
|
|
158
|
+
校验只在"值类型不对、会在渲染期崩"的场合抛 ValueError(提前到 --stage style)。
|
|
123
159
|
返回新的 theme dict(不改入参)。
|
|
124
160
|
"""
|
|
125
161
|
out = dict(theme or {})
|
|
@@ -145,9 +181,9 @@ def apply_theme_overrides(theme, overrides):
|
|
|
145
181
|
out[k] = int(round(num)) if isinstance(base, int) else float(num)
|
|
146
182
|
continue
|
|
147
183
|
if isinstance(base, dict):
|
|
148
|
-
# 嵌套结构(card / scale / qa
|
|
149
|
-
#
|
|
150
|
-
#
|
|
184
|
+
# 嵌套结构(card / scale / qa)不支持覆盖:静默忽略而不是报错。
|
|
185
|
+
# 配置里写了这种覆盖时应视为"没生效"而不是"配置坏了",升级不该突然报错。
|
|
186
|
+
# 想改嵌套字段要在 designkit 的 _theme_of_design 里改,见 references/appearance.md §7。
|
|
151
187
|
continue
|
|
152
188
|
if isinstance(base, str):
|
|
153
189
|
if not isinstance(v, str):
|
|
@@ -206,172 +242,22 @@ def _theme(style_id, label, keywords, palette, material, typ, layout, motion, hu
|
|
|
206
242
|
}
|
|
207
243
|
|
|
208
244
|
|
|
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=(84, 98, 94), 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
245
|
# ============================================================================
|
|
246
|
+
# 二、主题:没有内置表,由 designkit 现场构造
|
|
247
|
+
# ============================================================================
|
|
248
|
+
#
|
|
249
|
+
# `THEMES` 恒为空:本技能不提供任何内置外观,写 style id 一律被拒绝(见 resolve_script_style)。
|
|
250
|
+
#
|
|
251
|
+
# 主题由 designkit 从连续空间现场构造:
|
|
252
|
+
# · 底色 = (色相 h∈[0,360), 饱和 s, 明度 v) 连续取值,经 _palette_from_hue 算出三段底色;
|
|
253
|
+
# · 其余 8 个语义色由对比度网格求解(每套都必须过 theme_lint);
|
|
254
|
+
# · 产出是同形的 35 字段主题字典,scenes.py 不需要知道它从哪来。
|
|
255
|
+
#
|
|
256
|
+
# 本模块提供的是**造东西的工具**,不是外观目录:
|
|
257
|
+
# luminance / contrast / _rgb / _color_of / apply_theme_overrides /
|
|
258
|
+
# _theme(构造器) / hsv_to_rgb / rgb_to_hsv / LINT_LIMITS / theme_lint /
|
|
259
|
+
# bg_contrast / is_dark / family_compatible / LAYOUTS(几何参数,不是外观目录)
|
|
260
|
+
THEMES = {}
|
|
375
261
|
# 三、版式骨架(LAYOUTS):3 种,scenes.py 按名字取几何参数
|
|
376
262
|
# ============================================================================
|
|
377
263
|
#
|
|
@@ -413,278 +299,62 @@ def layout(name):
|
|
|
413
299
|
|
|
414
300
|
|
|
415
301
|
# ============================================================================
|
|
416
|
-
#
|
|
302
|
+
# 四、风格表:恒为空(本技能不提供模板)
|
|
417
303
|
# ============================================================================
|
|
418
304
|
#
|
|
419
|
-
#
|
|
420
|
-
#
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
305
|
+
# 一个设计从哪来只有一条路:work/design.json —— `propose.py` 按 brief 现场采样、
|
|
306
|
+
# 人 `--confirm` 之后冻结。表为空是**契约**,不是没填:selfcheck 断言它们必须为空,
|
|
307
|
+
# 有内容就意味着有人偷偷恢复了外观清单。
|
|
308
|
+
#
|
|
309
|
+
# `LEGACY_STYLE` 常量只服务于一件事:让 `style="legacy"` 这类写法被**明确拒绝**并
|
|
310
|
+
# 给出迁移指引,而不是抛 AttributeError。
|
|
311
|
+
STYLE_IDS = ()
|
|
312
|
+
DEFAULT_STYLE = None
|
|
313
|
+
LEGACY_STYLE = "legacy"
|
|
314
|
+
STYLES = {}
|
|
315
|
+
|
|
316
|
+
# ---- 调式池:技术原语,不是模板 ----
|
|
317
|
+
# 项目只支持中文口播常见的 4 个小调主音与 6 个大调主音。列出来是**约束**
|
|
318
|
+
# (musicgen 与 designkit 都按它校验"调性是否可实现"),不是"给你 10 套现成配色"。
|
|
426
319
|
MINOR_KEYS = ("Dm", "Am", "Em", "Cm")
|
|
427
320
|
MAJOR_KEYS = ("C", "G", "D", "F", "A", "Bb")
|
|
428
321
|
|
|
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
|
-
# ============================================================================
|
|
535
|
-
# 四、视觉语言包(VISUAL_LANGUAGES):v0.9
|
|
536
|
-
# ============================================================================
|
|
537
|
-
#
|
|
538
|
-
# 为什么还要一层
|
|
539
|
-
# -------------
|
|
540
|
-
# v0.5 的 THEMES 只管"皮肤":配色、字体族、纹理、颗粒、字距、缓动、HUD、3 种版式几何。
|
|
541
|
-
# 8 套风格共用同一批版式与同一套装饰(开场放射线 + 收尾光点环),所以换风格只是换色,
|
|
542
|
-
# 构图骨架(剪影)没变 —— 这正是"每个视频都差不多"的根因。
|
|
543
|
-
#
|
|
544
|
-
# 这一层补的是**结构签名**,四组:
|
|
545
|
-
# motion 入场怎么进、镜头怎么推、呼吸元素多快
|
|
546
|
-
# motif 这套风格独有的装饰图形语言(直角括角 / 手绘内框 / letterbox / 信号条 …)
|
|
547
|
-
# transition 段与段之间怎么过(dip / wash / 纸卷 / 斜擦 / 硬切 / 放射环)
|
|
548
|
-
# typography 字距、行高、对齐、编号写法、display:body 字号比
|
|
549
|
-
#
|
|
550
|
-
# 设计原则(与 THEMES 一致)
|
|
551
|
-
# 1. 纯数据:不 import PIL / filmkit,scenes.py 反过来读我,成环就炸。
|
|
552
|
-
# 2. 只追加不改顺序:新风格往 STYLE_IDS 末尾追加,老风格的指纹映射不漂。
|
|
553
|
-
# 3. 每条签名都必须"能被剪影看出差别":只改颜色/字重的项不算签名。
|
|
554
|
-
# 4. 全部受 PIL 预算约束:装饰要么预烘焙成静态层,要么是 O(几) 次 ImageDraw 调用;
|
|
555
|
-
# 禁每帧全屏 GaussianBlur(实测 30–60ms)与全屏双线性 resize(29ms),预算 25ms。
|
|
556
|
-
#
|
|
557
|
-
# 剪影判据(tools/silhouette_qa.py 用的口径,也是 P1 的验收标准)
|
|
558
|
-
# 同一段内容在两种风格下渲 3 帧 → 灰度 → 以四角亮度中位数为基准三值化 →
|
|
559
|
-
# 比较 前景占比 F / 前景质心 (Cx,Cy) / 四边覆盖率 B / 左右对称度 S。
|
|
560
|
-
# 五项里至少一项达标才算"这套风格真的不一样":
|
|
561
|
-
# ΔF ≥ 6pp、|ΔCx| ≥ 8pp、|ΔCy| ≥ 8pp、ΔB ≥ 14pp、ΔS ≥ 0.20
|
|
562
322
|
|
|
563
323
|
VISUAL_MOTIFS = ("brackets", "hairlines", "handdrawn", "slabs",
|
|
564
324
|
"minimal", "letterbox", "signal", "rays")
|
|
565
325
|
VISUAL_TRANSITIONS = ("dip", "wash", "paper", "wipe", "cut", "iris")
|
|
566
326
|
NUMBER_STYLES = ("dim", "serif", "cn", "block", "roman", "timecode", "dot", "gilt")
|
|
567
327
|
|
|
328
|
+
# ============================================================================
|
|
329
|
+
# 五、视觉语言包:名字是绘制原语,不是外观目录
|
|
330
|
+
# ============================================================================
|
|
331
|
+
#
|
|
332
|
+
# `VISUAL_LANGUAGES` 恒为空。视觉语言由 `designkit._language_of(design)` 从设计字段
|
|
333
|
+
# 直接拼出来(母题/转场/字距/缓动/重心/layout_cycle 五组),再交给
|
|
334
|
+
# `visual_lint_language` 做体检。
|
|
335
|
+
#
|
|
336
|
+
# 这里保留的是 VISUAL_MOTIFS / VISUAL_TRANSITIONS / NUMBER_STYLES —— **绘制原语的名字**:
|
|
337
|
+
# scenes.py 按名字分发到具体画法。这是"能画什么",不是"长什么样"。
|
|
568
338
|
VISUAL_LANGUAGES = {}
|
|
569
339
|
|
|
570
|
-
VISUAL_LANGUAGES["tech-deep"] = {
|
|
571
|
-
"motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.04, "breathe_hz": 0.85},
|
|
572
|
-
"motif": {"kind": "brackets", "density": 1.0,
|
|
573
|
-
"detail": {"corner": 24, "rule_step": 120, "scan": True}},
|
|
574
|
-
"transition": {"kind": "dip", "dur": 0.28},
|
|
575
|
-
"typography": {"tracking": 0.0, "line_gap": 1.30, "align": "left",
|
|
576
|
-
"number": "dim", "ratio": 1.50},
|
|
577
|
-
"bias": {"x": 0, "y": 0, "inset": 0},
|
|
578
|
-
"layout_cycle": ("left-stack", "split-right", "center-stack"),
|
|
579
|
-
}
|
|
580
340
|
|
|
581
|
-
|
|
582
|
-
"
|
|
583
|
-
"motif": {"kind": "hairlines", "density": 0.85,
|
|
584
|
-
"detail": {"header_rule": True, "column_rule": True, "pageno": True}},
|
|
585
|
-
"transition": {"kind": "wash", "dur": 0.35},
|
|
586
|
-
"typography": {"tracking": 2.0, "line_gap": 1.34, "align": "right",
|
|
587
|
-
"number": "serif", "ratio": 1.53},
|
|
588
|
-
"bias": {"x": 60, "y": -20, "inset": 0},
|
|
589
|
-
"layout_cycle": ("split-right", "left-stack", "center-stack"),
|
|
590
|
-
}
|
|
341
|
+
def visual_language(lang=None):
|
|
342
|
+
"""返回一份可用的视觉语言包:传入字典就原样返回,否则给空壳。
|
|
591
343
|
|
|
592
|
-
|
|
593
|
-
"
|
|
594
|
-
|
|
595
|
-
"detail": {"wobble": 3.0, "seal": True, "tape": True}},
|
|
596
|
-
"transition": {"kind": "paper", "dur": 0.35},
|
|
597
|
-
"typography": {"tracking": 1.0, "line_gap": 1.36, "align": "left",
|
|
598
|
-
"number": "cn", "ratio": 1.53},
|
|
599
|
-
# 重心偏移(px):paper 是"手账"气质——内容整体压在左下、右侧留出大片空白给框与胶带;
|
|
600
|
-
# 这不是配色能表达的差异,是构图差异(剪影 Cx/Cy 直接反映它)。
|
|
601
|
-
"bias": {"x": -150, "y": 80, "inset": 40},
|
|
602
|
-
"layout_cycle": ("left-stack", "center-stack", "split-right"),
|
|
603
|
-
}
|
|
344
|
+
真正的语言包由 `designkit._language_of()` 生成并挂在 plan["language"] 上;
|
|
345
|
+
这个函数只负责让"没有语言包"的调用点拿到一个合法空壳而不是 None。"""
|
|
346
|
+
return lang if isinstance(lang, dict) else {}
|
|
604
347
|
|
|
605
|
-
VISUAL_LANGUAGES["vivid"] = {
|
|
606
|
-
"motion": {"ease": "outBack", "enter": (0.35, 0.70), "push": 0.10, "breathe_hz": 1.10},
|
|
607
|
-
"motif": {"kind": "slabs", "density": 1.0,
|
|
608
|
-
"detail": {"bar_h": 24, "slant": 18, "sticker": True}},
|
|
609
|
-
"transition": {"kind": "wipe", "dur": 0.20},
|
|
610
|
-
"typography": {"tracking": 1.0, "line_gap": 1.26, "align": "center",
|
|
611
|
-
"number": "block", "ratio": 2.97},
|
|
612
|
-
"bias": {"x": 0, "y": 40, "inset": 0},
|
|
613
|
-
"layout_cycle": ("center-stack", "left-stack", "split-right"),
|
|
614
|
-
}
|
|
615
348
|
|
|
616
|
-
|
|
617
|
-
"
|
|
618
|
-
"motif": {"kind": "minimal", "density": 0.55,
|
|
619
|
-
"detail": {"rule_h": 2, "box_radius": 0}},
|
|
620
|
-
"transition": {"kind": "cut", "dur": 0.0},
|
|
621
|
-
"typography": {"tracking": 6.0, "line_gap": 1.40, "align": "center",
|
|
622
|
-
"number": "roman", "ratio": 3.15},
|
|
623
|
-
"bias": {"x": 0, "y": 0, "inset": 0},
|
|
624
|
-
"layout_cycle": ("center-stack", "center-stack", "center-stack"),
|
|
625
|
-
}
|
|
349
|
+
def visual_lint_language(vl):
|
|
350
|
+
"""视觉语言体检:对**任意** language 字典,不要求它属于某个命名风格。
|
|
626
351
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
"
|
|
630
|
-
"detail": {"top": 40, "bottom": 44, "flares": True}},
|
|
631
|
-
"transition": {"kind": "dip", "dur": 0.45},
|
|
632
|
-
"typography": {"tracking": 3.0, "line_gap": 1.38, "align": "center",
|
|
633
|
-
"number": "timecode", "ratio": 1.62},
|
|
634
|
-
"bias": {"x": 0, "y": 30, "inset": 0},
|
|
635
|
-
"layout_cycle": ("center-stack", "left-stack", "split-right"),
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
VISUAL_LANGUAGES["lo-fi"] = {
|
|
639
|
-
"motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.05, "breathe_hz": 0.60},
|
|
640
|
-
"motif": {"kind": "signal", "density": 0.90,
|
|
641
|
-
"detail": {"bars": 12, "radius": 18, "note": True}},
|
|
642
|
-
"transition": {"kind": "wash", "dur": 0.30},
|
|
643
|
-
"typography": {"tracking": 1.0, "line_gap": 1.36, "align": "left",
|
|
644
|
-
"number": "dot", "ratio": 1.48},
|
|
645
|
-
# lo-fi 是"桌面便签"气质:整体右移、内容偏上,底部留给信号条与时间戳。
|
|
646
|
-
"bias": {"x": 60, "y": -70, "inset": 0},
|
|
647
|
-
"layout_cycle": ("left-stack", "split-right", "center-stack"),
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
VISUAL_LANGUAGES["epic"] = {
|
|
651
|
-
"motion": {"ease": "out5", "enter": (0.70, 1.20), "push": 0.14, "breathe_hz": 0.28},
|
|
652
|
-
"motif": {"kind": "rays", "density": 1.0,
|
|
653
|
-
"detail": {"rays": 18, "rule_gap": 26, "frame": True}},
|
|
654
|
-
"transition": {"kind": "iris", "dur": 0.40},
|
|
655
|
-
"typography": {"tracking": 4.0, "line_gap": 1.32, "align": "center",
|
|
656
|
-
"number": "gilt", "ratio": 3.18},
|
|
657
|
-
"bias": {"x": 0, "y": -30, "inset": 0},
|
|
658
|
-
"layout_cycle": ("center-stack", "center-stack", "center-stack"),
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
# legacy:v0.4 行为,没有视觉语言(scenes.py 走 _LEGACY_DRAWERS,不会读到这张表)
|
|
662
|
-
VISUAL_LANGUAGES[LEGACY_STYLE] = {
|
|
663
|
-
"motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.04, "breathe_hz": 0.85},
|
|
664
|
-
"motif": {"kind": "brackets", "density": 1.0, "detail": {}},
|
|
665
|
-
"transition": {"kind": "cut", "dur": 0.0},
|
|
666
|
-
"typography": {"tracking": 0.0, "line_gap": 1.30, "align": "left",
|
|
667
|
-
"number": "dim", "ratio": 1.50},
|
|
668
|
-
"layout_cycle": ("left-stack",),
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
def visual_language(style_id):
|
|
673
|
-
"""取风格的结构签名;未知 id 退到默认风格(不让拼写错误炸掉渲染)。"""
|
|
674
|
-
sid = str(style_id or "").strip()
|
|
675
|
-
got = VISUAL_LANGUAGES.get(sid)
|
|
676
|
-
if got is not None:
|
|
677
|
-
return got
|
|
678
|
-
return VISUAL_LANGUAGES[DEFAULT_STYLE]
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
def visual_lint(style_id):
|
|
682
|
-
"""结构签名体检(--stage style 就拦,不等到渲完 300 帧)。返回问题列表,空 = 过。"""
|
|
352
|
+
没有这条检查,转场 1 秒、push 0.3 这类会让整片观感崩掉的错误要等渲完 300 帧
|
|
353
|
+
才发现——所以它在 design 校验阶段就要跑。
|
|
354
|
+
"""
|
|
683
355
|
bad = []
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
return ["风格 %r 没有视觉语言包" % sid]
|
|
687
|
-
vl = VISUAL_LANGUAGES[sid]
|
|
356
|
+
if not isinstance(vl, dict) or not vl:
|
|
357
|
+
return ["视觉语言包缺失或不是字典"]
|
|
688
358
|
mo = vl.get("motion") or {}
|
|
689
359
|
mf = vl.get("motif") or {} # 注意:mo = motion,mf = motif,别混用
|
|
690
360
|
tr = vl.get("transition") or {}
|
|
@@ -694,385 +364,92 @@ def visual_lint(style_id):
|
|
|
694
364
|
if str(tr.get("kind")) not in VISUAL_TRANSITIONS:
|
|
695
365
|
bad.append("transition.kind=%r 不在 %s 里"
|
|
696
366
|
% (tr.get("kind"), " / ".join(VISUAL_TRANSITIONS)))
|
|
697
|
-
|
|
367
|
+
try:
|
|
368
|
+
_d = float(tr.get("dur") or 0.0)
|
|
369
|
+
except (TypeError, ValueError):
|
|
370
|
+
_d = -1.0
|
|
371
|
+
if not 0.0 <= _d <= 0.60:
|
|
698
372
|
# 段界 veil 超过 0.6s 会吃掉 3–6s 节奏下限,也会把两段内容糊在一起
|
|
699
373
|
bad.append("transition.dur=%r 必须在 0~0.60s(段界 veil 上限)" % tr.get("dur"))
|
|
700
374
|
if str(ty.get("number")) not in NUMBER_STYLES:
|
|
701
375
|
bad.append("typography.number=%r 不在 %s 里"
|
|
702
376
|
% (ty.get("number"), " / ".join(NUMBER_STYLES)))
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
377
|
+
for key, lo, hi in (("ratio", 1.0, 3.6), ("tracking", 0.0, 8.0),
|
|
378
|
+
("line_gap", 1.15, 1.55), ("push", 0.0, 0.16)):
|
|
379
|
+
try:
|
|
380
|
+
v = float((ty if key != "push" else mo).get(key) or 0.0)
|
|
381
|
+
except (TypeError, ValueError):
|
|
382
|
+
bad.append("typography/motion.%s=%r 不是数字" % (key, (ty if key != "push" else mo).get(key)))
|
|
383
|
+
continue
|
|
384
|
+
if key == "ratio" and not lo <= v <= hi:
|
|
385
|
+
bad.append("typography.ratio=%r 必须在 1.0~3.6(display:body 字号比)" % v)
|
|
386
|
+
elif key == "tracking" and not lo <= v <= hi:
|
|
387
|
+
bad.append("typography.tracking=%r 必须在 0~8px" % v)
|
|
388
|
+
elif key == "line_gap" and not lo <= v <= hi:
|
|
389
|
+
bad.append("typography.line_gap=%r 必须在 1.15~1.55" % v)
|
|
390
|
+
elif key == "push" and not lo <= v <= hi:
|
|
391
|
+
# push > 0.16 时背景层放大后内容会被推出 SAFE(_bg_plate 只裁不缩)
|
|
392
|
+
bad.append("motion.push=%r 必须在 0~0.16(超过会把内容推出安全区)" % v)
|
|
393
|
+
try:
|
|
394
|
+
dens = float(mf.get("density") or 0.0)
|
|
395
|
+
except (TypeError, ValueError):
|
|
396
|
+
dens = -1.0
|
|
397
|
+
if not 0.0 <= dens <= 1.0:
|
|
710
398
|
bad.append("motif.density=%r 必须在 0~1" % mf.get("density"))
|
|
711
|
-
|
|
399
|
+
elif dens <= 0.0 and str(mf.get("kind")) != "minimal":
|
|
712
400
|
bad.append("motif.density=0 只允许用于 minimal(否则等于没有装饰语言)")
|
|
713
401
|
enter = mo.get("enter") or (0.45, 0.85)
|
|
714
|
-
|
|
715
|
-
|
|
402
|
+
try:
|
|
403
|
+
ok_enter = (isinstance(enter, (list, tuple)) and len(enter) == 2
|
|
404
|
+
and 0.0 < float(enter[0]) <= float(enter[1]) <= 1.6)
|
|
405
|
+
except (TypeError, ValueError):
|
|
406
|
+
ok_enter = False
|
|
407
|
+
if not ok_enter:
|
|
716
408
|
bad.append("motion.enter=%r 必须是 (起, 止) 且 0 < 起 <= 止 <= 1.6 秒" % (enter,))
|
|
717
|
-
if not 0.0 <= float(mo.get("push") or 0.0) <= 0.16:
|
|
718
|
-
# push > 0.16 时背景层放大后内容会被推出 SAFE(_bg_plate 只裁不缩)
|
|
719
|
-
bad.append("motion.push=%r 必须在 0~0.16(超过会把内容推出安全区)" % mo.get("push"))
|
|
720
409
|
return bad
|
|
721
410
|
|
|
722
411
|
|
|
723
|
-
def
|
|
724
|
-
"""
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if bad:
|
|
729
|
-
out[sid] = bad
|
|
730
|
-
return out
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
ALL_STYLE_IDS = (LEGACY_STYLE,) + STYLE_IDS
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
def style_ids(include_legacy=True):
|
|
737
|
-
return list(ALL_STYLE_IDS) if include_legacy else list(STYLE_IDS)
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
def get(style_id):
|
|
741
|
-
"""取风格包;未知 id 报错并列出可用值(不静默回落,v0.4 的教训)。"""
|
|
742
|
-
sid = str(style_id or "").strip()
|
|
743
|
-
if sid not in STYLES:
|
|
744
|
-
raise KeyError("未知风格 %r;可用:%s" % (style_id, " / ".join(ALL_STYLE_IDS)))
|
|
745
|
-
return STYLES[sid]
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
def theme_of(style_id):
|
|
749
|
-
"""取风格对应的主题字典(元组颜色原样返回,给 PIL 直接下标)。"""
|
|
750
|
-
return THEMES[get(style_id)["theme"]]
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
def is_legacy(style_id):
|
|
754
|
-
return str(style_id or LEGACY_STYLE) == LEGACY_STYLE
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
# ============================================================================
|
|
758
|
-
# 五、内容指纹与自动选风格
|
|
759
|
-
# ============================================================================
|
|
760
|
-
|
|
761
|
-
FP_VERSION = "style-fp-v1"
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
def _digest(*parts):
|
|
765
|
-
"""sha1 派生的稳定 32 位无符号整数(禁 hash():str hash 每次进程带随机盐)。"""
|
|
766
|
-
h = hashlib.sha1(("|".join(str(p) for p in parts)).encode("utf-8")).hexdigest()
|
|
767
|
-
return int(h[:8], 16)
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
def _text_of(script):
|
|
771
|
-
"""把剧本里所有可读文字摊平(标题权重最高)。"""
|
|
772
|
-
title = str(script.get("title") or "")
|
|
773
|
-
chunks = [title, title] # 标题 x2
|
|
774
|
-
for ln in script.get("lines") or []:
|
|
775
|
-
chunks.append(str(ln.get("chapter") or ln.get("kicker") or ""))
|
|
776
|
-
chunks.append(str(ln.get("text") or ""))
|
|
777
|
-
chunks.append(str(ln.get("sub") or ""))
|
|
778
|
-
for it in ln.get("items") or []:
|
|
779
|
-
if isinstance(it, dict):
|
|
780
|
-
chunks.append(str(it.get("text") or ""))
|
|
781
|
-
else:
|
|
782
|
-
chunks.append(str(it))
|
|
783
|
-
return "".join(chunks)
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
def fingerprint(script):
|
|
787
|
-
"""三级内容指纹。同剧本 -> 同指纹;换一部片 -> 三个都变。"""
|
|
788
|
-
title = str(script.get("title") or "")
|
|
789
|
-
lines = script.get("lines") or []
|
|
790
|
-
texts = [str(ln.get("text") or "") for ln in lines]
|
|
791
|
-
kinds = [str(ln.get("kind") or "body") for ln in lines]
|
|
792
|
-
dur_bucket = int(len("".join(texts)) // 120) # 体量桶:改几个字不动它
|
|
793
|
-
return {
|
|
794
|
-
"version": FP_VERSION,
|
|
795
|
-
"full": "%08x" % _digest(FP_VERSION, "full", title, *texts),
|
|
796
|
-
"tempo": "%08x" % _digest(FP_VERSION, "tempo", title, *kinds, dur_bucket),
|
|
797
|
-
"style": "%08x" % _digest(FP_VERSION, "style", title),
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
def _kind_profile(script):
|
|
802
|
-
"""kind 分布画像:用于给风格打分(题材相近的片子 kind 结构也像)。"""
|
|
803
|
-
kinds = [str(ln.get("kind") or "body") for ln in script.get("lines") or []]
|
|
804
|
-
n = max(1, len(kinds))
|
|
805
|
-
return {
|
|
806
|
-
"hook": kinds.count("hook") / n,
|
|
807
|
-
"body": kinds.count("body") / n,
|
|
808
|
-
"outro": kinds.count("outro") / n,
|
|
809
|
-
"items": sum(1 for ln in script.get("lines") or [] if ln.get("items")) / n,
|
|
810
|
-
"avg_len": sum(len(str(ln.get("text") or "")) for ln in script.get("lines") or []) / n,
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
def score_styles(script):
|
|
815
|
-
"""给所有风格打分,返回 [(style_id, score, hits)],按分数降序(同分按 STYLE_IDS 顺序)。"""
|
|
816
|
-
text = _text_of(script)
|
|
817
|
-
prof = _kind_profile(script)
|
|
818
|
-
fp = fingerprint(script)
|
|
819
|
-
rows = []
|
|
820
|
-
for sid in STYLE_IDS:
|
|
821
|
-
th = THEMES[STYLES[sid]["theme"]]
|
|
822
|
-
hits = []
|
|
823
|
-
score = 0.0
|
|
824
|
-
for kw in th["keywords"]:
|
|
825
|
-
c = text.count(str(kw))
|
|
826
|
-
if c:
|
|
827
|
-
score += 3.0 * c
|
|
828
|
-
hits.append(str(kw))
|
|
829
|
-
if prof["items"] >= 0.5:
|
|
830
|
-
score += 1.5 if sid in ("tech-deep", "magazine", "vivid") else 0.0
|
|
831
|
-
if prof["avg_len"] >= 28:
|
|
832
|
-
score += 1.0 if sid in ("cinema", "paper", "mono") else 0.0
|
|
833
|
-
if prof["hook"] > 0 and prof["outro"] > 0:
|
|
834
|
-
score += 0.5 if sid in ("tech-deep", "vivid", "epic") else 0.0
|
|
835
|
-
# 指纹破平:必须把**风格 id** 混进哈希,否则这一项对所有行是同一个常数,
|
|
836
|
-
# 等于没破平(v0.8 深审实测:旧写法在"AI 报告"上 tech-deep / magazine 同分 12.0,
|
|
837
|
-
# 永远按 STYLE_IDS 顺序选 tech-deep)。幅度上限 0.0006 << 最小真实分差 0.5,
|
|
838
|
-
# 只在精确同分时起作用,且不改变"改错别字不换风格"(指纹只吃标题)。
|
|
839
|
-
score += (_digest("tiebreak", fp["style"], sid) % 7) * 0.0001
|
|
840
|
-
rows.append((sid, round(score, 4), hits))
|
|
841
|
-
rows.sort(key=lambda r: (-r[1], STYLE_IDS.index(r[0])))
|
|
842
|
-
return rows
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
def pick_style(script):
|
|
846
|
-
"""内容驱动选风格。返回 (style_id, reason[list[str], 给用户看的理由])。"""
|
|
847
|
-
rows = score_styles(script)
|
|
848
|
-
best_id, best_score, best_hits = rows[0]
|
|
849
|
-
fp = fingerprint(script)
|
|
850
|
-
if best_score >= 1.0:
|
|
851
|
-
if best_hits:
|
|
852
|
-
reason = ["关键词命中:%s" % "、".join(dict.fromkeys(best_hits))[:80]]
|
|
853
|
-
else:
|
|
854
|
-
# 分数可以只由结构加分(items / 长句 / hook+outro)凑到 1.0 以上,
|
|
855
|
-
# 这时旧文案会写"关键词命中:"后面空一截 —— 等于向用户报告没发生的事。
|
|
856
|
-
reason = ["无关键词命中,按题材结构加分选中(items / 长句 / hook+outro)"]
|
|
857
|
-
else:
|
|
858
|
-
pool = list(STYLE_IDS)
|
|
859
|
-
best_id = pool[int(fp["tempo"], 16) % len(pool)]
|
|
860
|
-
reason = ["无关键词命中,按内容指纹落桶(%s)" % fp["tempo"][:8]]
|
|
861
|
-
return best_id, reason
|
|
412
|
+
def visual_lint(lang=None):
|
|
413
|
+
"""对**任意** language 字典做体检;传字符串(风格 id)时明确报错而不是静默通过。"""
|
|
414
|
+
if isinstance(lang, str):
|
|
415
|
+
return ["没有命名视觉语言 %r(视觉语言由 design 现场生成)" % lang]
|
|
416
|
+
return visual_lint_language(lang or {})
|
|
862
417
|
|
|
863
418
|
|
|
864
419
|
# ============================================================================
|
|
865
|
-
#
|
|
420
|
+
# 六、解析:任何"按 style id 取外观"的调用都必须明确失败
|
|
866
421
|
# ============================================================================
|
|
867
422
|
|
|
868
423
|
|
|
869
|
-
def _pick_from_pool(pool, fp_hex, salt=""):
|
|
870
|
-
"""从池里按指纹稳定取一个。
|
|
871
|
-
|
|
872
|
-
salt 用来把"风格 id"混进选择:不同风格即便指纹相同,也会落到池里不同位置。
|
|
873
|
-
没有 salt 时会出现 paper 与 lo-fi 都选到 84BPM / G 调这种"风格不同但基础参数撞车",
|
|
874
|
-
听感上就打了折扣 —— 这正是 v0.4 同质化的另一种形态。
|
|
875
|
-
"""
|
|
876
|
-
seq = [p for p in pool if p]
|
|
877
|
-
if not seq:
|
|
878
|
-
return None
|
|
879
|
-
v = int(fp_hex, 16)
|
|
880
|
-
if salt:
|
|
881
|
-
v += _digest("pick", salt)
|
|
882
|
-
return seq[v % len(seq)]
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
def _as_bool(v, default=False):
|
|
886
|
-
if v is None:
|
|
887
|
-
return default
|
|
888
|
-
if isinstance(v, str):
|
|
889
|
-
return v.strip().lower() not in ("0", "false", "no", "off", "")
|
|
890
|
-
return bool(v)
|
|
891
|
-
|
|
892
|
-
|
|
893
424
|
def resolve_script_style(script):
|
|
894
|
-
"""
|
|
425
|
+
"""没有设计时的**唯一失败出口**:明确报错并给两步迁移指引。
|
|
895
426
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
配乐 BPM / 调性 / 情绪、旁白声线映射、节奏都能被 film.json 单独覆盖。
|
|
901
|
-
"""
|
|
902
|
-
fp = fingerprint(script)
|
|
903
|
-
vis = script.get("visual") if isinstance(script.get("visual"), dict) else {}
|
|
904
|
-
mus = script.get("music") if isinstance(script.get("music"), dict) else {}
|
|
905
|
-
voce = script.get("voice") if isinstance(script.get("voice"), dict) else {}
|
|
906
|
-
|
|
907
|
-
has_style_field = "style" in script
|
|
908
|
-
raw = script.get("style")
|
|
909
|
-
raw_s = str(raw).strip().lower() if raw is not None else ""
|
|
910
|
-
if not has_style_field and not vis.get("theme") and not mus.get("style"):
|
|
911
|
-
# 老项目(film.json 里根本没有 style 字段)-> legacy:配乐逐样本一致,v0.4 原路径
|
|
912
|
-
sid, source, reason = LEGACY_STYLE, "legacy", ["film.json 没有 style 字段,走 v0.4 兼容路径"]
|
|
913
|
-
elif raw_s in ("", "auto", "default"):
|
|
914
|
-
if vis.get("theme"):
|
|
915
|
-
sid = str(vis["theme"])
|
|
916
|
-
source = "visual.theme"
|
|
917
|
-
reason = ["film.json visual.theme 指定"]
|
|
918
|
-
else:
|
|
919
|
-
sid, reason = pick_style(script)
|
|
920
|
-
source = "auto"
|
|
921
|
-
else:
|
|
922
|
-
sid = str(raw).strip()
|
|
923
|
-
source = "explicit"
|
|
924
|
-
reason = ["film.json style 指定"]
|
|
925
|
-
get(sid) # 未知 id 在这里就炸,不要等到渲染 300 帧后
|
|
926
|
-
pack = STYLES[sid]
|
|
927
|
-
|
|
928
|
-
# ---- 配乐:风格 / BPM / 调性 / 情绪 ----
|
|
929
|
-
music_style = str(mus.get("style") or "").strip().lower()
|
|
930
|
-
if music_style in ("", "auto", "default"):
|
|
931
|
-
music_style = sid if not is_legacy(sid) else LEGACY_STYLE
|
|
932
|
-
mpack = pack["music"]
|
|
933
|
-
ladder = tuple(mpack["bpm_ladder"])
|
|
934
|
-
bpm_raw = mus.get("bpm", script.get("bpm"))
|
|
935
|
-
if isinstance(bpm_raw, str) and bpm_raw.strip().lower() == "auto":
|
|
936
|
-
bpm_raw = "auto"
|
|
937
|
-
if bpm_raw in (None, "", "auto"):
|
|
938
|
-
if is_legacy(sid):
|
|
939
|
-
bpm, bpm_source = 120.0, "legacy" # v0.4 的缺省值,必须是 120
|
|
940
|
-
else:
|
|
941
|
-
bpm = float(_pick_from_pool(ladder, fp["tempo"], salt="bpm:" + sid))
|
|
942
|
-
bpm_source = "style_ladder"
|
|
943
|
-
else:
|
|
944
|
-
bpm, bpm_source = float(bpm_raw), "explicit"
|
|
945
|
-
|
|
946
|
-
key_raw = mus.get("key")
|
|
947
|
-
if key_raw in (None, "", "auto"):
|
|
948
|
-
if is_legacy(sid):
|
|
949
|
-
key, key_source = "Dm", "legacy" # v0.4 的缺省值,必须是 Dm
|
|
950
|
-
else:
|
|
951
|
-
key = _pick_from_pool(mpack["keys"], fp["tempo"], salt="key:" + sid) or "Dm"
|
|
952
|
-
key_source = "style_pool"
|
|
953
|
-
else:
|
|
954
|
-
key, key_source = str(key_raw), "explicit"
|
|
955
|
-
|
|
956
|
-
mood = str(mus.get("mood") or "").strip().lower()
|
|
957
|
-
if mood in ("", "auto", "default"):
|
|
958
|
-
mood, mood_source = str(mpack.get("mood") or "steady"), "style"
|
|
959
|
-
else:
|
|
960
|
-
mood_source = "explicit"
|
|
961
|
-
|
|
962
|
-
seed_raw = mus.get("seed", script.get("seed", 7))
|
|
963
|
-
try:
|
|
964
|
-
seed = abs(int(seed_raw if seed_raw is not None else 7))
|
|
965
|
-
except (TypeError, ValueError):
|
|
966
|
-
seed = 7
|
|
967
|
-
seed_from_content = _as_bool(mus.get("seed_from_content"), default=(not is_legacy(sid)))
|
|
968
|
-
if seed_from_content:
|
|
969
|
-
seed_effective = (seed * 1000003 + int(fp["full"], 16)) % (2 ** 31 - 1)
|
|
970
|
-
else:
|
|
971
|
-
seed_effective = seed
|
|
972
|
-
|
|
973
|
-
# ---- 旁白:声线映射 + 策略 ----
|
|
974
|
-
style_voice = pack["voice"]
|
|
975
|
-
voice_map = {}
|
|
976
|
-
if not is_legacy(sid):
|
|
977
|
-
for kind in ("hook", "body", "quote", "outro"):
|
|
978
|
-
dv = style_voice.get(kind)
|
|
979
|
-
if dv:
|
|
980
|
-
voice_map[kind] = dv
|
|
981
|
-
explicit_map = voce.get("map") if isinstance(voce.get("map"), dict) else {}
|
|
982
|
-
voice_map.update({str(k): str(v) for k, v in explicit_map.items() if v})
|
|
983
|
-
policy = {
|
|
984
|
-
"auto": _as_bool(voce.get("auto"), default=(not is_legacy(sid))),
|
|
985
|
-
"jitter": float(voce.get("jitter", 0.0 if is_legacy(sid) else 0.35)),
|
|
986
|
-
"max_distinct": int(voce.get("max_distinct", 2 if is_legacy(sid) else 3)),
|
|
987
|
-
"rate_range": (-15, 12),
|
|
988
|
-
"pitch_range": (-8, 8),
|
|
989
|
-
"cps_band": (3.6, 5.6),
|
|
990
|
-
}
|
|
991
|
-
if isinstance(voce.get("rate_range"), (list, tuple)) and len(voce["rate_range"]) == 2:
|
|
992
|
-
policy["rate_range"] = (int(voce["rate_range"][0]), int(voce["rate_range"][1]))
|
|
993
|
-
if isinstance(voce.get("cps_band"), (list, tuple)) and len(voce["cps_band"]) == 2:
|
|
994
|
-
policy["cps_band"] = (float(voce["cps_band"][0]), float(voce["cps_band"][1]))
|
|
995
|
-
|
|
996
|
-
# ---- 节奏 ----
|
|
997
|
-
sp = pack["pacing"]
|
|
998
|
-
pacing = {
|
|
999
|
-
"lead": float(script.get("lead", sp["lead"])),
|
|
1000
|
-
"tail": float(script.get("tail", sp["tail"])),
|
|
1001
|
-
"min_dur": float(script.get("min_dur", sp["min_dur"])),
|
|
1002
|
-
"hold": float(script.get("hold", sp["hold"])),
|
|
1003
|
-
"item_stagger": float(THEMES[pack["theme"]]["item_stagger"]),
|
|
1004
|
-
}
|
|
1005
|
-
explicit_pace = script.get("pacing") if isinstance(script.get("pacing"), dict) else {}
|
|
1006
|
-
for k in ("lead", "tail", "min_dur", "hold", "item_stagger"):
|
|
1007
|
-
if explicit_pace.get(k) is not None:
|
|
1008
|
-
pacing[k] = float(explicit_pace[k])
|
|
1009
|
-
|
|
1010
|
-
# ---- 版式轮换:按 chapter 轮换骨架(同一章节内不变,避免每句都跳) ----
|
|
1011
|
-
layouts = list(pack["layouts"]) or ["left-stack"]
|
|
1012
|
-
chapters = []
|
|
1013
|
-
for ln in script.get("lines") or []:
|
|
1014
|
-
ch = str(ln.get("chapter") or "")
|
|
1015
|
-
if ch not in chapters:
|
|
1016
|
-
chapters.append(ch)
|
|
1017
|
-
explicit_layout = str(vis.get("layout") or "").strip()
|
|
1018
|
-
if explicit_layout:
|
|
1019
|
-
layout(explicit_layout)
|
|
1020
|
-
chap_layout = {ch: explicit_layout for ch in chapters}
|
|
1021
|
-
else:
|
|
1022
|
-
chap_layout = {ch: layouts[i % len(layouts)] for i, ch in enumerate(chapters)}
|
|
1023
|
-
|
|
1024
|
-
# theme_overrides 走带校验的合并(颜色支持 "#rrggbb",非法值在这里就报错,
|
|
1025
|
-
# 不要等到 scenes.py 渲到第 300 帧才 int("#") 崩——见 apply_theme_overrides)
|
|
1026
|
-
theme = apply_theme_overrides(THEMES[pack["theme"]], vis.get("theme_overrides"))
|
|
1027
|
-
# visual.layout 是"显式换整片版式":旧的实现只把它写进 chapter_layout,而
|
|
1028
|
-
# PIL 路线渲染读的是 theme["layout"]、web 路线读 layout_default —— 两条路线都不看
|
|
1029
|
-
# chapter_layout,于是这条文档化的覆盖等于没写(v0.8 深审实测:visual.layout=
|
|
1030
|
-
# split-right 的帧与默认帧 sha256 完全相同)。这里补写 theme["layout"] 与 layout_default,
|
|
1031
|
-
# 让两条路线都跟着走;theme_overrides 更具体,它显式写了 layout 时以它为准。
|
|
1032
|
-
if explicit_layout and "layout" not in (vis.get("theme_overrides") or {}):
|
|
1033
|
-
theme["layout"] = explicit_layout
|
|
1034
|
-
if explicit_layout:
|
|
1035
|
-
chap_layout = {ch: str(theme["layout"]) for ch in chapters}
|
|
1036
|
-
|
|
1037
|
-
return {
|
|
1038
|
-
"version": 1,
|
|
1039
|
-
"generator": "stylekit 0.5.0",
|
|
1040
|
-
"style": sid,
|
|
1041
|
-
"style_name": pack["name"],
|
|
1042
|
-
"style_source": source,
|
|
1043
|
-
"style_reason": list(reason),
|
|
1044
|
-
"legacy": bool(is_legacy(sid)),
|
|
1045
|
-
"fingerprint": fp,
|
|
1046
|
-
"theme_id": pack["theme"],
|
|
1047
|
-
"theme_label": THEMES[pack["theme"]]["label"],
|
|
1048
|
-
# 主题字典随计划一起返回:scenes.py 的 _scene_theme 优先用它(含 theme_overrides),
|
|
1049
|
-
# 也方便 render.py 直接传给 draw_subtitle/write_ass,不必再按 id 查一次。
|
|
1050
|
-
"theme": theme,
|
|
1051
|
-
"layout_default": str(theme["layout"]) if explicit_layout else layouts[0],
|
|
1052
|
-
"chapter_layout": chap_layout,
|
|
1053
|
-
"music": {
|
|
1054
|
-
"style": music_style,
|
|
1055
|
-
"bpm": bpm, "bpm_source": bpm_source,
|
|
1056
|
-
"key": key, "key_source": key_source,
|
|
1057
|
-
"mood": mood, "mood_source": mood_source,
|
|
1058
|
-
"seed": int(seed), "seed_effective": int(seed_effective),
|
|
1059
|
-
"seed_from_content": bool(seed_from_content),
|
|
1060
|
-
},
|
|
1061
|
-
"voice": {
|
|
1062
|
-
"map": voice_map,
|
|
1063
|
-
"default": voce.get("default"),
|
|
1064
|
-
"policy": policy,
|
|
1065
|
-
},
|
|
1066
|
-
"pacing": pacing,
|
|
1067
|
-
}
|
|
427
|
+
本函数不再解析任何东西——本技能没有内置风格表,"按 style id 取外观"这件事不存在。
|
|
428
|
+
但它必须**存在且报错**:`filmkit.style_plan` / `scenes.py` / `preflight` / `qavmaf`
|
|
429
|
+
里都有「拿不到 plan 时回退到这里」的调用点。保留一个能给指引的入口,
|
|
430
|
+
那些点会得到"先 propose 再 confirm";删掉函数只会得到 AttributeError。
|
|
1068
431
|
|
|
432
|
+
真正的计划只有两个来源:`designkit.design_to_plan(work/design.json)`,
|
|
433
|
+
或 `render.resolve_style()` 解析好挂在 `script["_style"]` 上的那一份。
|
|
434
|
+
"""
|
|
435
|
+
raw = script.get("style") if isinstance(script, dict) else None
|
|
436
|
+
extra = ("(收到 style=%r)" % (raw,)) if raw not in (None, "") else ""
|
|
437
|
+
raise ValueError(
|
|
438
|
+
"这个剧本没有可解析的设计%s:本技能不提供任何内置风格/模板,\n"
|
|
439
|
+
" 「写 style 走内置外观」与「不写 style 走老外观」两条路都已删除。\n"
|
|
440
|
+
" 正确做法(两步):\n"
|
|
441
|
+
" 1) python propose.py --brief \"题材/要求\" --script <film.json> --n 3\n"
|
|
442
|
+
" 2) python propose.py --confirm --candidate A # 冻结成 work/design.json\n"
|
|
443
|
+
" 然后 python render.py --script <film.json> --stage all\n"
|
|
444
|
+
" (设计也可以手写 work/design.json,字段契约见 schemas/design.schema.json)"
|
|
445
|
+
% extra)
|
|
1069
446
|
|
|
1070
447
|
# ============================================================================
|
|
1071
448
|
# 七、门禁:主题体检(对比度 / 明暗 / 结构)
|
|
1072
449
|
# ============================================================================
|
|
1073
450
|
#
|
|
1074
|
-
#
|
|
1075
|
-
#
|
|
451
|
+
# 阈值来自 WCAG 2.1(inks 取 AAA 正文线,其余取 AA),不是拍脑袋定的。
|
|
452
|
+
# 量法:**按 bg/bg2 两端的最差值**,不看中点——中点会把"一端合格、一端不合格"平均过去。
|
|
1076
453
|
|
|
1077
454
|
LINT_LIMITS = {
|
|
1078
455
|
"ink": 7.0, # 正文级(WCAG AAA 正文线)
|
|
@@ -1090,10 +467,8 @@ def bg_contrast(th, role):
|
|
|
1090
467
|
"""文字色对背景的**最差**对比度:bg/bg2 是上下两端的主渐变(scenes._bg 按行插值),
|
|
1091
468
|
任何一行文字都可能落在两端附近,所以取二者较小值,而不是看中点。
|
|
1092
469
|
|
|
1093
|
-
|
|
1094
|
-
中点平均 4.84 把 AA 4.5 的门槛"平均"
|
|
1095
|
-
这一版把门槛改成端点最差值,并同时下调 lo-fi 的 muted 到 (84,98,94)
|
|
1096
|
-
(bg 5.44 / bg2 4.56,两端都过)。
|
|
470
|
+
为什么不能只看中点:某次实测的 muted(86,100,96) 对 bg 是 5.28、对 bg2 只有 4.42,
|
|
471
|
+
中点平均 4.84 把 AA 4.5 的门槛"平均"过去了。端点判据不会漏掉这种。
|
|
1097
472
|
"""
|
|
1098
473
|
return min(contrast(th[role], th["bg"]), contrast(th[role], th["bg2"]))
|
|
1099
474
|
|
|
@@ -1129,16 +504,6 @@ def theme_lint(th):
|
|
|
1129
504
|
return bad
|
|
1130
505
|
|
|
1131
506
|
|
|
1132
|
-
def lint_all():
|
|
1133
|
-
"""全部内置主题体检,返回 {style_id: [问题...]}(只有有问题的才在里面)。"""
|
|
1134
|
-
out = {}
|
|
1135
|
-
for sid in STYLE_IDS:
|
|
1136
|
-
bad = theme_lint(THEMES[STYLES[sid]["theme"]])
|
|
1137
|
-
if bad:
|
|
1138
|
-
out[sid] = bad
|
|
1139
|
-
return out
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
507
|
# ============================================================================
|
|
1143
508
|
# 八、片内一致性:明暗族判定(段界不许跨族,否则必触发闪烁门禁)
|
|
1144
509
|
# ============================================================================
|
|
@@ -1153,88 +518,42 @@ def family_compatible(a, b):
|
|
|
1153
518
|
return is_dark(a) == is_dark(b)
|
|
1154
519
|
|
|
1155
520
|
|
|
1156
|
-
def blend_theme(a, b, p):
|
|
1157
|
-
"""按 p in [0,1] 线性插值两套主题(同族时用于段界缓变,避免整片换底触发闪烁门禁)。"""
|
|
1158
|
-
if not family_compatible(a, b):
|
|
1159
|
-
raise ValueError("不能混合明暗族不同的主题:%s / %s" % (a.get("id"), b.get("id")))
|
|
1160
|
-
q = max(0.0, min(1.0, float(p)))
|
|
1161
|
-
out = dict(a)
|
|
1162
|
-
for k, va in a.items():
|
|
1163
|
-
vb = b.get(k, va)
|
|
1164
|
-
if isinstance(va, tuple) and len(va) == 3 and isinstance(vb, tuple) and len(vb) == 3:
|
|
1165
|
-
out[k] = tuple(int(round(va[i] * (1 - q) + vb[i] * q)) for i in range(3))
|
|
1166
|
-
elif (isinstance(va, (int, float)) and not isinstance(va, bool)
|
|
1167
|
-
and isinstance(vb, (int, float))):
|
|
1168
|
-
out[k] = type(va)(round(va * (1 - q) + vb * q, 4))
|
|
1169
|
-
return out
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
521
|
# ============================================================================
|
|
1173
|
-
# 九、CLI
|
|
522
|
+
# 九、CLI:没有可列/可看的风格,只解释能力去了哪
|
|
1174
523
|
# ============================================================================
|
|
524
|
+
#
|
|
525
|
+
# --list / --show / --lint 三个动作保留**壳**:说明"没有可选外观清单"并以 exit 2 退出。
|
|
526
|
+
# 不能返回 0 —— 在空表上 exit 0 是假通过,脚本会以为"列出来了"。
|
|
1175
527
|
|
|
1176
528
|
|
|
1177
|
-
def
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
% (pack["music"]["scale"], ",".join(pack["music"]["keys"]),
|
|
1187
|
-
",".join(str(b) for b in pack["music"]["bpm_ladder"]), pack["music"]["mood"]),
|
|
1188
|
-
" voice : %s" % ", ".join("%s=%s" % (k, v) for k, v in pack["voice"].items() if v),
|
|
1189
|
-
" pacing : lead=%.2f tail=%.2f min_dur=%.1f"
|
|
1190
|
-
% (pack["pacing"]["lead"], pack["pacing"]["tail"], pack["pacing"]["min_dur"]),
|
|
1191
|
-
" palette : bg=%s bg2=%s ink=%s accent=%s"
|
|
1192
|
-
% (th["bg"], th["bg2"], th["ink"], th["accent"]),
|
|
1193
|
-
# 与 theme_lint 同口径:bg/bg2 两端取最差(旧版打中点值,会掩盖端点不达标)
|
|
1194
|
-
" contrast : ink %.2f / muted %.2f / dim %.2f / accent %.2f(bg/bg2 最差端)"
|
|
1195
|
-
% (bg_contrast(th, "ink"), bg_contrast(th, "muted"),
|
|
1196
|
-
bg_contrast(th, "dim"), bg_contrast(th, "accent")),
|
|
1197
|
-
" font : family=%s display=%spx hud=%s"
|
|
1198
|
-
% (th["family"], th["scale"]["display"][0], th["hud"]),
|
|
1199
|
-
]
|
|
1200
|
-
bad = theme_lint(th)
|
|
1201
|
-
lines.append(" lint : %s" % ("PASS" if not bad else "FAIL -> " + ";".join(bad)))
|
|
1202
|
-
return "\n".join(lines)
|
|
529
|
+
def _explain_removed(what):
|
|
530
|
+
print("[stylekit] %s:本技能没有可选外观清单(不用内置风格表)。" % what,
|
|
531
|
+
file=sys.stderr)
|
|
532
|
+
print(" 现在没有「可选外观清单」,每一版外观都是现场生成的:", file=sys.stderr)
|
|
533
|
+
print(" python propose.py --brief \"题材/要求\" --script <film.json> --n 3", file=sys.stderr)
|
|
534
|
+
print(" python propose.py --confirm --candidate A # 冻结 work/design.json", file=sys.stderr)
|
|
535
|
+
print(" 想看某个设计长什么样 / 是否合规:", file=sys.stderr)
|
|
536
|
+
print(" python designkit.py --lint <design.json>", file=sys.stderr)
|
|
537
|
+
print(" python propose.py --show A", file=sys.stderr)
|
|
1203
538
|
|
|
1204
539
|
|
|
1205
540
|
def main(argv=None):
|
|
1206
|
-
ap = argparse.ArgumentParser(
|
|
1207
|
-
|
|
1208
|
-
ap.add_argument("--
|
|
1209
|
-
ap.add_argument("--
|
|
541
|
+
ap = argparse.ArgumentParser(
|
|
542
|
+
description="视觉原语库(无内置风格:本命令只说明外观从哪来,不列清单)")
|
|
543
|
+
ap.add_argument("--list", action="store_true", help="(没有此能力)列出外观")
|
|
544
|
+
ap.add_argument("--show", metavar="ID", help="(没有此能力)查看某个外观")
|
|
545
|
+
ap.add_argument("--lint", action="store_true", help="(没有此能力)批量体检内置主题")
|
|
1210
546
|
ap.add_argument("--json", action="store_true", help="配合 --show 输出 JSON")
|
|
1211
547
|
a = ap.parse_args(argv)
|
|
1212
548
|
try:
|
|
1213
549
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
1214
550
|
except Exception: # noqa: BLE001
|
|
1215
551
|
pass
|
|
1216
|
-
if a.show:
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
if a.json:
|
|
1222
|
-
import json
|
|
1223
|
-
print(json.dumps({"style": STYLES[a.show],
|
|
1224
|
-
"theme": THEMES[STYLES[a.show]["theme"]]},
|
|
1225
|
-
ensure_ascii=False, indent=1, default=str))
|
|
1226
|
-
else:
|
|
1227
|
-
print(_describe(a.show))
|
|
1228
|
-
return 0
|
|
1229
|
-
if a.lint:
|
|
1230
|
-
bad = lint_all()
|
|
1231
|
-
for sid in STYLE_IDS:
|
|
1232
|
-
print(("FAIL " if sid in bad else "PASS ") + sid
|
|
1233
|
-
+ ("" if sid not in bad else " " + ";".join(bad[sid])))
|
|
1234
|
-
return 1 if bad else 0
|
|
1235
|
-
for sid in ALL_STYLE_IDS:
|
|
1236
|
-
print(_describe(sid))
|
|
1237
|
-
print("")
|
|
552
|
+
if a.show or a.list or a.lint:
|
|
553
|
+
_explain_removed("--list / --show / --lint")
|
|
554
|
+
# 退出码 2:与 render.py 的「未知取值」口径一致(不是 1 的「跑失败」)。
|
|
555
|
+
return 2
|
|
556
|
+
ap.print_help()
|
|
1238
557
|
return 0
|
|
1239
558
|
|
|
1240
559
|
|