@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +473 -0
  2. package/README.md +263 -327
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +31 -42
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1216 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +2 -4
  11. package/skills/video-studio/assets/golden_frames.json +13 -10
  12. package/skills/video-studio/assets/scenes.py +153 -65
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/appearance.md +123 -0
  15. package/skills/video-studio/references/audio-variation.md +74 -93
  16. package/skills/video-studio/references/audio.md +64 -66
  17. package/skills/video-studio/references/change-impact.md +5 -5
  18. package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
  19. package/skills/video-studio/references/frame-qa.md +48 -16
  20. package/skills/video-studio/references/pipeline.md +4 -4
  21. package/skills/video-studio/references/pitfalls.md +22 -3
  22. package/skills/video-studio/references/proposal.md +160 -0
  23. package/skills/video-studio/references/quality-gates.md +11 -11
  24. package/skills/video-studio/references/scene-authoring.md +2 -2
  25. package/skills/video-studio/references/scene-grammar.md +40 -42
  26. package/skills/video-studio/references/sfx-design.md +8 -6
  27. package/skills/video-studio/references/web-engine.md +8 -8
  28. package/skills/video-studio/references/word-timeline.md +9 -9
  29. package/skills/video-studio/requirements.txt +9 -13
  30. package/skills/video-studio/scripts/designkit.py +2115 -0
  31. package/skills/video-studio/scripts/filmcheck.py +22 -48
  32. package/skills/video-studio/scripts/filmkit.py +2980 -2904
  33. package/skills/video-studio/scripts/gates.py +4 -4
  34. package/skills/video-studio/scripts/grammar.py +73 -13
  35. package/skills/video-studio/scripts/musicgen.py +2042 -0
  36. package/skills/video-studio/scripts/preflight.py +14 -7
  37. package/skills/video-studio/scripts/previewkit.py +848 -0
  38. package/skills/video-studio/scripts/propose.py +825 -0
  39. package/skills/video-studio/scripts/qa_frames.py +73 -39
  40. package/skills/video-studio/scripts/qaasr.py +26 -7
  41. package/skills/video-studio/scripts/qavmaf.py +89 -23
  42. package/skills/video-studio/scripts/render.py +545 -231
  43. package/skills/video-studio/scripts/scorekit.py +55 -1755
  44. package/skills/video-studio/scripts/selfcheck.py +815 -215
  45. package/skills/video-studio/scripts/sfxkit.py +27 -26
  46. package/skills/video-studio/scripts/stagewatch.py +24 -8
  47. package/skills/video-studio/scripts/stylekit.py +207 -888
  48. package/skills/video-studio/scripts/voicekit.py +508 -387
  49. package/skills/video-studio/scripts/webengine.py +71 -72
  50. package/tools/audio_similarity.py +64 -37
  51. package/tools/ci_check.py +20 -14
  52. package/tools/estimate.py +34 -29
  53. package/tools/golden_frames.py +72 -28
  54. package/tools/silhouette_qa.py +520 -168
  55. package/docs/demo-pil-sheet.png +0 -0
  56. package/docs/styles/demo-results.json +0 -76
  57. package/docs/styles/sheet-8-styles.png +0 -0
  58. package/docs/styles/sheet-ai.png +0 -0
  59. package/docs/styles/sheet-before-after.png +0 -0
  60. package/docs/styles/sheet-craft.png +0 -0
  61. package/docs/styles/sheet-night.png +0 -0
  62. package/skills/video-studio/assets/score_demo.json +0 -307
  63. package/skills/video-studio/references/style-packs.md +0 -283
@@ -1,42 +1,34 @@
1
1
  # -*- coding: utf-8 -*-
2
- """stylekit.py — 风格包(Style Packs):一个 id 同时决定画风、配乐、旁白与节奏。
2
+ """stylekit.py — 视觉/音频原语库:颜色数学、版式几何、主题体检、视觉语言体检。
3
3
 
4
- v0.5.0 新增。解决的问题是 v0.4 及以前的同质化:画风锁死在 scenes.py 的 THEME 常量、
5
- 配乐锁死在 scorekit 的 Dm/120,旁白锁死在 render.py 的单个 voice —— 每部片必然长一个样。
4
+ 本模块是**纯数据 + 纯函数**的技术底座,不提供任何外观成品。
5
+ 外观从哪来只有一条路:`work/design.json`——由 `propose.py` 按 brief 现场采样、
6
+ 用户 `--confirm` 之后冻结,再由 `designkit.design_to_plan()` 转成同形的计划字典。
6
7
 
7
8
  设计原则
8
9
  --------
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
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
- plan = ST.resolve_script_style(script) # 纯函数,返回可直接落盘的 dict
29
- th = ST.theme_of(plan["style"]) # 主题字典(元组原样,给 PIL 用)
30
- ST.theme_lint(th) # 对比度体检,返回问题列表(空 = 过)
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 --list # 列所有风格
33
- python stylekit.py --show cinema # 看一套风格的完整字段
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/style-packs.md 的示例
123
+ 为什么必须支持 hex:schemas/film.schema.json 与 references/appearance.md 的示例
88
124
  就是 {"accent": "#ff8a3d"},而 JSON 没有元组,照着文档写 hex 是必然的。
89
- v0.5 旧实现把字符串原样塞进主题,scenes.py 第一次 int("#") 就崩在渲染期
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/style-packs.md §3):
152
+ 契约(schemas/film.schema.json:只接受主题里已存在的键;references/appearance.md §3):
117
153
  - 未知键忽略:以后主题加字段时老剧本不因为它不认识就炸;
118
154
  - 颜色键(值本来是三色元组的那些)接受 [r,g,b] / "#rrggbb" / "#rgb",统一成元组;
119
155
  - 数值键接受数字或数字字符串;字符串键必须是字符串;
120
- - 嵌套结构(card / scale / qa …)不支持覆盖,静默忽略(与 v0.5 一致,避免破坏老配置);
121
- 真要改嵌套字段请改风格包。
122
- 校验只在"值类型不对、旧版会在渲染期崩"的场合抛 ValueError(提前到 --stage style)。
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)不支持覆盖。这里保持 v0.5 的**静默忽略**:
149
- # 老配置里如果写了这种覆盖,旧版只是没生效、并不会崩;升级后突然报错是破坏性变更。
150
- # 想改嵌套字段请改风格包(STYLES/THEMES),见 references/style-packs.md §7。
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
- # 四、风格包(STYLES):一个 id = 主题 + 配乐 + 旁白 + 节奏
302
+ # 四、风格表:恒为空(本技能不提供模板)
417
303
  # ============================================================================
418
304
  #
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
-
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
- VISUAL_LANGUAGES["magazine"] = {
582
- "motion": {"ease": "out5", "enter": (0.60, 1.10), "push": 0.001, "breathe_hz": 0.50},
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
- VISUAL_LANGUAGES["paper"] = {
593
- "motion": {"ease": "inOut3", "enter": (0.55, 1.00), "push": 0.03, "breathe_hz": 0.42},
594
- "motif": {"kind": "handdrawn", "density": 1.0,
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
- VISUAL_LANGUAGES["mono"] = {
617
- "motion": {"ease": "inOut3", "enter": (0.50, 0.90), "push": 0.02, "breathe_hz": 0.35},
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
- VISUAL_LANGUAGES["cinema"] = {
628
- "motion": {"ease": "inOut3", "enter": (0.70, 1.20), "push": 0.12, "breathe_hz": 0.30},
629
- "motif": {"kind": "letterbox", "density": 1.0,
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
- sid = str(style_id or "")
685
- if sid not in VISUAL_LANGUAGES:
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
- if not 0.0 <= float(tr.get("dur") or 0.0) <= 0.60:
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
- if not 1.0 <= float(ty.get("ratio") or 0.0) <= 3.6:
704
- bad.append("typography.ratio=%r 必须在 1.0~3.6(display:body 字号比)" % ty.get("ratio"))
705
- if not 0.0 <= float(ty.get("tracking") or 0.0) <= 8.0:
706
- bad.append("typography.tracking=%r 必须在 0~8px" % ty.get("tracking"))
707
- if not 1.15 <= float(ty.get("line_gap") or 0.0) <= 1.55:
708
- bad.append("typography.line_gap=%r 必须在 1.15~1.55" % ty.get("line_gap"))
709
- if not 0.0 <= float(mf.get("density") or 0.0) <= 1.0:
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
- if float(mf.get("density") or 0.0) <= 0.0 and str(mf.get("kind")) != "minimal":
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
- if (not isinstance(enter, (list, tuple)) or len(enter) != 2
715
- or not 0.0 < float(enter[0]) <= float(enter[1]) <= 1.6):
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 visual_lint_all():
724
- """全部内置风格的视觉语言体检,返回 {style_id: [问题]}(只有有问题的才在里面)。"""
725
- out = {}
726
- for sid in STYLE_IDS:
727
- bad = visual_lint(sid)
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
- # 六、解析:把 script 里所有显式覆盖 + 自动结果合成一份「最终计划」
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
- """解析整份剧本的最终风格计划(纯函数、确定性、可直接 json.dump)。
425
+ """没有设计时的**唯一失败出口**:明确报错并给两步迁移指引。
895
426
 
896
- 优先级(从高到低):
897
- 1. script["style"] 全片锁定(不是 "auto" 就生效)
898
- 2. script["visual"]["theme"] 等价于锁 style(主题与风格 1:1)
899
- 3. 自动:关键词 + 指纹
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
- # 阈值不是拍脑袋:以 v0.4 现行主题实测值当基线(tech-deep:ink 16.3 / muted 7.8 / accent 7.0,
1075
- # 那是旧版按 bg/bg2 中点量的;v0.8 改成按两端最差值量后同一主题是 14.8 / 7.1 / 6.3,仍远高于线)。
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
- 为什么不能只看中点:lo-fi 的 muted(86,100,96) 对 bg 5.28、对 bg2 只有 4.42,
1094
- 中点平均 4.84 把 AA 4.5 的门槛"平均"过去了 —— 实测过(v0.8 深审),
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 _describe(sid):
1178
- pack = STYLES[sid]
1179
- th = THEMES[pack["theme"]]
1180
- lines = [
1181
- "[%s] %s (theme=%s / layouts=%s)" % (sid, pack["name"], pack["theme"],
1182
- ",".join(pack["layouts"])),
1183
- " tags : %s" % "、".join(pack["tags"]),
1184
- " keywords : %s" % "、".join(th["keywords"]),
1185
- " music : %s 调池=%s BPM 梯度=%s mood=%s"
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(description="风格包:列出 / 查看 / 体检")
1207
- ap.add_argument("--list", action="store_true", help="列出所有风格")
1208
- ap.add_argument("--show", metavar="ID", help="查看某个风格的完整字段")
1209
- ap.add_argument("--lint", action="store_true", help="全部内置主题走对比度门禁")
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
- # 未知 id 以前是 KeyError traceback,现在给可用清单 + exit 2(与 render.py 的口径一致)
1218
- if a.show not in STYLES:
1219
- print("[未知风格] %r;可用:%s" % (a.show, " / ".join(ALL_STYLE_IDS)), file=sys.stderr)
1220
- return 2
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