@dsh-gis/dsh-video-studio-skill 0.8.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +582 -0
- package/README.md +287 -304
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/package.json +11 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +171 -15
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1316 -1066
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +1 -2
- package/skills/video-studio/assets/golden_frames.json +12 -9
- package/skills/video-studio/assets/scenes.py +2207 -1190
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/audio-variation.md +15 -11
- package/skills/video-studio/references/audio.md +36 -34
- package/skills/video-studio/references/change-impact.md +3 -3
- package/skills/video-studio/references/frame-qa.md +2 -2
- package/skills/video-studio/references/proposal.md +177 -0
- package/skills/video-studio/references/quality-gates.md +2 -2
- package/skills/video-studio/references/scene-grammar.md +311 -0
- package/skills/video-studio/references/sfx-design.md +1 -1
- package/skills/video-studio/references/style-packs.md +107 -168
- package/skills/video-studio/references/v08-quality.md +8 -8
- package/skills/video-studio/references/web-engine.md +1 -1
- package/skills/video-studio/references/word-timeline.md +2 -2
- package/skills/video-studio/scripts/designkit.py +2120 -0
- package/skills/video-studio/scripts/filmcheck.py +104 -33
- package/skills/video-studio/scripts/filmkit.py +153 -78
- package/skills/video-studio/scripts/grammar.py +619 -0
- package/skills/video-studio/scripts/musicgen.py +2043 -0
- package/skills/video-studio/scripts/preflight.py +10 -3
- package/skills/video-studio/scripts/previewkit.py +849 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +45 -10
- package/skills/video-studio/scripts/qavmaf.py +77 -11
- package/skills/video-studio/scripts/render.py +505 -142
- package/skills/video-studio/scripts/scorekit.py +50 -1748
- package/skills/video-studio/scripts/selfcheck.py +851 -115
- package/skills/video-studio/scripts/stagewatch.py +21 -5
- package/skills/video-studio/scripts/stylekit.py +293 -570
- package/skills/video-studio/scripts/voicekit.py +502 -380
- package/skills/video-studio/scripts/webengine.py +15 -14
- package/tools/audio_similarity.py +57 -30
- package/tools/ci_check.py +9 -1
- package/tools/estimate.py +14 -8
- package/tools/golden_frames.py +56 -11
- package/tools/silhouette_qa.py +1263 -0
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/skills/video-studio/assets/score_demo.json +0 -307
|
@@ -0,0 +1,2120 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""designkit.py — 设计 DSL(v1.0):把"这一版长什么样"写成可校验、可采样的 JSON。
|
|
3
|
+
|
|
4
|
+
设计原则(与 stylekit 一致的纪律)
|
|
5
|
+
----------------------------------
|
|
6
|
+
1. 纯标准库 + stylekit(stylekit 也不 import PIL / filmkit),不碰渲染。
|
|
7
|
+
contrast / theme_lint / visual_lint 的阈值只有一份,全部从 stylekit 复用,本文件不抄第二份。
|
|
8
|
+
2. 确定性:只用 sha1 派生,禁 random / time / hash()(str hash 每进程带随机盐)。
|
|
9
|
+
同一份 brief 永远采样出同一组候选。
|
|
10
|
+
3. fail-closed:设计缺字段、枚举越界、对比度不达标 -> 立刻报错,**绝不静默回落默认值**
|
|
11
|
+
(静默回落 = 所有片子又长得一样,这正是本次改造要根除的毛病)。
|
|
12
|
+
|
|
13
|
+
design.json 的形状(schema=1)
|
|
14
|
+
-----------------------------
|
|
15
|
+
{
|
|
16
|
+
"schema": 1,
|
|
17
|
+
"id": "design-1a2b3c4d", # ^[a-z0-9][a-z0-9._-]{1,63}$
|
|
18
|
+
"label": "科技深蓝·四角括线", # <= 40 字
|
|
19
|
+
"pitch": "……", # 一句话立意(中文),<= 120 字
|
|
20
|
+
"structure": {"segments": 6, "beats": [...], "hold_policy": "readable",
|
|
21
|
+
"target_sec": 45.0 | null},
|
|
22
|
+
"visual": {
|
|
23
|
+
"palette": {bg,bg2,surface,ink,muted,dim,accent,accent2,good,bad,rule}, # 11 色,[r,g,b]
|
|
24
|
+
"family": "sans|serif|kai|round",
|
|
25
|
+
"typography": {"tracking","line_gap","number","ratio","align"},
|
|
26
|
+
"motif": {"kind","density","detail"},
|
|
27
|
+
"transition": {"kind","dur"},
|
|
28
|
+
"motion": {"ease","enter","push","breathe_hz"},
|
|
29
|
+
"bias": {"x","y","inset"},
|
|
30
|
+
"layout": "left-stack|center-stack|split-right",
|
|
31
|
+
"hud": "chip-left|corner-line|none",
|
|
32
|
+
"size": "1920x1080", "fps": 30 # 可选(schema 里可选)
|
|
33
|
+
},
|
|
34
|
+
"audio": {
|
|
35
|
+
"voice": "zh-CN-YunxiNeural" 或
|
|
36
|
+
{"mode":"edge_id","id":"zh-CN-YunxiNeural",
|
|
37
|
+
"map":{kind: id},"jitter":0.2,"max_distinct":2,
|
|
38
|
+
"rate":"+0%","pitch":"+0Hz"},
|
|
39
|
+
"music": {"mode":"params","style":"tech-deep","scale":"minor",
|
|
40
|
+
"bpm":104,"key":"Dm","mood":"steady","seed":7},
|
|
41
|
+
"sfx": {"enabled":true,"density":"normal","gain_db":-6} # 可选
|
|
42
|
+
},
|
|
43
|
+
"pacing": {"lead","tail","min_dur","hold","item_stagger"},
|
|
44
|
+
"_unmatched_constraints": ["没认出来的要求"] # 采样期附加信息(不参与指纹)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
配色不是"随便取色":语义色由对比度求解得到
|
|
48
|
+
-----------------------------------------
|
|
49
|
+
实测(本轮):纯 HSV 随机取色的 theme_lint 通过率 21/60 = 35%,不可用。
|
|
50
|
+
改成"色相族 + (s,v) 网格求解,取最接近设计意图且满足对比度的色"后:60/60 = 100%。
|
|
51
|
+
网格是 s,v ∈ {0,0.1,...,1.0} 的 11×11;可行解按 |Δs|+|Δv| 取最小(并列取先遇到者,确定性)。
|
|
52
|
+
7 个语义色的对比度门槛直接取 stylekit.LINT_LIMITS(与 theme_lint 同一份)。
|
|
53
|
+
|
|
54
|
+
注意:**设计里存的是求解后的 11 个颜色**,不是配色族名——这样 design.json 是自足的,
|
|
55
|
+
换机器/换版本也不会因为"rs 里没有那个族"而漂移;配色族只用于采样期的求解过程。
|
|
56
|
+
|
|
57
|
+
对外接口
|
|
58
|
+
--------
|
|
59
|
+
validate_design(design) -> list[str] # 空 = 过;fail-closed 用
|
|
60
|
+
design_to_plan(design) -> dict # 直接喂给 scenes.draw_frame 的 plan
|
|
61
|
+
sample_designs(brief, n=3, seed=None) -> list[dict]
|
|
62
|
+
design_fingerprint(design) -> str
|
|
63
|
+
design_distance(a, b) -> dict # 用于"伪多方案"门禁
|
|
64
|
+
|
|
65
|
+
brief.must_have / avoid 是真过滤(v1.1)
|
|
66
|
+
----------------------------------------
|
|
67
|
+
词表见 _CONSTRAINT_WORDS:配色 / 母题 / 转场 / 情绪 / 声线,中文按子串认、英文按整词认。
|
|
68
|
+
must_have 是 AND(候选不许违反任何一条),avoid 是否定(违反一条就出局)。
|
|
69
|
+
认不出的那一条**不静默丢**,而是写进每个候选的 ``_unmatched_constraints``;只有"整个
|
|
70
|
+
must_have 一条都认不出"才报错——那等于用户的要求被整体忽略,继续"成功"是最坏的结果。
|
|
71
|
+
约束把某一类的选择域清空(两条互斥、或 must_have 与 avoid 撞车)时立刻报错,并在
|
|
72
|
+
报错里点名是哪一条,绝不放宽后照常返回。
|
|
73
|
+
|
|
74
|
+
CLI
|
|
75
|
+
---
|
|
76
|
+
python designkit.py --list-palettes
|
|
77
|
+
python designkit.py --sample "AI 大模型的三年" --n 3
|
|
78
|
+
python designkit.py --lint design.json
|
|
79
|
+
"""
|
|
80
|
+
from __future__ import annotations
|
|
81
|
+
|
|
82
|
+
import argparse
|
|
83
|
+
import colorsys
|
|
84
|
+
import hashlib
|
|
85
|
+
import json
|
|
86
|
+
import os
|
|
87
|
+
import re
|
|
88
|
+
import sys
|
|
89
|
+
|
|
90
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
91
|
+
if HERE not in sys.path:
|
|
92
|
+
sys.path.insert(0, HERE)
|
|
93
|
+
|
|
94
|
+
import stylekit as ST
|
|
95
|
+
|
|
96
|
+
SCHEMA = 1
|
|
97
|
+
|
|
98
|
+
# ============================================================================
|
|
99
|
+
# 一、采样域:直接复用 stylekit(不抄第二份)
|
|
100
|
+
# ============================================================================
|
|
101
|
+
|
|
102
|
+
MOTIFS = ST.VISUAL_MOTIFS
|
|
103
|
+
TRANSITIONS = ST.VISUAL_TRANSITIONS
|
|
104
|
+
EASES = ("out3", "out5", "inOut3", "outBack")
|
|
105
|
+
SCALES = ("minor", "major")
|
|
106
|
+
MOODS = ("calm", "reflective", "hopeful", "steady", "driving", "tense", "uplifting")
|
|
107
|
+
NUMBERS = ST.NUMBER_STYLES
|
|
108
|
+
LAYOUTS = tuple(ST.LAYOUT_IDS)
|
|
109
|
+
HUDS = ("chip-left", "corner-line", "none")
|
|
110
|
+
FONT_FAMILIES = ("sans", "serif", "kai", "round")
|
|
111
|
+
ALIGNS = ("left", "center", "right")
|
|
112
|
+
SIZES = ("1920x1080", "1280x720", "960x540")
|
|
113
|
+
FPSES = (24, 25, 30, 50, 60)
|
|
114
|
+
HOLD_POLICIES = ("none", "readable", "long")
|
|
115
|
+
SFX_DENSITIES = ("none", "sparse", "normal", "rich")
|
|
116
|
+
|
|
117
|
+
# 配色:**没有命名家族**(v1.0.1)。
|
|
118
|
+
#
|
|
119
|
+
# 决定这件事的依据很直白:本技能只提供"生成视频的技术",不提供模板。既然没有模板,
|
|
120
|
+
# 就不该存在"深蓝 / 近黑金 / 冷薄荷…"这样一份**可选清单** —— 有清单就意味着
|
|
121
|
+
# 用户的片子只能在 8 个预设外观里挑,第 9 种永远不存在。
|
|
122
|
+
#
|
|
123
|
+
# 现在的模型是**连续色相空间**:
|
|
124
|
+
# · 底色 = (色相 h ∈ [0,360), 饱和度 s, 明度 v) 三个连续量 -> 直接算出 bg/bg2/surface;
|
|
125
|
+
# · 其余 8 个语义色(ink/muted/dim/accent/accent2/good/bad/rule)由对比度网格求解;
|
|
126
|
+
# · 全部通过 theme_lint 才算数(对比度不足就重采,绝不悄悄放行)。
|
|
127
|
+
# 于是"第 9 种外观"不是新增模板,而是把 h 转到 200° 就自然得到的东西。
|
|
128
|
+
_PALETTE_SPACE = {
|
|
129
|
+
True: {"v": (0.030, 0.075), "s": (0.10, 0.55)}, # 暗色片:底子压很暗
|
|
130
|
+
False: {"v": (0.90, 0.985), "s": (0.02, 0.28)}, # 亮色片:纸感底子
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _palette_from_hue(hue, sat, val, dark):
|
|
135
|
+
"""(h,s,v) -> 底色三件套 bg/bg2/surface(连续量,无数值枚举)。"""
|
|
136
|
+
def hsv(h, s, v):
|
|
137
|
+
return ST.hsv_to_rgb(h % 360.0, _clamp(s, 0.0, 1.0), _clamp(v, 0.0, 1.0))
|
|
138
|
+
bg = hsv(hue, sat, val)
|
|
139
|
+
# bg2 更亮一档、surface 再亮一档:层次靠明度差,不靠换色相(换色相会变"两部片")
|
|
140
|
+
step2 = 0.055 if dark else -0.045
|
|
141
|
+
step3 = 0.100 if dark else -0.075
|
|
142
|
+
bg2 = hsv(hue + 6.0, sat * 1.10, val + step2)
|
|
143
|
+
surface = hsv(hue + 10.0, sat * 1.18, val + step3)
|
|
144
|
+
return bg, bg2, surface
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# 没有"配色族"这个概念了(v1.0.1)。这个空 dict 只是为了让老调用点
|
|
148
|
+
# (`for name in PALETTE_FAMILIES` 之类)不 AttributeError —— 它必须保持为空:
|
|
149
|
+
# 一旦有人往里加东西,就等于偷偷恢复了一份"可选外观清单"。
|
|
150
|
+
PALETTE_FAMILIES = {}
|
|
151
|
+
|
|
152
|
+
# 但 brief 里仍然会有人说"要深蓝 / 近黑 / 薄荷"。这些词要能生效,否则用户的
|
|
153
|
+
# 自然语言要求就落空了(那正是 v1.0 的毛病)。
|
|
154
|
+
# 做法:词 -> **色相锚点 + 明暗**,不是"族名 -> 一套现成外观"。锚点只是采样的起点,
|
|
155
|
+
# 色相仍然是连续量(可在锚点附近 ±HUE_WINDOW 内浮动)。
|
|
156
|
+
_PALETTE_ANCHOR = {
|
|
157
|
+
"deep-blue": (210.0, True),
|
|
158
|
+
"near-black": (240.0, True),
|
|
159
|
+
"cool-mint": (168.0, True),
|
|
160
|
+
"sand": (36.0, True),
|
|
161
|
+
"slate": (215.0, True),
|
|
162
|
+
"forest": (150.0, True),
|
|
163
|
+
"warm-paper": (38.0, False),
|
|
164
|
+
"ink-paper": (60.0, False),
|
|
165
|
+
}
|
|
166
|
+
_HUE_WINDOW = 26.0 # 锚点附近 ±26°(再宽就不像"深蓝"了,再窄等于恢复成枚举)
|
|
167
|
+
# 多个候选之间的"底色必须看得出不一样"的色相最小间距(度)。
|
|
168
|
+
# 为什么是 12° 而不是"配色族":v1.0.1 删掉风格表后没有族可枚举;12° 是并排看图时
|
|
169
|
+
# 人眼能稳定分辨的色相下限附近(再小就成了同一片底色的细微偏色)。约束收窄时
|
|
170
|
+
# 可选色相窗口只有 52° 宽,12° 间距仍能放下 3–4 个候选,不会像"24° 分桶"那样
|
|
171
|
+
# 在 n=3 时大半概率采样失败。
|
|
172
|
+
_PALETTE_KEEP_GAP = 12.0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _hue_gap(a, b):
|
|
176
|
+
"""两个色相在色环上的最短距离(0..180)。色相是环形的:350° 与 10° 只差 20°。"""
|
|
177
|
+
d = abs(float(a) - float(b)) % 360.0
|
|
178
|
+
return d if d <= 180.0 else 360.0 - d
|
|
179
|
+
|
|
180
|
+
# 11 个颜色字段(theme_lint 检的就是这些;顺序固定,便于审计)
|
|
181
|
+
PALETTE_KEYS = ("bg", "bg2", "surface", "ink", "muted", "dim",
|
|
182
|
+
"accent", "accent2", "good", "bad", "rule")
|
|
183
|
+
|
|
184
|
+
# 设计意图 (s0, v0):求解器在可行域里找离它最近的网格点。
|
|
185
|
+
# 暗色族要"亮而不刺眼"的文字、明色族要"深而不死黑"的文字;accent 两族都要求高饱和。
|
|
186
|
+
_ROLE_INTENT = {
|
|
187
|
+
True: {"ink": (0.08, 0.94), "muted": (0.22, 0.72), "dim": (0.24, 0.56),
|
|
188
|
+
"accent": (0.86, 0.94), "accent2": (0.58, 0.90)},
|
|
189
|
+
False: {"ink": (0.18, 0.16), "muted": (0.16, 0.36), "dim": (0.12, 0.52),
|
|
190
|
+
"accent": (0.86, 0.56), "accent2": (0.58, 0.50)},
|
|
191
|
+
}
|
|
192
|
+
_ROLE_HUE = {"ink": 0, "muted": 0, "dim": 0, "accent": 0, "accent2": 150}
|
|
193
|
+
_GOOD_HUE = 135.0
|
|
194
|
+
_BAD_HUE = 5.0
|
|
195
|
+
|
|
196
|
+
# 情绪 -> BPM / 层门限。注意这里**没有"情绪 -> 曲风"的映射**:
|
|
197
|
+
# v1.0 曾有一张 _MUSIC_BY_MOOD 把 7 种情绪映射到 7 套 scorekit 风格表,
|
|
198
|
+
# 那是把"配乐模板"伪装成参数。v1.0.1 起配乐由 musicgen 现场编排,
|
|
199
|
+
# 情绪只决定 BPM 与织体门限,不再指向任何现成风格。
|
|
200
|
+
_MOOD_BPM = {
|
|
201
|
+
"calm": 78, "reflective": 86, "hopeful": 92, "steady": 104,
|
|
202
|
+
"driving": 124, "tense": 98, "uplifting": 118,
|
|
203
|
+
}
|
|
204
|
+
_MOOD_WORD = {
|
|
205
|
+
"calm": "克制留白", "reflective": "回望与沉淀", "hopeful": "向上的暖意",
|
|
206
|
+
"steady": "稳稳推进", "driving": "强驱动节奏", "tense": "绷紧的悬念",
|
|
207
|
+
"uplifting": "昂扬收束",
|
|
208
|
+
}
|
|
209
|
+
_MOTIF_WORD = {
|
|
210
|
+
"brackets": "四角括线", "hairlines": "细线页眉", "handdrawn": "手绘内框",
|
|
211
|
+
"slabs": "粗色块", "minimal": "极简留白", "letterbox": "电影遮幅",
|
|
212
|
+
"signal": "信号条", "rays": "放射线",
|
|
213
|
+
}
|
|
214
|
+
_TRANS_WORD = {
|
|
215
|
+
"dip": "叠化", "wash": "淡入", "paper": "纸卷",
|
|
216
|
+
"wipe": "擦入", "cut": "硬切", "iris": "光圈",
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
# 声线池:全部是可被 static 校验的 zh-CN-XxxNeural(本模块不 import voicekit——
|
|
220
|
+
# 那会拉起 edge_tts。格式只做正则,不做"这个 id 是否真的存在"的联网校验)。
|
|
221
|
+
#
|
|
222
|
+
# v1.0 / P4:**只从"有响度补偿数据的音色"里采样**。
|
|
223
|
+
# 为什么:voicekit 的响度补偿是一张按 voice id 的数据表(VOICE_LOUDNESS / assets/voice_loudness.json);
|
|
224
|
+
# 采样器如果产出表里没有的音色,那行的 gain_db 会回落 0 —— 等于"这个候选的旁白电平没校准"。
|
|
225
|
+
# 实测(改造前):5 个题材 × 3 候选共产出 7 个音色,其中 **4 个没有补偿数据**。
|
|
226
|
+
# 两处各存一份清单必然漂移,所以这里直接读共享 JSON(不 import voicekit)。
|
|
227
|
+
_VOICE_LOUDNESS_PATH = os.path.normpath(
|
|
228
|
+
os.path.join(HERE, os.pardir, "assets", "voice_loudness.json"))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _loudness_voices():
|
|
232
|
+
"""读共享响度表里的音色(读不到就返回空,由调用方兜底到内置池)。"""
|
|
233
|
+
try:
|
|
234
|
+
with open(_VOICE_LOUDNESS_PATH, encoding="utf-8") as fh:
|
|
235
|
+
data = json.load(fh)
|
|
236
|
+
voices = tuple(sorted(str(k) for k in (data.get("voices") or {})))
|
|
237
|
+
return tuple(v for v in voices if _VOICE_RE.match(v))
|
|
238
|
+
except (OSError, json.JSONDecodeError):
|
|
239
|
+
return ()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_FALLBACK_VOICES = (
|
|
243
|
+
"zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural",
|
|
244
|
+
"zh-CN-YunjianNeural", "zh-CN-YunyangNeural",
|
|
245
|
+
)
|
|
246
|
+
# 只认三地中文(CN/TW/HK):实测 edge-tts 免费端点的 zh 音色就这三类,
|
|
247
|
+
# 而响度补偿表里确实有 zh-TW-HsiaoChenNeural(软糯女声)——只写 zh-CN 会把它判非法。
|
|
248
|
+
_VOICE_RE = re.compile(r"^zh-(?:CN|TW|HK)-[A-Z][A-Za-z]*Neural$")
|
|
249
|
+
VOICES = _loudness_voices() or _FALLBACK_VOICES
|
|
250
|
+
VOICE_KINDS = ("hook", "body", "quote", "outro")
|
|
251
|
+
|
|
252
|
+
# 骨架几何按母题给(母题决定"框长什么样",卡片只是它的容器)
|
|
253
|
+
_CARD_BY_MOTIF = {
|
|
254
|
+
"brackets": dict(radius=14, fill_a=16, outline_a=28, pad=34, h=96, gap=118),
|
|
255
|
+
"hairlines": dict(radius=8, fill_a=26, outline_a=40, pad=30, h=92, gap=112),
|
|
256
|
+
"handdrawn": dict(radius=4, fill_a=30, outline_a=52, pad=32, h=94, gap=116),
|
|
257
|
+
"slabs": dict(radius=22, fill_a=30, outline_a=56, pad=32, h=100, gap=122),
|
|
258
|
+
"minimal": dict(radius=0, fill_a=0, outline_a=70, pad=28, h=90, gap=108),
|
|
259
|
+
"letterbox": dict(radius=10, fill_a=20, outline_a=32, pad=36, h=88, gap=124),
|
|
260
|
+
"signal": dict(radius=12, fill_a=22, outline_a=36, pad=30, h=92, gap=114),
|
|
261
|
+
"rays": dict(radius=18, fill_a=18, outline_a=46, pad=34, h=96, gap=126),
|
|
262
|
+
}
|
|
263
|
+
_TEXTURE_BY_MOTIF = {
|
|
264
|
+
"brackets": "dots", "hairlines": "paper", "handdrawn": "paper",
|
|
265
|
+
"slabs": "grid", "minimal": "none", "letterbox": "none",
|
|
266
|
+
"signal": "scanlines", "rays": "none",
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# 采样用的离散档位(取整避免浮点尾差,JSON 才好逐字节比对)
|
|
270
|
+
_DENSITIES = (0.55, 0.70, 0.85, 1.00)
|
|
271
|
+
_PUSHES = (0.02, 0.04, 0.06, 0.08, 0.10, 0.12, 0.14)
|
|
272
|
+
_BREATHES = (0.30, 0.40, 0.50, 0.60, 0.75, 0.90, 1.05)
|
|
273
|
+
_ENTERS = ((0.35, 0.75), (0.40, 0.90), (0.45, 0.85), (0.50, 1.00),
|
|
274
|
+
(0.55, 1.00), (0.60, 1.10), (0.70, 1.20))
|
|
275
|
+
_XS = (-150, -90, -60, -30, 0, 30, 60, 90, 150)
|
|
276
|
+
_YS = (-90, -60, -30, 0, 30, 60, 90)
|
|
277
|
+
_INSETS = (0, 20, 40, 60)
|
|
278
|
+
|
|
279
|
+
# 校验用的键白名单(schema 的 additionalProperties=false 的同义实现)
|
|
280
|
+
# _unmatched_constraints 是采样期附带的"这条要求没生效"的记录(见 _resolve_constraints):
|
|
281
|
+
# 不参与指纹、不参与渲染,但必须被契约接纳,否则采样器吐出的设计自己就过不了校验。
|
|
282
|
+
_TOP_KEYS = ("schema", "id", "label", "pitch", "structure", "visual", "audio", "pacing",
|
|
283
|
+
"_unmatched_constraints")
|
|
284
|
+
_STRUCT_KEYS = ("segments", "beats", "hold_policy", "target_sec")
|
|
285
|
+
# 主题字典的字段清单(与 stylekit._theme 构造器的产出严格一致)。
|
|
286
|
+
# 从构造器现场造一份样板再取键,而不是手抄一份常量 —— 手抄的清单会和构造器漂移。
|
|
287
|
+
def _theme_field_keys():
|
|
288
|
+
"""主题字段清单:从 _theme 构造器现场造一份样板再取键。
|
|
289
|
+
|
|
290
|
+
为什么不再从 ST.THEMES[某套内置主题] 取:v1.0.1 删光了内置主题,没有"参照物"了。
|
|
291
|
+
也不手抄一份常量 —— 手抄的清单一定会在某次改构造器时漂移,
|
|
292
|
+
然后表现成"某个颜色字段取不到"这种半夜才能查出来的 KeyError。
|
|
293
|
+
"""
|
|
294
|
+
pal = {"bg": (0, 0, 0), "bg2": (16, 16, 16), "surface": (32, 32, 32),
|
|
295
|
+
"ink": (240, 240, 240), "muted": (180, 180, 180), "dim": (120, 120, 120),
|
|
296
|
+
"accent": (200, 160, 60), "accent2": (80, 140, 220), "good": (60, 180, 110),
|
|
297
|
+
"bad": (220, 70, 70), "rule": (60, 60, 60)}
|
|
298
|
+
mat = {"vignette": 0.2, "vignette_tint": (0, 0, 0), "grain": 0.02,
|
|
299
|
+
"grain_freq": 1.2, "texture": "none", "texture_alpha": 0.0}
|
|
300
|
+
typ = {"family": "sans", "scale": {}, "line_gap": 1.3, "tracking": 0.0}
|
|
301
|
+
lay = {"layout": "left-stack", "card": {}, "safe_bias": 0}
|
|
302
|
+
mo = {"ease": "out3", "entrance_dx": 0, "entrance_dy": 30,
|
|
303
|
+
"push": 0.04, "item_stagger": 0.3, "transition": "dip"}
|
|
304
|
+
hud = {"style": "chip-left"}
|
|
305
|
+
probe = ST._theme("probe", "探针", (), pal, mat, typ, lay, mo, hud,
|
|
306
|
+
{"luma_max": 0.045})
|
|
307
|
+
return tuple(probe.keys())
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# 模块加载时立刻算出来:它只是把构造器跑一遍取键名,没有副作用。
|
|
311
|
+
# (曾经写成空元组占位、忘了回填,于是所有 design 都报"主题字段集合不一致"——
|
|
312
|
+
# 断言本身是对的,错在参照物是空的。)
|
|
313
|
+
_THEME_FIELD_KEYS = _theme_field_keys()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
_VISUAL_KEYS = ("palette", "family", "typography", "motif", "transition", "motion",
|
|
317
|
+
"bias", "layout", "layout_cycle", "hud", "size", "fps", "subtitle")
|
|
318
|
+
# 字幕参数(v1.0.2):以前字幕字号 / 位置 / 背板 / 圆角全写死在 filmkit.draw_subtitle,
|
|
319
|
+
# 于是**任何两条片子**的字幕带都一模一样 —— 恰好是用户原则 5 说的"大面积长时间重复"。
|
|
320
|
+
# 现在这些参数进设计 DSL(可选,缺省 = 过去的行为,老 design.json 逐像素不变)。
|
|
321
|
+
_SUBTITLE_DEFAULTS = {
|
|
322
|
+
"size": 46, "y": None, "align": "center", "plate_alpha": 170,
|
|
323
|
+
"radius": 10, "family": None, "color": None, "max_lines": 2,
|
|
324
|
+
}
|
|
325
|
+
_SUBTITLE_KEYS = tuple(_SUBTITLE_DEFAULTS.keys())
|
|
326
|
+
_SUBTITLE_ALIGNS = ("center", "left")
|
|
327
|
+
_FONT_FAMILIES_FOR_SUB = ("sans", "serif", "kai", "round")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def subtitle_of(design):
|
|
331
|
+
"""design -> 补齐默认值后的字幕参数(渲染侧只需读这个,不要各写一份默认值)。"""
|
|
332
|
+
out = dict(_SUBTITLE_DEFAULTS)
|
|
333
|
+
vis = design.get("visual") if isinstance(design, dict) else None
|
|
334
|
+
sub = vis.get("subtitle") if isinstance(vis, dict) else None
|
|
335
|
+
if isinstance(sub, dict):
|
|
336
|
+
for k in _SUBTITLE_KEYS:
|
|
337
|
+
if k in sub and sub[k] is not None:
|
|
338
|
+
out[k] = sub[k]
|
|
339
|
+
return out
|
|
340
|
+
_TYPO_KEYS = ("tracking", "line_gap", "number", "ratio", "align")
|
|
341
|
+
_MOTIF_KEYS = ("kind", "density", "detail")
|
|
342
|
+
_TRANS_KEYS = ("kind", "dur")
|
|
343
|
+
_MOTION_KEYS = ("ease", "enter", "push", "breathe_hz")
|
|
344
|
+
_BIAS_KEYS = ("x", "y", "inset")
|
|
345
|
+
_AUDIO_KEYS = ("voice", "music", "sfx")
|
|
346
|
+
_VOICE_KEYS = ("mode", "id", "rate", "pitch", "map", "jitter", "max_distinct")
|
|
347
|
+
_MUSIC_KEYS = ("mode", "arranger", "style", "scale", "bpm", "key", "mood", "seed",
|
|
348
|
+
"gain_db", "duck_db", "sections", "file")
|
|
349
|
+
_SFX_KEYS = ("enabled", "density", "gain_db", "cues")
|
|
350
|
+
_PACING_KEYS = ("lead", "tail", "min_dur", "hold", "item_stagger")
|
|
351
|
+
_BRIEF_KEYS = ("topic", "duration_target", "must_have", "avoid")
|
|
352
|
+
|
|
353
|
+
_MAX_RESAMPLE = 50
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ============================================================================
|
|
357
|
+
# 二、小工具:确定性哈希 / 颜色 / 类型
|
|
358
|
+
# ============================================================================
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _digest(*parts):
|
|
362
|
+
"""sha1 派生的稳定 48 位整数(禁 hash():str hash 每次进程带随机盐)。"""
|
|
363
|
+
raw = "|".join(str(p) for p in parts).encode("utf-8")
|
|
364
|
+
return int(hashlib.sha1(raw).hexdigest()[:12], 16)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _canonical(obj):
|
|
368
|
+
"""规范 JSON:排序键 + 紧凑分隔符;同一对象永远同一串(指纹与采样共用)。"""
|
|
369
|
+
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _is_num(v):
|
|
373
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _is_int(v):
|
|
377
|
+
return isinstance(v, int) and not isinstance(v, bool)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _clamp(v, lo, hi):
|
|
381
|
+
return lo if v < lo else (hi if v > hi else v)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _hsv(hue_deg, s, v):
|
|
385
|
+
"""HSV -> RGB 元组;色相用角度(0~360),s/v 用 0~1。"""
|
|
386
|
+
r, g, b = colorsys.hsv_to_rgb((float(hue_deg) % 360.0) / 360.0,
|
|
387
|
+
_clamp(float(s), 0.0, 1.0),
|
|
388
|
+
_clamp(float(v), 0.0, 1.0))
|
|
389
|
+
return (int(round(r * 255.0)), int(round(g * 255.0)), int(round(b * 255.0)))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _hex(rgb):
|
|
393
|
+
"""(r,g,b) -> "#rrggbb"(只用于展示/距离描述)。"""
|
|
394
|
+
try:
|
|
395
|
+
t = ST._color_of(rgb, "display")
|
|
396
|
+
except Exception: # noqa: BLE001 - 展示用,坏值返回空串
|
|
397
|
+
return ""
|
|
398
|
+
return "#%02x%02x%02x" % t
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _slug(text):
|
|
402
|
+
s = re.sub(r"[^a-z0-9._-]+", "-", str(text or "").lower()).strip("-")
|
|
403
|
+
return s or "design"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ============================================================================
|
|
407
|
+
# 三、配色求解:色相族 + (s,v) 网格(本轮实测唯一 100% 通过的做法)
|
|
408
|
+
# ============================================================================
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _solve_role(hue, s0, v0, need, refs):
|
|
412
|
+
"""在 s,v ∈ {0,0.1,...,1.0} 里找"对 refs 都 >= need"且最接近 (s0,v0) 的颜色。
|
|
413
|
+
|
|
414
|
+
为什么是网格而不是二分:对比度对 s、v 并非单维单调(v 到 0/1 两端都能拉高对比),
|
|
415
|
+
单维二分只能找到一个可行点,找不到**最贴近设计意图**的那个。11×11 网格足够密,
|
|
416
|
+
且并列时取先遇到者(s 升序、v 升序),结果确定。
|
|
417
|
+
"""
|
|
418
|
+
best, best_d = None, None
|
|
419
|
+
for si in range(11):
|
|
420
|
+
s = si / 10.0
|
|
421
|
+
for vi in range(11):
|
|
422
|
+
v = vi / 10.0
|
|
423
|
+
c = _hsv(hue, s, v)
|
|
424
|
+
if min(ST.contrast(c, r) for r in refs) < need:
|
|
425
|
+
continue
|
|
426
|
+
d = abs(s - s0) + abs(v - v0)
|
|
427
|
+
if best is None or d < best_d - 1e-12:
|
|
428
|
+
best, best_d = c, d
|
|
429
|
+
return best
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _solve_palette(family, hue_shift=0.0, target_shift=0.0):
|
|
433
|
+
"""一个配色族 -> 11 个颜色(语义色全部求解;无解直接报错,不回落)。
|
|
434
|
+
|
|
435
|
+
hue_shift / target_shift 是采样器给同族不同候选留的小扰动(确定性,幅度很小),
|
|
436
|
+
让"同族不同设计"也有色相差异;扰动不会改变可行性(网格里始终有 v=0/v=1 的端点)。
|
|
437
|
+
"""
|
|
438
|
+
# 只接受 (色相, 饱和度, 明度, 暗色)。v1.0.1 之前还接受 8 个命名族("deep-blue" 之类),
|
|
439
|
+
# 那条兼容分支已经删除:design.json 里存的是**算好的 RGB**,从不存族名,
|
|
440
|
+
# 所以"老 design 带族名"这件事根本不存在——留着就是一个永远走不到、只能误导人的分支。
|
|
441
|
+
if not isinstance(family, (tuple, list)) or len(family) < 4:
|
|
442
|
+
raise ValueError("配色必须是 (色相, 饱和度, 明度, 暗色) 四元组;收到 %r" % (family,))
|
|
443
|
+
hue, sat, val = float(family[0]), float(family[1]), float(family[2])
|
|
444
|
+
dark = bool(family[3])
|
|
445
|
+
bg, bg2, surface = _palette_from_hue(hue, sat, val, dark)
|
|
446
|
+
refs = (bg, bg2)
|
|
447
|
+
colors = {"bg": bg, "bg2": bg2, "surface": surface}
|
|
448
|
+
lost = []
|
|
449
|
+
for role in ("ink", "muted", "dim", "accent", "accent2"):
|
|
450
|
+
s0, v0 = _ROLE_INTENT[dark][role]
|
|
451
|
+
c = _solve_role(hue + _ROLE_HUE[role] + hue_shift,
|
|
452
|
+
_clamp(s0 + target_shift, 0.0, 1.0),
|
|
453
|
+
_clamp(v0 - target_shift, 0.0, 1.0),
|
|
454
|
+
ST.LINT_LIMITS[role], refs)
|
|
455
|
+
if c is None:
|
|
456
|
+
lost.append(role)
|
|
457
|
+
else:
|
|
458
|
+
colors[role] = c
|
|
459
|
+
for role, hue in (("good", _GOOD_HUE), ("bad", _BAD_HUE)):
|
|
460
|
+
c = _solve_role(hue, 0.62, 0.66, ST.LINT_LIMITS[role], (surface,))
|
|
461
|
+
if c is None:
|
|
462
|
+
lost.append(role)
|
|
463
|
+
else:
|
|
464
|
+
colors[role] = c
|
|
465
|
+
if lost:
|
|
466
|
+
raise ValueError("配色 (hue=%.1f sat=%.2f val=%.3f dark=%s) 下这些语义色无解"
|
|
467
|
+
"(对比度不可达):%s" % (hue, sat, val, dark, "、".join(lost)))
|
|
468
|
+
colors["rule"] = (255, 255, 255) if dark else (16, 16, 16)
|
|
469
|
+
return {k: colors[k] for k in PALETTE_KEYS}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ============================================================================
|
|
473
|
+
# 四、design -> theme / language(35 字段主题;五组视觉签名)
|
|
474
|
+
# ============================================================================
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _font_scale(ratio):
|
|
478
|
+
"""字号盘:display:body = ratio(visual_lint 查的就是这个比值),其余槽位固定。
|
|
479
|
+
|
|
480
|
+
基准 body=34 是本模块的画布假设;ratio 是设计变量,真正决定大标题有多大。
|
|
481
|
+
"""
|
|
482
|
+
body = 34
|
|
483
|
+
return {
|
|
484
|
+
"display": (max(20, int(round(body * float(ratio)))), "bold"),
|
|
485
|
+
"heading": (64, "bold"),
|
|
486
|
+
"sub": (38, "light"),
|
|
487
|
+
"body": (body, "reg"),
|
|
488
|
+
"kicker": (28, "bold"),
|
|
489
|
+
"chip": (27, "reg"),
|
|
490
|
+
"brand": (23, "reg"),
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _card_for(motif_kind):
|
|
495
|
+
got = _CARD_BY_MOTIF.get(str(motif_kind))
|
|
496
|
+
if got is None:
|
|
497
|
+
raise ValueError("母题 %r 没有卡片几何(designkit 的 _CARD_BY_MOTIF 要跟着 "
|
|
498
|
+
"stylekit.VISUAL_MOTIFS 更新)" % (motif_kind,))
|
|
499
|
+
return dict(got)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _safe_bias(bias):
|
|
503
|
+
# 主题的 safe_bias 是题目安全区的整体偏移;把它挂到设计重心上,避免两处各写一份。
|
|
504
|
+
return int(_clamp(round(float(bias["x"]) * 0.12), -28, 28))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _theme_id(design_id):
|
|
508
|
+
# 设计 id 已经是 "design-xxxxxxxx";这里只做字符规整,不再叠前缀。
|
|
509
|
+
return _slug(design_id)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _theme_of_design(design):
|
|
513
|
+
"""把 design.visual 展开成 35 字段主题(颜色是 (r,g,b) 元组)。
|
|
514
|
+
|
|
515
|
+
直接调 stylekit._theme:字段集合/默认口径只有一份,本文件不手搓 35 个键。
|
|
516
|
+
随后校验"字段集合与 stylekit 完全一致",防止 stylekit 加字段后这里悄悄少一个。
|
|
517
|
+
"""
|
|
518
|
+
vis = design["visual"]
|
|
519
|
+
pal = vis["palette"]
|
|
520
|
+
colors = {k: ST._color_of(pal[k], "visual.palette." + k) for k in PALETTE_KEYS}
|
|
521
|
+
dark = ST.is_dark({"bg": colors["bg"]})
|
|
522
|
+
if dark:
|
|
523
|
+
material = dict(vignette=0.42, vignette_tint=(0, 0, 0),
|
|
524
|
+
grain=5.0, grain_freq=0.31,
|
|
525
|
+
texture=_TEXTURE_BY_MOTIF[vis["motif"]["kind"]],
|
|
526
|
+
texture_alpha=(0.0 if _TEXTURE_BY_MOTIF[vis["motif"]["kind"]] == "none"
|
|
527
|
+
else 0.045))
|
|
528
|
+
else:
|
|
529
|
+
material = dict(vignette=0.16,
|
|
530
|
+
vignette_tint=tuple(int(round(c * 0.86)) for c in colors["bg2"]),
|
|
531
|
+
grain=3.0, grain_freq=0.27,
|
|
532
|
+
texture=_TEXTURE_BY_MOTIF[vis["motif"]["kind"]],
|
|
533
|
+
texture_alpha=(0.0 if _TEXTURE_BY_MOTIF[vis["motif"]["kind"]] == "none"
|
|
534
|
+
else 0.030))
|
|
535
|
+
qa = dict(luma_max=(0.030 if ST.luminance(colors["bg"]) < 0.02 else 0.045),
|
|
536
|
+
flicker_thresh=6.0, dark=bool(dark))
|
|
537
|
+
mo = vis["motion"]
|
|
538
|
+
th = ST._theme(
|
|
539
|
+
_theme_id(design["id"]),
|
|
540
|
+
str(design["label"]),
|
|
541
|
+
(),
|
|
542
|
+
colors,
|
|
543
|
+
material,
|
|
544
|
+
dict(family=vis["family"], scale=_font_scale(vis["typography"]["ratio"]),
|
|
545
|
+
line_gap=float(vis["typography"]["line_gap"]), tracking=0.0),
|
|
546
|
+
dict(layout=vis["layout"], card=_card_for(vis["motif"]["kind"]),
|
|
547
|
+
safe_bias=_safe_bias(vis["bias"])),
|
|
548
|
+
dict(ease=mo["ease"],
|
|
549
|
+
entrance_dx=0,
|
|
550
|
+
entrance_dy=int(round(14.0 + float(mo["push"]) * 100.0)),
|
|
551
|
+
push=float(mo["push"]),
|
|
552
|
+
item_stagger=float(design["pacing"]["item_stagger"]),
|
|
553
|
+
transition=vis["transition"]["kind"]),
|
|
554
|
+
dict(style=vis["hud"]),
|
|
555
|
+
qa,
|
|
556
|
+
)
|
|
557
|
+
# 主题的字段清单:v1.0.1 起没有内置主题可参照,改成从构造器现场造一份样板取键。
|
|
558
|
+
# 为什么还要求"字段完全一致":scenes.py 按固定键名取色,少一个键就是 KeyError,
|
|
559
|
+
# 多一个键则说明构造器与消费端脱节 —— 两边必须严丝合缝。
|
|
560
|
+
ref = set(_THEME_FIELD_KEYS)
|
|
561
|
+
if set(th.keys()) != ref:
|
|
562
|
+
raise ValueError("主题字段集合与 stylekit 不一致:缺 %s / 多 %s"
|
|
563
|
+
% (sorted(ref - set(th.keys())), sorted(set(th.keys()) - ref)))
|
|
564
|
+
return th
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _language_of(design):
|
|
568
|
+
"""design -> 五组视觉签名(motion / motif / transition / typography / bias)。
|
|
569
|
+
|
|
570
|
+
与 stylekit.VISUAL_LANGUAGES 同形;scenes.py 直接读这五组(layout_cycle 没有消费者)。
|
|
571
|
+
所有字段都直接下标:缺字段会抛 KeyError,由 validate_design 转成一条问题,
|
|
572
|
+
绝不在这里塞默认值。
|
|
573
|
+
"""
|
|
574
|
+
vis = design["visual"]
|
|
575
|
+
ty = vis["typography"]
|
|
576
|
+
mo = vis["motion"]
|
|
577
|
+
return {
|
|
578
|
+
"motion": {"ease": mo["ease"],
|
|
579
|
+
"enter": (float(mo["enter"][0]), float(mo["enter"][1])),
|
|
580
|
+
"push": float(mo["push"]),
|
|
581
|
+
"breathe_hz": float(mo["breathe_hz"])},
|
|
582
|
+
"motif": {"kind": vis["motif"]["kind"],
|
|
583
|
+
"density": float(vis["motif"]["density"]),
|
|
584
|
+
"detail": dict(vis["motif"]["detail"])},
|
|
585
|
+
"transition": {"kind": vis["transition"]["kind"],
|
|
586
|
+
"dur": float(vis["transition"]["dur"])},
|
|
587
|
+
"typography": {"tracking": float(ty["tracking"]),
|
|
588
|
+
"line_gap": float(ty["line_gap"]),
|
|
589
|
+
"align": ty["align"],
|
|
590
|
+
"number": ty["number"],
|
|
591
|
+
"ratio": float(ty["ratio"])},
|
|
592
|
+
"bias": {"x": int(vis["bias"]["x"]),
|
|
593
|
+
"y": int(vis["bias"]["y"]),
|
|
594
|
+
"inset": int(vis["bias"]["inset"])},
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
_VISUAL_PROBE_ID = "__designkit_probe__"
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _visual_lint_language(lang):
|
|
602
|
+
"""对任意 language 字典做体检(v1.0.1:直接调纯函数,不再往 stylekit 的表里临时注册)。"""
|
|
603
|
+
return ST.visual_lint_language(lang)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# ============================================================================
|
|
607
|
+
# 五、校验(fail-closed:缺字段/越界/对比度不达标全部变成问题)
|
|
608
|
+
# ============================================================================
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _need(obj, keys, path, bad):
|
|
612
|
+
for k in keys:
|
|
613
|
+
if k not in obj:
|
|
614
|
+
bad.append("%s 缺必填字段 %r(fail-closed:不会取默认值)" % (path, k))
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _only(obj, allowed, path, bad):
|
|
618
|
+
for k in sorted(obj, key=str):
|
|
619
|
+
if k not in allowed:
|
|
620
|
+
bad.append("%s 有未知字段 %r(设计契约不认这个键)" % (path, k))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _num(v, path, bad, lo=None, hi=None):
|
|
624
|
+
if not _is_num(v):
|
|
625
|
+
bad.append("%s 必须是数字,收到 %r" % (path, v))
|
|
626
|
+
return False
|
|
627
|
+
if lo is not None and float(v) < lo:
|
|
628
|
+
bad.append("%s=%r 不能小于 %s" % (path, v, lo))
|
|
629
|
+
return False
|
|
630
|
+
if hi is not None and float(v) > hi:
|
|
631
|
+
bad.append("%s=%r 不能大于 %s" % (path, v, hi))
|
|
632
|
+
return False
|
|
633
|
+
return True
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _int_of(v, path, bad, lo=None, hi=None):
|
|
637
|
+
if not _is_int(v):
|
|
638
|
+
bad.append("%s 必须是整数,收到 %r" % (path, v))
|
|
639
|
+
return False
|
|
640
|
+
if lo is not None and v < lo:
|
|
641
|
+
bad.append("%s=%r 不能小于 %s" % (path, v, lo))
|
|
642
|
+
return False
|
|
643
|
+
if hi is not None and v > hi:
|
|
644
|
+
bad.append("%s=%r 不能大于 %s" % (path, v, hi))
|
|
645
|
+
return False
|
|
646
|
+
return True
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _enum_of(v, allowed, path, bad):
|
|
650
|
+
if v not in allowed:
|
|
651
|
+
bad.append("%s=%r 不在 %s 里" % (path, v, " / ".join(allowed)))
|
|
652
|
+
return False
|
|
653
|
+
return True
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _validate_voice(vo, bad):
|
|
657
|
+
"""voice 允许两种形态:裸 edge id 字符串,或 {mode,id,map,jitter,max_distinct}。"""
|
|
658
|
+
if isinstance(vo, str):
|
|
659
|
+
if not _VOICE_RE.match(vo):
|
|
660
|
+
bad.append("audio.voice=%r 不是 edge voice id(必须匹配 zh-CN-XxxNeural)" % (vo,))
|
|
661
|
+
return
|
|
662
|
+
if not isinstance(vo, dict):
|
|
663
|
+
bad.append("audio.voice 必须是字符串或 dict,收到 %r" % (type(vo).__name__,))
|
|
664
|
+
return
|
|
665
|
+
_only(vo, _VOICE_KEYS, "audio.voice", bad)
|
|
666
|
+
if "mode" not in vo:
|
|
667
|
+
bad.append("audio.voice 缺必填字段 'mode'(fail-closed:不会猜 edge_id/wav)")
|
|
668
|
+
return
|
|
669
|
+
mode = vo.get("mode")
|
|
670
|
+
if mode not in ("edge_id", "wav"):
|
|
671
|
+
bad.append("audio.voice.mode=%r 必须是 edge_id / wav" % (mode,))
|
|
672
|
+
return
|
|
673
|
+
vid = vo.get("id")
|
|
674
|
+
if not isinstance(vid, str) or not vid.strip():
|
|
675
|
+
bad.append("audio.voice.id 必须是非空字符串")
|
|
676
|
+
elif mode == "edge_id" and not _VOICE_RE.match(vid):
|
|
677
|
+
bad.append("audio.voice.id=%r 不是 edge voice id(必须匹配 zh-CN-XxxNeural)" % (vid,))
|
|
678
|
+
if mode == "edge_id":
|
|
679
|
+
_need(vo, ("map", "jitter", "max_distinct"), "audio.voice", bad)
|
|
680
|
+
if "map" in vo:
|
|
681
|
+
mp = vo["map"]
|
|
682
|
+
if not isinstance(mp, dict):
|
|
683
|
+
bad.append("audio.voice.map 必须是 dict")
|
|
684
|
+
else:
|
|
685
|
+
for k in sorted(mp, key=str):
|
|
686
|
+
v = mp[k]
|
|
687
|
+
if isinstance(v, str):
|
|
688
|
+
if not _VOICE_RE.match(v):
|
|
689
|
+
bad.append("audio.voice.map[%r]=%r 不是 edge voice id" % (k, v))
|
|
690
|
+
elif isinstance(v, dict):
|
|
691
|
+
_only(v, ("mode", "id"), "audio.voice.map.%s" % k, bad)
|
|
692
|
+
if v.get("mode") not in ("edge_id", "wav"):
|
|
693
|
+
bad.append("audio.voice.map[%r].mode 必须是 edge_id / wav" % (k,))
|
|
694
|
+
if not isinstance(v.get("id"), str) or not v.get("id"):
|
|
695
|
+
bad.append("audio.voice.map[%r].id 必须是非空字符串" % (k,))
|
|
696
|
+
elif v.get("mode") == "edge_id" and not _VOICE_RE.match(str(v["id"])):
|
|
697
|
+
bad.append("audio.voice.map[%r].id=%r 不是 edge voice id"
|
|
698
|
+
% (k, v.get("id")))
|
|
699
|
+
else:
|
|
700
|
+
bad.append("audio.voice.map[%r] 必须是 voice id 或 {mode,id}" % (k,))
|
|
701
|
+
if "jitter" in vo:
|
|
702
|
+
_num(vo["jitter"], "audio.voice.jitter", bad, 0.0, 1.0)
|
|
703
|
+
if "max_distinct" in vo:
|
|
704
|
+
_int_of(vo["max_distinct"], "audio.voice.max_distinct", bad, 1, 5)
|
|
705
|
+
for k in ("rate", "pitch"):
|
|
706
|
+
if k in vo and not isinstance(vo[k], str):
|
|
707
|
+
bad.append("audio.voice.%s 必须是字符串(如 \"+0%%\" / \"+0Hz\")" % k)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _validate_music(mu, bad):
|
|
711
|
+
if not isinstance(mu, dict):
|
|
712
|
+
bad.append("audio.music 必须是 dict")
|
|
713
|
+
return
|
|
714
|
+
_only(mu, _MUSIC_KEYS, "audio.music", bad)
|
|
715
|
+
if "mode" not in mu:
|
|
716
|
+
bad.append("audio.music 缺必填字段 'mode'(fail-closed:不会猜 params/file/off)")
|
|
717
|
+
return
|
|
718
|
+
mode = mu.get("mode")
|
|
719
|
+
if mode not in ("params", "file", "off"):
|
|
720
|
+
bad.append("audio.music.mode=%r 必须是 params / file / off" % (mode,))
|
|
721
|
+
# v1.0.1:**不再要求 style**,也不再限定成 8 个内置名。
|
|
722
|
+
# 配乐由 musicgen 现场编排,它吃的是 bpm/key/scale/mood/seed + 可选的
|
|
723
|
+
# progressions/motif/drums/voices —— 没有"曲风"这个概念了。
|
|
724
|
+
# 老 design.json 里残留的 style 字段仍被接受(不校验取值),但不参与渲染,
|
|
725
|
+
# 避免"升级后老设计全部报错"。
|
|
726
|
+
_need(mu, ("scale", "bpm", "key", "mood", "seed"), "audio.music", bad)
|
|
727
|
+
if "style" in mu and not isinstance(mu["style"], str):
|
|
728
|
+
bad.append("audio.music.style 若存在必须是字符串(v1.0.1 起不参与渲染)")
|
|
729
|
+
if "scale" in mu:
|
|
730
|
+
_enum_of(mu["scale"], SCALES, "audio.music.scale", bad)
|
|
731
|
+
if "mood" in mu:
|
|
732
|
+
_enum_of(mu["mood"], MOODS, "audio.music.mood", bad)
|
|
733
|
+
if "bpm" in mu:
|
|
734
|
+
_num(mu["bpm"], "audio.music.bpm", bad, 40.0, 220.0)
|
|
735
|
+
if "key" in mu and isinstance(mu["key"], str):
|
|
736
|
+
scale = mu.get("scale")
|
|
737
|
+
if scale in SCALES:
|
|
738
|
+
pool = ST.MINOR_KEYS if scale == "minor" else ST.MAJOR_KEYS
|
|
739
|
+
if mu["key"] not in pool:
|
|
740
|
+
bad.append("audio.music.key=%r 与 scale=%r 不匹配(%s 调池:%s)"
|
|
741
|
+
% (mu["key"], scale, scale, " / ".join(pool)))
|
|
742
|
+
elif "key" in mu:
|
|
743
|
+
bad.append("audio.music.key 必须是字符串")
|
|
744
|
+
if "seed" in mu:
|
|
745
|
+
_int_of(mu["seed"], "audio.music.seed", bad)
|
|
746
|
+
if mode == "file" and not (isinstance(mu.get("file"), str) and mu.get("file")):
|
|
747
|
+
bad.append("audio.music.mode=file 时必须给非空 audio.music.file")
|
|
748
|
+
if "gain_db" in mu:
|
|
749
|
+
_num(mu["gain_db"], "audio.music.gain_db", bad, -60.0, 12.0)
|
|
750
|
+
if "duck_db" in mu:
|
|
751
|
+
_num(mu["duck_db"], "audio.music.duck_db", bad, 0.0, 24.0)
|
|
752
|
+
if "arranger" in mu and not isinstance(mu["arranger"], str):
|
|
753
|
+
bad.append("audio.music.arranger 必须是字符串")
|
|
754
|
+
if "sections" in mu:
|
|
755
|
+
sec = mu["sections"]
|
|
756
|
+
if not isinstance(sec, (list, tuple)) or any(not isinstance(x, dict) for x in sec):
|
|
757
|
+
bad.append("audio.music.sections 必须是对象数组")
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _validate_sfx(sfx, bad):
|
|
761
|
+
if not isinstance(sfx, dict):
|
|
762
|
+
bad.append("audio.sfx 必须是 dict")
|
|
763
|
+
return
|
|
764
|
+
_only(sfx, _SFX_KEYS, "audio.sfx", bad)
|
|
765
|
+
if "enabled" in sfx and not isinstance(sfx["enabled"], bool):
|
|
766
|
+
bad.append("audio.sfx.enabled 必须是布尔")
|
|
767
|
+
if "density" in sfx:
|
|
768
|
+
_enum_of(sfx["density"], SFX_DENSITIES, "audio.sfx.density", bad)
|
|
769
|
+
if "gain_db" in sfx:
|
|
770
|
+
_num(sfx["gain_db"], "audio.sfx.gain_db", bad, -40.0, 12.0)
|
|
771
|
+
if "cues" in sfx:
|
|
772
|
+
cues = sfx["cues"]
|
|
773
|
+
if not isinstance(cues, (list, tuple)) or any(not isinstance(x, dict) for x in cues):
|
|
774
|
+
bad.append("audio.sfx.cues 必须是对象数组")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def validate_design(design):
|
|
778
|
+
"""fail-closed 校验。返回问题列表(空 = 通过)。
|
|
779
|
+
|
|
780
|
+
覆盖:
|
|
781
|
+
1. schema 版本 / id / label / pitch / 四个必填块;
|
|
782
|
+
2. 枚举:motif.kind / transition.kind / typography.number / ease / scale / mood
|
|
783
|
+
(以及 family / align / layout / hud / size / fps / 声线格式);
|
|
784
|
+
3. 数值边界:**直接调 stylekit.visual_lint**(临时语言包),与阈值同源;
|
|
785
|
+
4. 配色:把 design 展开成 35 字段主题后调 stylekit.theme_lint(不自己写对比度);
|
|
786
|
+
5. 任何"缺了就取默认值"的路径都在这里变成一条问题。
|
|
787
|
+
"""
|
|
788
|
+
bad = []
|
|
789
|
+
if not isinstance(design, dict):
|
|
790
|
+
return ["design 不是 dict:%r" % (design,)]
|
|
791
|
+
|
|
792
|
+
_need(design, ("schema", "id", "label", "pitch", "structure", "visual", "audio", "pacing"),
|
|
793
|
+
"design", bad)
|
|
794
|
+
_only(design, _TOP_KEYS, "design", bad)
|
|
795
|
+
|
|
796
|
+
um = design.get("_unmatched_constraints")
|
|
797
|
+
if um is not None and (not isinstance(um, (list, tuple))
|
|
798
|
+
or any(not isinstance(x, str) for x in um)):
|
|
799
|
+
bad.append("_unmatched_constraints 必须是字符串数组(认不出的 brief 要求)")
|
|
800
|
+
|
|
801
|
+
sch = design.get("schema")
|
|
802
|
+
if not _is_int(sch):
|
|
803
|
+
bad.append("schema 必须是整数 %d,收到 %r" % (SCHEMA, sch))
|
|
804
|
+
elif sch != SCHEMA:
|
|
805
|
+
bad.append("schema 版本不支持:%r(本模块只认 %d)" % (sch, SCHEMA))
|
|
806
|
+
|
|
807
|
+
did = design.get("id")
|
|
808
|
+
if not isinstance(did, str) or not re.match(r"^[a-z0-9][a-z0-9._-]{1,63}$", did):
|
|
809
|
+
bad.append("id=%r 必须匹配 ^[a-z0-9][a-z0-9._-]{1,63}$" % (did,))
|
|
810
|
+
label = design.get("label")
|
|
811
|
+
if not isinstance(label, str) or not label.strip():
|
|
812
|
+
bad.append("label 必须是非空字符串")
|
|
813
|
+
elif len(label) > 40:
|
|
814
|
+
bad.append("label 超过 40 字(当前 %d)" % len(label))
|
|
815
|
+
pitch = design.get("pitch")
|
|
816
|
+
if not isinstance(pitch, str) or not pitch.strip():
|
|
817
|
+
bad.append("pitch 必须是非空字符串(一句话立意)")
|
|
818
|
+
elif len(pitch) > 120:
|
|
819
|
+
bad.append("pitch 超过 120 字(当前 %d)" % len(pitch))
|
|
820
|
+
|
|
821
|
+
# ---- structure ----
|
|
822
|
+
st = design.get("structure")
|
|
823
|
+
if not isinstance(st, dict):
|
|
824
|
+
bad.append("structure 必须是 dict")
|
|
825
|
+
else:
|
|
826
|
+
_only(st, _STRUCT_KEYS, "structure", bad)
|
|
827
|
+
_need(st, ("segments",), "structure", bad)
|
|
828
|
+
if "segments" in st:
|
|
829
|
+
_int_of(st["segments"], "structure.segments", bad, 1, 40)
|
|
830
|
+
if "beats" in st:
|
|
831
|
+
beats = st["beats"]
|
|
832
|
+
if not isinstance(beats, (list, tuple)) or any(not isinstance(x, str) for x in beats):
|
|
833
|
+
bad.append("structure.beats 必须是字符串数组")
|
|
834
|
+
if "hold_policy" in st:
|
|
835
|
+
_enum_of(st["hold_policy"], HOLD_POLICIES, "structure.hold_policy", bad)
|
|
836
|
+
if "target_sec" in st and st["target_sec"] is not None:
|
|
837
|
+
_num(st["target_sec"], "structure.target_sec", bad, 3.0, 1800.0)
|
|
838
|
+
|
|
839
|
+
# ---- visual ----
|
|
840
|
+
vis = design.get("visual")
|
|
841
|
+
if not isinstance(vis, dict):
|
|
842
|
+
bad.append("visual 必须是 dict")
|
|
843
|
+
vis = {}
|
|
844
|
+
else:
|
|
845
|
+
_only(vis, _VISUAL_KEYS, "visual", bad)
|
|
846
|
+
_need(vis, ("palette", "family", "typography", "motif", "transition", "motion",
|
|
847
|
+
"bias", "layout", "hud"), "visual", bad)
|
|
848
|
+
|
|
849
|
+
pal = vis.get("palette")
|
|
850
|
+
if not isinstance(pal, dict):
|
|
851
|
+
bad.append("visual.palette 必须是 dict(11 色)")
|
|
852
|
+
else:
|
|
853
|
+
_only(pal, PALETTE_KEYS, "visual.palette", bad)
|
|
854
|
+
_need(pal, PALETTE_KEYS, "visual.palette", bad)
|
|
855
|
+
for k in PALETTE_KEYS:
|
|
856
|
+
if k not in pal:
|
|
857
|
+
continue
|
|
858
|
+
try:
|
|
859
|
+
ST._color_of(pal[k], "visual.palette." + k)
|
|
860
|
+
except Exception as e: # noqa: BLE001
|
|
861
|
+
bad.append("visual.palette.%s 不是颜色([r,g,b] 或 \"#rrggbb\"):%s"
|
|
862
|
+
% (k, e))
|
|
863
|
+
|
|
864
|
+
if "family" in vis:
|
|
865
|
+
_enum_of(vis["family"], FONT_FAMILIES, "visual.family", bad)
|
|
866
|
+
|
|
867
|
+
ty = vis.get("typography")
|
|
868
|
+
if not isinstance(ty, dict):
|
|
869
|
+
bad.append("visual.typography 必须是 dict")
|
|
870
|
+
else:
|
|
871
|
+
_only(ty, _TYPO_KEYS, "visual.typography", bad)
|
|
872
|
+
_need(ty, ("tracking", "line_gap", "number", "ratio", "align"),
|
|
873
|
+
"visual.typography", bad)
|
|
874
|
+
for k in ("tracking", "line_gap", "ratio"):
|
|
875
|
+
if k in ty:
|
|
876
|
+
_num(ty[k], "visual.typography." + k, bad)
|
|
877
|
+
if "number" in ty:
|
|
878
|
+
_enum_of(ty["number"], NUMBERS, "visual.typography.number", bad)
|
|
879
|
+
if "align" in ty:
|
|
880
|
+
_enum_of(ty["align"], ALIGNS, "visual.typography.align", bad)
|
|
881
|
+
|
|
882
|
+
mo = vis.get("motif")
|
|
883
|
+
if not isinstance(mo, dict):
|
|
884
|
+
bad.append("visual.motif 必须是 dict")
|
|
885
|
+
else:
|
|
886
|
+
_only(mo, _MOTIF_KEYS, "visual.motif", bad)
|
|
887
|
+
_need(mo, _MOTIF_KEYS, "visual.motif", bad)
|
|
888
|
+
if "kind" in mo:
|
|
889
|
+
_enum_of(mo["kind"], MOTIFS, "visual.motif.kind", bad)
|
|
890
|
+
if "density" in mo:
|
|
891
|
+
_num(mo["density"], "visual.motif.density", bad, 0.0, 1.0)
|
|
892
|
+
if "detail" in mo and not isinstance(mo["detail"], dict):
|
|
893
|
+
bad.append("visual.motif.detail 必须是 dict(可以是空 {})")
|
|
894
|
+
|
|
895
|
+
tr = vis.get("transition")
|
|
896
|
+
if not isinstance(tr, dict):
|
|
897
|
+
bad.append("visual.transition 必须是 dict")
|
|
898
|
+
else:
|
|
899
|
+
_only(tr, _TRANS_KEYS, "visual.transition", bad)
|
|
900
|
+
_need(tr, _TRANS_KEYS, "visual.transition", bad)
|
|
901
|
+
if "kind" in tr:
|
|
902
|
+
_enum_of(tr["kind"], TRANSITIONS, "visual.transition.kind", bad)
|
|
903
|
+
if "dur" in tr:
|
|
904
|
+
_num(tr["dur"], "visual.transition.dur", bad, 0.0, 0.60)
|
|
905
|
+
|
|
906
|
+
mo2 = vis.get("motion")
|
|
907
|
+
if not isinstance(mo2, dict):
|
|
908
|
+
bad.append("visual.motion 必须是 dict")
|
|
909
|
+
else:
|
|
910
|
+
_only(mo2, _MOTION_KEYS, "visual.motion", bad)
|
|
911
|
+
_need(mo2, _MOTION_KEYS, "visual.motion", bad)
|
|
912
|
+
if "ease" in mo2:
|
|
913
|
+
_enum_of(mo2["ease"], EASES, "visual.motion.ease", bad)
|
|
914
|
+
for k in ("push", "breathe_hz"):
|
|
915
|
+
if k in mo2:
|
|
916
|
+
_num(mo2[k], "visual.motion." + k, bad)
|
|
917
|
+
if "enter" in mo2:
|
|
918
|
+
ent = mo2["enter"]
|
|
919
|
+
if (not isinstance(ent, (list, tuple)) or len(ent) != 2
|
|
920
|
+
or any(not _is_num(x) for x in ent)):
|
|
921
|
+
bad.append("visual.motion.enter 必须是两个数字 [起, 止]")
|
|
922
|
+
|
|
923
|
+
bias = vis.get("bias")
|
|
924
|
+
if not isinstance(bias, dict):
|
|
925
|
+
bad.append("visual.bias 必须是 dict")
|
|
926
|
+
else:
|
|
927
|
+
_only(bias, _BIAS_KEYS, "visual.bias", bad)
|
|
928
|
+
_need(bias, _BIAS_KEYS, "visual.bias", bad)
|
|
929
|
+
for k in ("x", "y"):
|
|
930
|
+
if k in bias:
|
|
931
|
+
_int_of(bias[k], "visual.bias." + k, bad, -240, 240)
|
|
932
|
+
if "inset" in bias:
|
|
933
|
+
_int_of(bias["inset"], "visual.bias.inset", bad, 0, 120)
|
|
934
|
+
|
|
935
|
+
if "layout" in vis:
|
|
936
|
+
_enum_of(vis["layout"], LAYOUTS, "visual.layout", bad)
|
|
937
|
+
if "hud" in vis:
|
|
938
|
+
_enum_of(vis["hud"], HUDS, "visual.hud", bad)
|
|
939
|
+
if "size" in vis:
|
|
940
|
+
_enum_of(vis["size"], SIZES, "visual.size", bad)
|
|
941
|
+
if "fps" in vis:
|
|
942
|
+
if not _is_int(vis["fps"]):
|
|
943
|
+
bad.append("visual.fps 必须是整数")
|
|
944
|
+
elif vis["fps"] not in FPSES:
|
|
945
|
+
bad.append("visual.fps=%r 不在 %s 里" % (vis["fps"], FPSES))
|
|
946
|
+
|
|
947
|
+
# 版式轮换(v1.0.2):片内按章节轮换版式,防"全片一张版式"。
|
|
948
|
+
# 不写 = 由 design_to_plan 按设计指纹自动派生一套轮换(老 design.json 行为不变,
|
|
949
|
+
# 因为 plan 里没有 layout_cycle 时 grammar 仍走 layout_default)。
|
|
950
|
+
if "layout_cycle" in vis and vis["layout_cycle"] is not None:
|
|
951
|
+
cyc = vis["layout_cycle"]
|
|
952
|
+
if not isinstance(cyc, (list, tuple)) or not cyc:
|
|
953
|
+
bad.append("visual.layout_cycle 必须是非空字符串数组(元素取自 %s)"
|
|
954
|
+
% " / ".join(LAYOUTS))
|
|
955
|
+
else:
|
|
956
|
+
for i, name in enumerate(cyc):
|
|
957
|
+
if name not in LAYOUTS:
|
|
958
|
+
bad.append("visual.layout_cycle[%d]=%r 不是可用版式(可用:%s)"
|
|
959
|
+
% (i, name, " / ".join(LAYOUTS)))
|
|
960
|
+
if len(set(str(x) for x in cyc)) < 2:
|
|
961
|
+
bad.append("visual.layout_cycle 至少要有 2 种不同版式(只有一种等于不轮换)")
|
|
962
|
+
|
|
963
|
+
# 字幕样式(v1.0.2):可选;不写 = 过去写死的值(逐像素不变)。
|
|
964
|
+
sub = vis.get("subtitle")
|
|
965
|
+
if sub is not None:
|
|
966
|
+
if not isinstance(sub, dict):
|
|
967
|
+
bad.append("visual.subtitle 必须是 dict")
|
|
968
|
+
else:
|
|
969
|
+
_only(sub, _SUBTITLE_KEYS, "visual.subtitle", bad)
|
|
970
|
+
if "size" in sub and sub["size"] is not None:
|
|
971
|
+
_int_of(sub["size"], "visual.subtitle.size", bad, 20, 96)
|
|
972
|
+
if "y" in sub and sub["y"] is not None:
|
|
973
|
+
_int_of(sub["y"], "visual.subtitle.y", bad, 0, 1030)
|
|
974
|
+
if "align" in sub and sub["align"] is not None:
|
|
975
|
+
_enum_of(sub["align"], _SUBTITLE_ALIGNS, "visual.subtitle.align", bad)
|
|
976
|
+
if "plate_alpha" in sub and sub["plate_alpha"] is not None:
|
|
977
|
+
_int_of(sub["plate_alpha"], "visual.subtitle.plate_alpha", bad, 0, 255)
|
|
978
|
+
if "radius" in sub and sub["radius"] is not None:
|
|
979
|
+
_int_of(sub["radius"], "visual.subtitle.radius", bad, 0, 48)
|
|
980
|
+
if "family" in sub and sub["family"] is not None:
|
|
981
|
+
_enum_of(sub["family"], _FONT_FAMILIES_FOR_SUB, "visual.subtitle.family", bad)
|
|
982
|
+
if "color" in sub and sub["color"] is not None:
|
|
983
|
+
try:
|
|
984
|
+
ST._color_of(sub["color"], "visual.subtitle.color")
|
|
985
|
+
except Exception as e: # noqa: BLE001
|
|
986
|
+
bad.append("visual.subtitle.color 不是颜色([r,g,b] 或 \"#rrggbb\"):%s" % e)
|
|
987
|
+
if "max_lines" in sub and sub["max_lines"] is not None:
|
|
988
|
+
_int_of(sub["max_lines"], "visual.subtitle.max_lines", bad, 1, 3)
|
|
989
|
+
|
|
990
|
+
# ---- audio ----
|
|
991
|
+
au = design.get("audio")
|
|
992
|
+
if not isinstance(au, dict):
|
|
993
|
+
bad.append("audio 必须是 dict")
|
|
994
|
+
else:
|
|
995
|
+
_only(au, _AUDIO_KEYS, "audio", bad)
|
|
996
|
+
_need(au, ("voice", "music"), "audio", bad)
|
|
997
|
+
if "voice" in au:
|
|
998
|
+
_validate_voice(au["voice"], bad)
|
|
999
|
+
if "music" in au:
|
|
1000
|
+
_validate_music(au["music"], bad)
|
|
1001
|
+
if "sfx" in au:
|
|
1002
|
+
_validate_sfx(au["sfx"], bad)
|
|
1003
|
+
|
|
1004
|
+
# ---- pacing ----
|
|
1005
|
+
pa = design.get("pacing")
|
|
1006
|
+
if not isinstance(pa, dict):
|
|
1007
|
+
bad.append("pacing 必须是 dict")
|
|
1008
|
+
else:
|
|
1009
|
+
_only(pa, _PACING_KEYS, "pacing", bad)
|
|
1010
|
+
_need(pa, _PACING_KEYS, "pacing", bad)
|
|
1011
|
+
for k, lo, hi in (("lead", 0.0, 3.0), ("tail", 0.0, 3.0),
|
|
1012
|
+
("min_dur", 1.0, 12.0), ("hold", 0.0, 5.0),
|
|
1013
|
+
("item_stagger", 0.05, 1.2)):
|
|
1014
|
+
if k in pa:
|
|
1015
|
+
_num(pa[k], "pacing." + k, bad, lo, hi)
|
|
1016
|
+
|
|
1017
|
+
# ---- 同源数值门禁:stylekit.visual_lint(临时语言包;门槛只有一份) ----
|
|
1018
|
+
try:
|
|
1019
|
+
lang = _language_of(design)
|
|
1020
|
+
except Exception as e: # noqa: BLE001
|
|
1021
|
+
bad.append("无法构造视觉语言包(visual_lint 没跑):%s" % e)
|
|
1022
|
+
else:
|
|
1023
|
+
for p in _visual_lint_language(lang):
|
|
1024
|
+
bad.append("visual: " + p)
|
|
1025
|
+
|
|
1026
|
+
# ---- 同源配色门禁:stylekit.theme_lint(不自己写对比度) ----
|
|
1027
|
+
try:
|
|
1028
|
+
th = _theme_of_design(design)
|
|
1029
|
+
for p in ST.theme_lint(th):
|
|
1030
|
+
bad.append("theme: " + p)
|
|
1031
|
+
except Exception as e: # noqa: BLE001
|
|
1032
|
+
bad.append("主题展开失败(theme_lint 没跑):%s" % e)
|
|
1033
|
+
|
|
1034
|
+
# 去重但保持首次出现的顺序(同一条缺字段会被多个检查提到)
|
|
1035
|
+
seen, out = set(), []
|
|
1036
|
+
for s in bad:
|
|
1037
|
+
if s not in seen:
|
|
1038
|
+
seen.add(s)
|
|
1039
|
+
out.append(s)
|
|
1040
|
+
return out
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
# ============================================================================
|
|
1044
|
+
# 六、design -> plan(scenes.draw_frame 直接可吃)
|
|
1045
|
+
# ============================================================================
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _fingerprint_part(obj):
|
|
1049
|
+
raw = json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
1050
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def design_to_plan(design):
|
|
1054
|
+
"""设计 -> 风格计划(形状对齐 stylekit.resolve_script_style 的返回)。
|
|
1055
|
+
|
|
1056
|
+
theme 必须通过 stylekit.theme_lint;不通过 raise ValueError(带全部问题)。
|
|
1057
|
+
渲染侧把返回值挂到 script["_style"],scenes._plan 会直接用它。
|
|
1058
|
+
"""
|
|
1059
|
+
bad = validate_design(design)
|
|
1060
|
+
if bad:
|
|
1061
|
+
raise ValueError("设计没过校验(%d 条):\n - %s" % (len(bad), "\n - ".join(bad)))
|
|
1062
|
+
|
|
1063
|
+
th = _theme_of_design(design)
|
|
1064
|
+
lint = ST.theme_lint(th)
|
|
1065
|
+
if lint:
|
|
1066
|
+
raise ValueError("主题没过 stylekit.theme_lint(%d 条):\n - %s"
|
|
1067
|
+
% (len(lint), "\n - ".join(lint)))
|
|
1068
|
+
|
|
1069
|
+
vis = design["visual"]
|
|
1070
|
+
au = design["audio"]
|
|
1071
|
+
pa = design["pacing"]
|
|
1072
|
+
mu = au["music"]
|
|
1073
|
+
lang = _language_of(design)
|
|
1074
|
+
|
|
1075
|
+
seed = int(mu["seed"])
|
|
1076
|
+
fp = design_fingerprint(design)
|
|
1077
|
+
seed_effective = (seed * 1000003 + int(fp, 16)) % (2 ** 31 - 1)
|
|
1078
|
+
|
|
1079
|
+
vo = au["voice"]
|
|
1080
|
+
if isinstance(vo, str):
|
|
1081
|
+
vid = vo
|
|
1082
|
+
vmap = {k: vid for k in VOICE_KINDS}
|
|
1083
|
+
v_jitter, v_maxd, v_auto = 0.0, 1, False
|
|
1084
|
+
else:
|
|
1085
|
+
vid = str(vo["id"])
|
|
1086
|
+
raw_map = vo["map"]
|
|
1087
|
+
vmap = {}
|
|
1088
|
+
for k in VOICE_KINDS:
|
|
1089
|
+
if k in raw_map:
|
|
1090
|
+
v = raw_map[k]
|
|
1091
|
+
vmap[k] = str(v["id"]) if isinstance(v, dict) else str(v)
|
|
1092
|
+
if not vmap:
|
|
1093
|
+
# 空 map 的语义 = 四个 kind 都用主声线(不是"缺字段回落",是契约里写明的意思)
|
|
1094
|
+
vmap = {k: vid for k in VOICE_KINDS}
|
|
1095
|
+
v_jitter = float(vo["jitter"])
|
|
1096
|
+
v_maxd = int(vo["max_distinct"])
|
|
1097
|
+
v_auto = True
|
|
1098
|
+
if "body" in vmap:
|
|
1099
|
+
v_default = vmap["body"]
|
|
1100
|
+
else:
|
|
1101
|
+
v_default = vid
|
|
1102
|
+
|
|
1103
|
+
return {
|
|
1104
|
+
"version": 1,
|
|
1105
|
+
"generator": "designkit 1.0",
|
|
1106
|
+
"style": "design:%s" % design["id"],
|
|
1107
|
+
"style_name": str(design["label"]),
|
|
1108
|
+
"style_source": "design",
|
|
1109
|
+
"style_reason": [str(design["pitch"])],
|
|
1110
|
+
"legacy": False,
|
|
1111
|
+
"theme_id": th["id"],
|
|
1112
|
+
"theme_label": th["label"],
|
|
1113
|
+
"theme": th,
|
|
1114
|
+
"language": lang,
|
|
1115
|
+
"layout_default": vis["layout"],
|
|
1116
|
+
# 片内版式轮换(v1.0.2):grammar 在 chapter_layout 为空时按章节轮换这个序列。
|
|
1117
|
+
# 没有这个键(老 design.json)时 grammar 行为与过去完全一致。
|
|
1118
|
+
"layout_cycle": list(vis.get("layout_cycle") or []),
|
|
1119
|
+
"chapter_layout": {},
|
|
1120
|
+
# 字幕样式(v1.0.2):render/filmkit 只读这一份,不再各自写默认值。
|
|
1121
|
+
"subtitle": subtitle_of(design),
|
|
1122
|
+
# 目标时长(可能为 null):只用于"实测 vs 目标"提示,不改变 TTS 决定的真实时长。
|
|
1123
|
+
"target_sec": (None if design.get("structure", {}).get("target_sec") is None
|
|
1124
|
+
else float(design["structure"]["target_sec"])),
|
|
1125
|
+
"music": {
|
|
1126
|
+
# v1.0.1:不再有 "style" 这个键 —— 配乐由 musicgen 现场编排,
|
|
1127
|
+
# 没有"曲风表"可指向。老设计里残留的 style 字段被忽略。
|
|
1128
|
+
"scale": mu["scale"],
|
|
1129
|
+
"bpm": float(mu["bpm"]),
|
|
1130
|
+
"bpm_source": "design",
|
|
1131
|
+
"key": mu["key"],
|
|
1132
|
+
"key_source": "design",
|
|
1133
|
+
"mood": mu["mood"],
|
|
1134
|
+
"mood_source": "design",
|
|
1135
|
+
"seed": seed,
|
|
1136
|
+
"seed_effective": int(seed_effective),
|
|
1137
|
+
"seed_from_content": True,
|
|
1138
|
+
},
|
|
1139
|
+
"voice": {
|
|
1140
|
+
"map": vmap,
|
|
1141
|
+
"default": v_default,
|
|
1142
|
+
"policy": {
|
|
1143
|
+
"auto": v_auto,
|
|
1144
|
+
"jitter": v_jitter,
|
|
1145
|
+
"max_distinct": v_maxd,
|
|
1146
|
+
"rate_range": (-15, 12),
|
|
1147
|
+
"pitch_range": (-8, 8),
|
|
1148
|
+
"cps_band": (3.6, 5.6),
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
# ---- 声音参数(v1.0.2)----
|
|
1152
|
+
# design.audio.music.gain_db / duck_db 与 audio.sfx.* 以前**只被校验、从不被读**:
|
|
1153
|
+
# 用户改了 design.json 里的音量/音效开关,渲染结果一模一样(实测 enabled=false
|
|
1154
|
+
# 仍出 8 条音效)。现在原样交给 render,由它填进 script 的 music/sfx 缺省位
|
|
1155
|
+
# (film.json 显式写了就仍以 film.json 为准)。
|
|
1156
|
+
"audio_overrides": {
|
|
1157
|
+
"music": {k: float(mu[k]) for k in ("gain_db", "duck_db") if mu.get(k) is not None},
|
|
1158
|
+
"sfx": {k: (bool(au["sfx"][k]) if k == "enabled" else
|
|
1159
|
+
(str(au["sfx"][k]) if k == "density" else float(au["sfx"][k])))
|
|
1160
|
+
for k in ("enabled", "density", "gain_db")
|
|
1161
|
+
if isinstance(au.get("sfx"), dict) and au["sfx"].get(k) is not None},
|
|
1162
|
+
},
|
|
1163
|
+
"pacing": {
|
|
1164
|
+
"lead": float(pa["lead"]),
|
|
1165
|
+
"tail": float(pa["tail"]),
|
|
1166
|
+
"min_dur": float(pa["min_dur"]),
|
|
1167
|
+
"hold": float(pa["hold"]),
|
|
1168
|
+
"item_stagger": float(pa["item_stagger"]),
|
|
1169
|
+
},
|
|
1170
|
+
"fingerprint": {
|
|
1171
|
+
"version": "design-fp-v1",
|
|
1172
|
+
"full": fp,
|
|
1173
|
+
"tempo": _fingerprint_part({"structure": design.get("structure"),
|
|
1174
|
+
"pacing": design.get("pacing")}),
|
|
1175
|
+
"style": _fingerprint_part({"visual": design.get("visual")}),
|
|
1176
|
+
},
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
# ============================================================================
|
|
1181
|
+
# 七、指纹 / 距离
|
|
1182
|
+
# ============================================================================
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def design_fingerprint(design):
|
|
1186
|
+
"""设计指纹:只看"决定长相"的四个块,忽略 id/label/pitch 这类文案。
|
|
1187
|
+
|
|
1188
|
+
为什么要忽略 id:propose.py 会给候选改写 id(A-1a2b3c4d),若把 id 算进指纹,
|
|
1189
|
+
同一个设计在 proposal 与 design.json 里会得到两个指纹,确认状态会"假 stale"。
|
|
1190
|
+
"""
|
|
1191
|
+
if isinstance(design, dict):
|
|
1192
|
+
payload = {
|
|
1193
|
+
"schema": design.get("schema"),
|
|
1194
|
+
"structure": design.get("structure"),
|
|
1195
|
+
"visual": design.get("visual"),
|
|
1196
|
+
"audio": design.get("audio"),
|
|
1197
|
+
"pacing": design.get("pacing"),
|
|
1198
|
+
}
|
|
1199
|
+
else:
|
|
1200
|
+
payload = design
|
|
1201
|
+
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True,
|
|
1202
|
+
separators=(",", ":"), default=str)
|
|
1203
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _dig(obj, keys):
|
|
1207
|
+
cur = obj
|
|
1208
|
+
for k in keys:
|
|
1209
|
+
if not isinstance(cur, dict):
|
|
1210
|
+
return None
|
|
1211
|
+
cur = cur.get(k)
|
|
1212
|
+
return cur
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _palette_key(design):
|
|
1216
|
+
pal = _dig(design, ("visual", "palette"))
|
|
1217
|
+
if not isinstance(pal, dict):
|
|
1218
|
+
return ""
|
|
1219
|
+
return "bg=%s accent=%s" % (_hex(pal.get("bg")), _hex(pal.get("accent")))
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _voice_key(design):
|
|
1223
|
+
vo = _dig(design, ("audio", "voice"))
|
|
1224
|
+
if isinstance(vo, str):
|
|
1225
|
+
return vo
|
|
1226
|
+
if isinstance(vo, dict):
|
|
1227
|
+
return str(vo.get("id") or "")
|
|
1228
|
+
return ""
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
# 维度顺序固定(展示与 diff 都按它)。前 7 个是任务要求的底线,后 3 个是额外观测项。
|
|
1232
|
+
def _music_key(design):
|
|
1233
|
+
"""design -> 配乐身份("调式/速度档/情绪"复合键)。
|
|
1234
|
+
|
|
1235
|
+
v1.0.2 修:这里以前读 audio.music.style —— 那是"配乐风格表"时代的字段,
|
|
1236
|
+
v1.0.1 删掉风格表后采样器不再产出它,于是这一维**恒为 None**,两个候选
|
|
1237
|
+
永远"配乐相同",SKILL §0.5 承诺的"候选至少在含配乐在内的 2 个维度上不同"
|
|
1238
|
+
实际少了一维(也是重采样失败率偏高的原因之一)。
|
|
1239
|
+
现在用参数化配乐真正决定听感的三件事做签名:调式 + BPM 档(±6 归一)+ 情绪。
|
|
1240
|
+
"""
|
|
1241
|
+
mu = _dig(design, ("audio", "music")) or {}
|
|
1242
|
+
if not isinstance(mu, dict):
|
|
1243
|
+
return None
|
|
1244
|
+
bpm = mu.get("bpm")
|
|
1245
|
+
try:
|
|
1246
|
+
bucket = int(round(float(bpm) / 6.0))
|
|
1247
|
+
except (TypeError, ValueError):
|
|
1248
|
+
bucket = None
|
|
1249
|
+
return "%s/%s/%s" % (mu.get("scale"), bucket, mu.get("mood"))
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
_AXES = (
|
|
1253
|
+
("palette", _palette_key),
|
|
1254
|
+
("motif", lambda d: _dig(d, ("visual", "motif", "kind"))),
|
|
1255
|
+
("transition", lambda d: _dig(d, ("visual", "transition", "kind"))),
|
|
1256
|
+
("ease", lambda d: _dig(d, ("visual", "motion", "ease"))),
|
|
1257
|
+
("number", lambda d: _dig(d, ("visual", "typography", "number"))),
|
|
1258
|
+
("music", _music_key),
|
|
1259
|
+
("voice", _voice_key),
|
|
1260
|
+
("mood", lambda d: _dig(d, ("audio", "music", "mood"))),
|
|
1261
|
+
("layout", lambda d: _dig(d, ("visual", "layout"))),
|
|
1262
|
+
("scale", lambda d: _dig(d, ("audio", "music", "scale"))),
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def design_distance(a, b):
|
|
1267
|
+
"""两个设计在哪些维度上不同("伪多方案"门禁的判据)。
|
|
1268
|
+
|
|
1269
|
+
返回 {"axes": [维度名...], "n": 不同维度数, "detail": {维度: (a值, b值)}}。
|
|
1270
|
+
只看离散签名,不看连续量(ratio/tracking 的细小差异不算另一个人)。
|
|
1271
|
+
"""
|
|
1272
|
+
axes, detail = [], {}
|
|
1273
|
+
for name, getter in _AXES:
|
|
1274
|
+
va, vb = getter(a), getter(b)
|
|
1275
|
+
if va != vb:
|
|
1276
|
+
axes.append(name)
|
|
1277
|
+
detail[name] = (va, vb)
|
|
1278
|
+
return {"axes": axes, "n": len(axes), "detail": detail}
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
# ============================================================================
|
|
1282
|
+
# 八、采样器:sha1 派生的确定性采样 + 两两差异自检
|
|
1283
|
+
# ============================================================================
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _check_brief(brief):
|
|
1287
|
+
"""brief 的形状校验:topic 必填,其余三个可选;未知键一律报错(fail-closed)。"""
|
|
1288
|
+
if not isinstance(brief, dict):
|
|
1289
|
+
raise TypeError("brief 必须是 dict,收到 %r" % (type(brief).__name__,))
|
|
1290
|
+
extra = [k for k in brief if k not in _BRIEF_KEYS]
|
|
1291
|
+
if extra:
|
|
1292
|
+
raise ValueError("brief 有未知字段 %s;只认 %s"
|
|
1293
|
+
% (extra, " / ".join(_BRIEF_KEYS)))
|
|
1294
|
+
topic = brief.get("topic")
|
|
1295
|
+
if not isinstance(topic, str) or not topic.strip():
|
|
1296
|
+
raise ValueError("brief.topic 必须是非空字符串(至少告诉采样器在给什么题材做方案)")
|
|
1297
|
+
dt = brief.get("duration_target")
|
|
1298
|
+
if dt is not None:
|
|
1299
|
+
if not _is_num(dt):
|
|
1300
|
+
raise ValueError("brief.duration_target 必须是数字或 null,收到 %r" % (dt,))
|
|
1301
|
+
if not 3.0 <= float(dt) <= 1800.0:
|
|
1302
|
+
raise ValueError("brief.duration_target 必须在 3~1800 秒,收到 %r" % (dt,))
|
|
1303
|
+
for k in ("must_have", "avoid"):
|
|
1304
|
+
v = brief.get(k)
|
|
1305
|
+
if v is None:
|
|
1306
|
+
continue
|
|
1307
|
+
if not isinstance(v, (list, tuple)) or any(not isinstance(x, str) for x in v):
|
|
1308
|
+
raise ValueError("brief.%s 必须是字符串数组" % k)
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _brief_canon(brief):
|
|
1312
|
+
"""brief 的规范形态(只含契约字段;JSON 排序键后作为 sha1 的输入)。"""
|
|
1313
|
+
dt = brief.get("duration_target")
|
|
1314
|
+
return {
|
|
1315
|
+
"topic": str(brief["topic"]),
|
|
1316
|
+
"duration_target": None if dt is None else float(dt),
|
|
1317
|
+
"must_have": [str(x) for x in brief.get("must_have", [])],
|
|
1318
|
+
"avoid": [str(x) for x in brief.get("avoid", [])],
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
# ---------------------------------------------------------------- 约束(真过滤)
|
|
1322
|
+
# (类别, 取值, 认得的中英文说法)。只有这五类:它们正好各自对应 design 里一个能直接
|
|
1323
|
+
# 判定的字段——配色族(由 bg/bg2/surface 反查)、母题、转场、情绪、声线性别。
|
|
1324
|
+
_CONSTRAINT_WORDS = (
|
|
1325
|
+
("palette", "deep-blue", ("深蓝", "deep-blue")),
|
|
1326
|
+
("palette", "near-black", ("近黑", "near-black")),
|
|
1327
|
+
("palette", "cool-mint", ("薄荷", "cool-mint")),
|
|
1328
|
+
("palette", "sand", ("砂金", "sand")),
|
|
1329
|
+
("palette", "slate", ("石青", "slate")),
|
|
1330
|
+
("palette", "forest", ("森林", "forest")),
|
|
1331
|
+
("palette", "warm-paper", ("暖纸", "warm-paper")),
|
|
1332
|
+
("palette", "ink-paper", ("墨纸", "ink-paper")),
|
|
1333
|
+
("motif", "brackets", ("括角", "brackets")),
|
|
1334
|
+
("motif", "hairlines", ("细线", "hairlines")),
|
|
1335
|
+
("motif", "handdrawn", ("手绘", "handdrawn")),
|
|
1336
|
+
("motif", "slabs", ("色块", "slabs")),
|
|
1337
|
+
("motif", "minimal", ("极简", "minimal")),
|
|
1338
|
+
("motif", "letterbox", ("遮幅", "letterbox")),
|
|
1339
|
+
("motif", "signal", ("信号", "signal")),
|
|
1340
|
+
("motif", "rays", ("放射", "rays")),
|
|
1341
|
+
("transition", "dip", ("叠化", "dip")),
|
|
1342
|
+
("transition", "wash", ("晕染", "wash")),
|
|
1343
|
+
("transition", "paper", ("纸卷", "paper")),
|
|
1344
|
+
("transition", "wipe", ("擦入", "wipe")),
|
|
1345
|
+
("transition", "cut", ("硬切", "cut")),
|
|
1346
|
+
("transition", "iris", ("光圈", "iris")),
|
|
1347
|
+
("mood", "calm", ("平静", "calm")),
|
|
1348
|
+
("mood", "reflective", ("回望", "reflective")),
|
|
1349
|
+
("mood", "hopeful", ("希望", "hopeful")),
|
|
1350
|
+
("mood", "steady", ("稳", "steady")),
|
|
1351
|
+
("mood", "driving", ("推进", "driving")),
|
|
1352
|
+
("mood", "tense", ("紧张", "tense")),
|
|
1353
|
+
("mood", "uplifting", ("上扬", "uplifting")),
|
|
1354
|
+
("voice", "male", ("男声", "male")),
|
|
1355
|
+
("voice", "female", ("女声", "female")),
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
_CATEGORY_VALUES = {
|
|
1359
|
+
# 配色没有"可选值清单"了(连续色相空间),这里只保留空元组以兼容旧约束解析路径:
|
|
1360
|
+
# brief 里写"深蓝"这类词会走 _CONSTRAINT_WORDS -> 被解释成色相区间(见 _resolve_constraints)。
|
|
1361
|
+
"palette": (),
|
|
1362
|
+
"motif": tuple(MOTIFS),
|
|
1363
|
+
"transition": tuple(TRANSITIONS),
|
|
1364
|
+
"mood": tuple(MOODS),
|
|
1365
|
+
"voice": ("male", "female"),
|
|
1366
|
+
}
|
|
1367
|
+
_CATEGORY_WORD = {"palette": "配色", "motif": "母题", "transition": "转场",
|
|
1368
|
+
"mood": "情绪", "voice": "声线"}
|
|
1369
|
+
# 英文按"整词"认:不加边界的话 "warm-paper" 会顺带命中"纸卷/paper"、"female" 会命中"male"。
|
|
1370
|
+
_ASCII_WORD = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _alias_spans(text_low, alias):
|
|
1374
|
+
"""alias 在 text 里的所有起点;英文别名要求前后不紧贴字母数字(整词)。"""
|
|
1375
|
+
al = alias.lower()
|
|
1376
|
+
ascii_alias = al.isascii()
|
|
1377
|
+
out, i = [], text_low.find(al)
|
|
1378
|
+
while i != -1:
|
|
1379
|
+
j = i + len(al)
|
|
1380
|
+
ok = True
|
|
1381
|
+
if ascii_alias:
|
|
1382
|
+
if i > 0 and text_low[i - 1] in _ASCII_WORD:
|
|
1383
|
+
ok = False
|
|
1384
|
+
if j < len(text_low) and text_low[j] in _ASCII_WORD:
|
|
1385
|
+
ok = False
|
|
1386
|
+
if ok:
|
|
1387
|
+
out.append(i)
|
|
1388
|
+
i = text_low.find(al, i + 1)
|
|
1389
|
+
return out
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _constraints_in(text):
|
|
1393
|
+
"""一句话 -> {类别: (取值, 命中的词)},每类只留最长的那个词。
|
|
1394
|
+
|
|
1395
|
+
为什么要"每类只留最长":中文里"稳稳推进"同时命中"稳"(steady) 和"推进"(driving),
|
|
1396
|
+
两者在 must_have 里无法同时成立;取长的那条才符合人的本意(他在说"推进")。
|
|
1397
|
+
先按长度全局排序再吃掉重叠区间,"warm-paper"就整段算配色、不再顺带算一次"纸卷"。
|
|
1398
|
+
"""
|
|
1399
|
+
low = str(text or "").lower()
|
|
1400
|
+
hits = []
|
|
1401
|
+
for order, (cat, val, aliases) in enumerate(_CONSTRAINT_WORDS):
|
|
1402
|
+
for alias in aliases:
|
|
1403
|
+
for start in _alias_spans(low, alias):
|
|
1404
|
+
hits.append((-len(alias), start, order, cat, val, alias))
|
|
1405
|
+
hits.sort()
|
|
1406
|
+
taken, got = [], {}
|
|
1407
|
+
for neg_len, start, _order, cat, val, alias in hits:
|
|
1408
|
+
end = start - neg_len
|
|
1409
|
+
if any(not (end <= a or start >= b) for a, b in taken):
|
|
1410
|
+
continue # 落在更长的命中里:是那个词的一部分
|
|
1411
|
+
taken.append((start, end))
|
|
1412
|
+
got.setdefault(cat, (val, alias))
|
|
1413
|
+
return got
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _vocab(cat):
|
|
1417
|
+
"""某一类认得的词(只列中文,报错信息要一眼能读完)。"""
|
|
1418
|
+
return " / ".join(aliases[0] for c, _v, aliases in _CONSTRAINT_WORDS if c == cat)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _too_narrow(cat, want, banned):
|
|
1422
|
+
""""约束太窄"的统一措辞:必须点名是哪条,否则用户不知道该删哪个。"""
|
|
1423
|
+
bits = []
|
|
1424
|
+
if want is not None:
|
|
1425
|
+
bits.append("must_have「%s」把%s锁成 %s" % (want[2], _CATEGORY_WORD[cat], want[1]))
|
|
1426
|
+
for _val, word, src in banned:
|
|
1427
|
+
bits.append("avoid「%s」又排除了「%s」" % (src, word))
|
|
1428
|
+
return ("brief 的约束把%s的可选范围清空了:%s——没有任何设计能同时满足,请放宽其中一条"
|
|
1429
|
+
% (_CATEGORY_WORD[cat], ";".join(bits)))
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _resolve_constraints(brief):
|
|
1433
|
+
"""brief 的 must_have / avoid -> (must, avoid, domain, unmatched)。
|
|
1434
|
+
|
|
1435
|
+
must {类别: (取值, 命中的词, 原句)}
|
|
1436
|
+
avoid {类别: [(取值, 命中的词, 原句), ...]}
|
|
1437
|
+
domain {类别: 允许的取值元组;None = 这一类没被约束(保持 v1.0 的原采样域)}
|
|
1438
|
+
unmatched 一句都没认出来的原句(按出现顺序去重)——交给顶层提示用户"这条没生效"
|
|
1439
|
+
"""
|
|
1440
|
+
must_raw = [str(x) for x in (brief.get("must_have") or [])]
|
|
1441
|
+
avoid_raw = [str(x) for x in (brief.get("avoid") or [])]
|
|
1442
|
+
must, avoid, unmatched = {}, {}, []
|
|
1443
|
+
|
|
1444
|
+
for src in must_raw:
|
|
1445
|
+
hits = _constraints_in(src)
|
|
1446
|
+
if not hits:
|
|
1447
|
+
unmatched.append(src)
|
|
1448
|
+
continue
|
|
1449
|
+
for cat, (val, word) in hits.items():
|
|
1450
|
+
old = must.get(cat)
|
|
1451
|
+
if old is not None and old[0] != val:
|
|
1452
|
+
# 同类两条不同的要求 = 无解(一个设计只有一族配色/一种母题),
|
|
1453
|
+
# 这不是"再采几次碰运气"能救的,必须马上告诉用户是哪两条在打架。
|
|
1454
|
+
raise ValueError(
|
|
1455
|
+
"brief.must_have 的约束互斥:%s既要「%s」又要「%s」"
|
|
1456
|
+
"(一个设计只能选一个,没法同时满足)——去掉一条,或把另一条放进 avoid"
|
|
1457
|
+
% (_CATEGORY_WORD[cat], old[1], word))
|
|
1458
|
+
must[cat] = (val, word, src)
|
|
1459
|
+
|
|
1460
|
+
for src in avoid_raw:
|
|
1461
|
+
hits = _constraints_in(src)
|
|
1462
|
+
if not hits:
|
|
1463
|
+
if src not in unmatched:
|
|
1464
|
+
unmatched.append(src)
|
|
1465
|
+
continue
|
|
1466
|
+
for cat, (val, word) in hits.items():
|
|
1467
|
+
avoid.setdefault(cat, []).append((val, word, src))
|
|
1468
|
+
|
|
1469
|
+
for cat, (val, word, _src) in sorted(must.items()):
|
|
1470
|
+
for aval, aword, _asrc in avoid.get(cat, ()):
|
|
1471
|
+
if aval == val:
|
|
1472
|
+
raise ValueError(
|
|
1473
|
+
"brief 自相矛盾:「%s」在 must_have 里要 %s=%s,"
|
|
1474
|
+
"「%s」又在 avoid 里排除同一个值——没有任何候选能同时满足"
|
|
1475
|
+
% (word, _CATEGORY_WORD[cat], val, aword))
|
|
1476
|
+
|
|
1477
|
+
domain = {}
|
|
1478
|
+
for cat, values in _CATEGORY_VALUES.items():
|
|
1479
|
+
want = must.get(cat)
|
|
1480
|
+
banned = [v for v, _w, _s in avoid.get(cat, ())]
|
|
1481
|
+
if cat == "palette":
|
|
1482
|
+
# 配色是**连续色相空间**,不能按"枚举过滤"处理(那会直接清空可选范围)。
|
|
1483
|
+
# 约束在这里被翻译成色相窗口:要"深蓝"= 采 h≈210±26° 且暗色底;
|
|
1484
|
+
# avoid"放射"这类非配色词不会走到这;avoid"薄荷"= 跳过 168° 附近。
|
|
1485
|
+
if want is not None and want[0] not in _PALETTE_ANCHOR:
|
|
1486
|
+
raise ValueError("配色约束「%s」没有对应的色相锚点" % want[1])
|
|
1487
|
+
banned_hues = []
|
|
1488
|
+
for bv, bw, _bs in avoid.get(cat, ()):
|
|
1489
|
+
if bv not in _PALETTE_ANCHOR:
|
|
1490
|
+
raise ValueError("配色排除词「%s」没有对应的色相锚点" % bw)
|
|
1491
|
+
banned_hues.append(_PALETTE_ANCHOR[bv][0])
|
|
1492
|
+
if want is None and not banned_hues:
|
|
1493
|
+
domain[cat] = None
|
|
1494
|
+
else:
|
|
1495
|
+
domain[cat] = {"anchor": _PALETTE_ANCHOR[want[0]] if want else None,
|
|
1496
|
+
"banned_hues": tuple(banned_hues),
|
|
1497
|
+
"window": _HUE_WINDOW}
|
|
1498
|
+
continue
|
|
1499
|
+
allowed = tuple(v for v in values
|
|
1500
|
+
if (want is None or want[0] == v) and v not in banned)
|
|
1501
|
+
if not allowed:
|
|
1502
|
+
raise ValueError(_too_narrow(cat, want, avoid.get(cat, ())))
|
|
1503
|
+
domain[cat] = None if (want is None and not banned) else allowed
|
|
1504
|
+
|
|
1505
|
+
if must_raw and not must:
|
|
1506
|
+
# 单条认不出 -> 记录继续;全都认不出 -> 报错。差别在于:后一种情况下用户的
|
|
1507
|
+
# 要求被整体忽略却还返回"成功",比失败更危险(他会以为要求生效了)。
|
|
1508
|
+
raise ValueError(
|
|
1509
|
+
"brief.must_have 里没有一条能识别:%s\n"
|
|
1510
|
+
" 认得的说法:配色 %s / 母题 %s / 转场 %s / 情绪 %s / 声线 %s"
|
|
1511
|
+
% ("、".join("「%s」" % s for s in must_raw),
|
|
1512
|
+
_vocab("palette"), _vocab("motif"), _vocab("transition"),
|
|
1513
|
+
_vocab("mood"), _vocab("voice")))
|
|
1514
|
+
return must, avoid, domain, unmatched
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _narrow_hint(must, avoid):
|
|
1518
|
+
"""采样失败时的附加说明:把收窄了可选域的约束点名(没有约束就返回空,不乱加提示)。"""
|
|
1519
|
+
words = [w for _v, w, _s in must.values()]
|
|
1520
|
+
for cat in avoid:
|
|
1521
|
+
words.extend(w for _v, w, _s in avoid[cat])
|
|
1522
|
+
return "" if not words else ";收窄了可选域的约束:%s" % " / ".join(words)
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def _voices_for(genders):
|
|
1526
|
+
"""声线池按性别过滤——"女声"必须落在挑 id 之前,否则只能靠重采样碰运气。
|
|
1527
|
+
|
|
1528
|
+
只从"认得出性别"的声线里挑:响度表以后加了新命名,认不出的那些只是在有性别约束时
|
|
1529
|
+
不参与,绝不会因为认不出就被判成违规(那会让整批候选重采样到报错)。
|
|
1530
|
+
"""
|
|
1531
|
+
if not genders:
|
|
1532
|
+
return VOICES
|
|
1533
|
+
got = tuple(v for v in VOICES if _voice_gender_of(v) in genders)
|
|
1534
|
+
if not got:
|
|
1535
|
+
raise ValueError("声线池里没有 %s 的声线(VOICES=%s)"
|
|
1536
|
+
% ("/".join(genders), list(VOICES)))
|
|
1537
|
+
return got
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _palette_family_of(design):
|
|
1541
|
+
"""design -> 配色族名(反查 bg/bg2/surface 三个锚点;认不出返回 None)。
|
|
1542
|
+
|
|
1543
|
+
为什么反查而不是在 design 里多存一个 family 字段:design.json 要保持"自足",
|
|
1544
|
+
配色一旦存了族名,改族名就会让老设计漂移;三个底色是采样时原样写进去的,反查无歧义。
|
|
1545
|
+
"""
|
|
1546
|
+
pal = _dig(design, ("visual", "palette"))
|
|
1547
|
+
if not isinstance(pal, dict):
|
|
1548
|
+
return None
|
|
1549
|
+
try:
|
|
1550
|
+
bg = tuple(ST._color_of(pal.get("bg"), "visual.palette.bg"))
|
|
1551
|
+
except Exception: # noqa: BLE001
|
|
1552
|
+
return None
|
|
1553
|
+
# "同一个配色"= 底色的**色相 + 明暗**落在同一档。量化到 24° 是为了让
|
|
1554
|
+
# "差 3° 的两个色相"算同一种(人眼也分不出),而不是让每个颜色都算新配色。
|
|
1555
|
+
try:
|
|
1556
|
+
hue, _s, _v = ST.rgb_to_hsv(bg)
|
|
1557
|
+
except Exception: # noqa: BLE001
|
|
1558
|
+
return None
|
|
1559
|
+
return "%s-%02d" % ("dark" if ST.is_dark({"bg": bg}) else "light",
|
|
1560
|
+
int(round(hue / 24.0)) % 15)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _voice_ids_of(design):
|
|
1564
|
+
"""design 里用到的全部 edge voice id(主声线 + map 里的每一个)。"""
|
|
1565
|
+
vo = _dig(design, ("audio", "voice"))
|
|
1566
|
+
if isinstance(vo, str):
|
|
1567
|
+
return [vo]
|
|
1568
|
+
if not isinstance(vo, dict):
|
|
1569
|
+
return []
|
|
1570
|
+
ids = []
|
|
1571
|
+
if isinstance(vo.get("id"), str):
|
|
1572
|
+
ids.append(vo["id"])
|
|
1573
|
+
mp = vo.get("map")
|
|
1574
|
+
if isinstance(mp, dict):
|
|
1575
|
+
for k in sorted(mp, key=str):
|
|
1576
|
+
v = mp[k]
|
|
1577
|
+
ids.append(v.get("id") if isinstance(v, dict) else v)
|
|
1578
|
+
return [str(x) for x in ids if isinstance(x, str) and x]
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _by_id(rows, did):
|
|
1582
|
+
"""从已接受候选里按 id 取回设计对象(找不到返回一个空 dict:让距离判据退化为"全不同")。"""
|
|
1583
|
+
for row in rows or ():
|
|
1584
|
+
if isinstance(row, dict) and row.get("id") == did:
|
|
1585
|
+
return row
|
|
1586
|
+
return {}
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def _design_value(design, cat):
|
|
1590
|
+
"""design 在某个约束类别上的取值(声线要求"整条片子的声线都是这一性别")。"""
|
|
1591
|
+
if cat == "palette":
|
|
1592
|
+
return _palette_family_of(design)
|
|
1593
|
+
if cat == "motif":
|
|
1594
|
+
return _dig(design, ("visual", "motif", "kind"))
|
|
1595
|
+
if cat == "transition":
|
|
1596
|
+
return _dig(design, ("visual", "transition", "kind"))
|
|
1597
|
+
if cat == "mood":
|
|
1598
|
+
return _dig(design, ("audio", "music", "mood"))
|
|
1599
|
+
if cat == "voice":
|
|
1600
|
+
kinds = {_voice_gender_of(v) for v in _voice_ids_of(design)}
|
|
1601
|
+
return kinds.pop() if len(kinds) == 1 else "mixed"
|
|
1602
|
+
return None
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
def _constraint_problems(design, must, avoid):
|
|
1606
|
+
"""按 brief 复核一个候选:返回违反项(空 = 过)。采样器用它兜底,不信"域已经限住"。"""
|
|
1607
|
+
bad = []
|
|
1608
|
+
for cat, (val, word, _src) in sorted(must.items()):
|
|
1609
|
+
if cat == "palette":
|
|
1610
|
+
# 配色按**色相窗口**复核(不是"族名相等"):连续空间里"是不是深蓝"
|
|
1611
|
+
# 取决于底色的色相落在 210°±26° 内,而不是某个字符串匹配。
|
|
1612
|
+
got_h = _design_bg_hue(design)
|
|
1613
|
+
anchor = _PALETTE_ANCHOR.get(val)
|
|
1614
|
+
if got_h is None or anchor is None:
|
|
1615
|
+
bad.append("配色不可判定(%r / %s)" % (got_h, val))
|
|
1616
|
+
elif _hue_gap(got_h, anchor[0]) > _HUE_WINDOW:
|
|
1617
|
+
bad.append("配色=%.1f° 不满足 must_have「%s」(要 %s 的色相,即 %.0f°±%.0f°)"
|
|
1618
|
+
% (got_h, word, val, anchor[0], _HUE_WINDOW))
|
|
1619
|
+
continue
|
|
1620
|
+
got = _design_value(design, cat)
|
|
1621
|
+
if got != val:
|
|
1622
|
+
bad.append("%s=%r 不满足 must_have「%s」(%s 要 %r)"
|
|
1623
|
+
% (_CATEGORY_WORD[cat], got, word, _CATEGORY_WORD[cat], val))
|
|
1624
|
+
for cat, rows in sorted(avoid.items()):
|
|
1625
|
+
if cat == "palette":
|
|
1626
|
+
got_h = _design_bg_hue(design)
|
|
1627
|
+
for val, word, _src in rows:
|
|
1628
|
+
anchor = _PALETTE_ANCHOR.get(val)
|
|
1629
|
+
if got_h is not None and anchor and _hue_gap(got_h, anchor[0]) <= _HUE_WINDOW:
|
|
1630
|
+
bad.append("配色=%.1f° 违反了 avoid「%s」(落在 %s 的色相范围内)"
|
|
1631
|
+
% (got_h, word, val))
|
|
1632
|
+
continue
|
|
1633
|
+
got = _design_value(design, cat)
|
|
1634
|
+
for val, word, _src in rows:
|
|
1635
|
+
if got == val:
|
|
1636
|
+
bad.append("%s=%r 违反了 avoid「%s」" % (_CATEGORY_WORD[cat], got, word))
|
|
1637
|
+
return bad
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
def _design_bg_hue(design):
|
|
1641
|
+
"""取一个设计底色的色相;取不到返回 None。"""
|
|
1642
|
+
try:
|
|
1643
|
+
pal = _dig(design, ("visual", "palette"))
|
|
1644
|
+
bg = ST._color_of(pal.get("bg"), "visual.palette.bg")
|
|
1645
|
+
return float(ST.rgb_to_hsv(bg)[0])
|
|
1646
|
+
except Exception: # noqa: BLE001
|
|
1647
|
+
return None
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def _beats_for(segments):
|
|
1652
|
+
"""叙事用途序列:首 hook、尾 outro,中间按固定顺序取(不足就少取)。"""
|
|
1653
|
+
mid = ["context", "data", "compare", "process", "quote", "turn"]
|
|
1654
|
+
n = max(1, int(segments))
|
|
1655
|
+
if n == 1:
|
|
1656
|
+
return ["hook"]
|
|
1657
|
+
if n == 2:
|
|
1658
|
+
return ["hook", "outro"]
|
|
1659
|
+
return ["hook"] + mid[:n - 2] + ["outro"]
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def _voice_gender_of(vid):
|
|
1663
|
+
"""edge voice id -> "male"/"female";认不出返回 None(不猜:猜错等于把"女声"悄悄换成男声)。
|
|
1664
|
+
|
|
1665
|
+
口径取自 edge 中文声线的命名:Xiao*/Hsiao*/Hiu*(晓/曉)是女声,Yun*/Wan*(云/雲)是
|
|
1666
|
+
男声;香港粤语的 HiuMaan(女)/ WanLung(男)也在这条命名里。认不出的 id
|
|
1667
|
+
(wav 声线、别的引擎)一律"认不出",由调用方决定怎么处理——不猜。
|
|
1668
|
+
"""
|
|
1669
|
+
stem = str(vid or "").split("-")[-1]
|
|
1670
|
+
if stem.startswith(("Xiao", "Hsiao", "Hiu")):
|
|
1671
|
+
return "female"
|
|
1672
|
+
if stem.startswith(("Yun", "Wan")):
|
|
1673
|
+
return "male"
|
|
1674
|
+
return None
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _voice_gender(vid):
|
|
1678
|
+
"""pitch 文案用的单字:女 / 男(认不出的仍按男声写,与 v1.0 的输出一致)。"""
|
|
1679
|
+
return "女" if _voice_gender_of(vid) == "female" else "男"
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def _make_design(brief, index, seed, attempt, domain=None, unmatched=()):
|
|
1683
|
+
"""按 sha1(brief 规范 JSON + index + seed + attempt) 派生一个完整 design。
|
|
1684
|
+
|
|
1685
|
+
选择用"哈希 + 下标步进"而不是"每个字段独立随机":对 n<=8 的候选,
|
|
1686
|
+
配色族/母题/转场/编号/声线/情绪等维度按互质步长轮转,天然保证两两至少差 2 维;
|
|
1687
|
+
attempt 只在自检失败时参与,仍全是 sha1,没有 random。
|
|
1688
|
+
|
|
1689
|
+
domain 是 brief 约束算出来的"每类允许的取值"(None = 这一类不受限)。步进仍在
|
|
1690
|
+
**允许集**上做,所以"两两至少差 2 维"这条保证在过滤之后依然成立,而不是被过滤打散。
|
|
1691
|
+
"""
|
|
1692
|
+
cb = _brief_canon(brief)
|
|
1693
|
+
ctext = _canonical(cb)
|
|
1694
|
+
# nonce 是"这一次生成本身"的身份:默认每次调用新生成,传入 seed 时固定。
|
|
1695
|
+
# 它必须参与哈希,否则同一 brief 的第二次生成与第一次逐位相同(v1.0 的 bug)。
|
|
1696
|
+
nonce = brief.get("_nonce") if isinstance(brief, dict) else None
|
|
1697
|
+
tag = "%s|%s" % ("" if seed is None else str(seed), nonce or "")
|
|
1698
|
+
|
|
1699
|
+
def h(salt):
|
|
1700
|
+
return _digest(salt, ctext, tag, index, attempt)
|
|
1701
|
+
|
|
1702
|
+
dom = domain or {}
|
|
1703
|
+
# 配色 = 连续色相空间里的一个点(不再是 8 个名字之一)。
|
|
1704
|
+
# 色相按黄金角 137.5° 步进:相邻候选的色相必不接近(比固定步长更难撞色),
|
|
1705
|
+
# 且整体分布均匀 —— 这就是"每条片底色都不同"的来源。
|
|
1706
|
+
dark = h("dark") % 100 < 62 # 约 62% 暗色片(贴合"工程技术片"气质)
|
|
1707
|
+
sp = _PALETTE_SPACE[dark]
|
|
1708
|
+
# 配色:连续色相空间取点。若 brief 给了配色约束,就落在它的色相窗口内
|
|
1709
|
+
# (窗口内仍是连续的,不是"锁定到某一个预设")。
|
|
1710
|
+
pcon = dom.get("palette")
|
|
1711
|
+
if isinstance(pcon, dict):
|
|
1712
|
+
anchor = pcon.get("anchor")
|
|
1713
|
+
if anchor is not None:
|
|
1714
|
+
base_hue, dark = anchor
|
|
1715
|
+
# 窗口内按哈希散开,但**避开**用户明确排除的色相
|
|
1716
|
+
span = float(pcon.get("window") or _HUE_WINDOW)
|
|
1717
|
+
for k in range(24): # 最多试 24 个位置,全都撞 banned 才放弃
|
|
1718
|
+
hue = (base_hue + ((h("palette") + k * 11) % 100) / 100.0 * span - span / 2.0) % 360.0
|
|
1719
|
+
if not any(_hue_gap(hue, bh) < span for bh in (pcon.get("banned_hues") or ())):
|
|
1720
|
+
break
|
|
1721
|
+
else:
|
|
1722
|
+
hue = (h("palette") + index * 137.508) % 360.0
|
|
1723
|
+
banned_hues = pcon.get("banned_hues") or ()
|
|
1724
|
+
for k in range(24):
|
|
1725
|
+
if not any(_hue_gap(hue, bh) < _HUE_WINDOW for bh in banned_hues):
|
|
1726
|
+
break
|
|
1727
|
+
hue = (hue + 37.0) % 360.0
|
|
1728
|
+
else:
|
|
1729
|
+
dark = h("dark") % 100 < 62
|
|
1730
|
+
hue = (h("palette") + index * 137.508) % 360.0
|
|
1731
|
+
sp = _PALETTE_SPACE[dark]
|
|
1732
|
+
sat = sp["s"][0] + (h("sat") % 100) / 100.0 * (sp["s"][1] - sp["s"][0])
|
|
1733
|
+
val = sp["v"][0] + (h("val") % 100) / 100.0 * (sp["v"][1] - sp["v"][0])
|
|
1734
|
+
family = (hue, sat, val, dark)
|
|
1735
|
+
motifs = tuple(dom.get("motif") or MOTIFS)
|
|
1736
|
+
motif = motifs[(h("motif") + 3 * index) % len(motifs)]
|
|
1737
|
+
transes = tuple(dom.get("transition") or TRANSITIONS)
|
|
1738
|
+
trans = transes[(h("transition") + 5 * index) % len(transes)]
|
|
1739
|
+
ease = EASES[(h("ease") + index) % len(EASES)]
|
|
1740
|
+
number = NUMBERS[(h("number") + 3 * index) % len(NUMBERS)]
|
|
1741
|
+
moods = tuple(dom.get("mood") or MOODS)
|
|
1742
|
+
mood = moods[(h("mood") + 3 * index) % len(moods)]
|
|
1743
|
+
pool = _voices_for(dom.get("voice"))
|
|
1744
|
+
voice_index = (h("voice") + index) % len(pool)
|
|
1745
|
+
scale = SCALES[(h("scale") + index) % len(SCALES)]
|
|
1746
|
+
layout = LAYOUTS[(h("layout") + index) % len(LAYOUTS)]
|
|
1747
|
+
hud = HUDS[(h("hud") + index) % len(HUDS)]
|
|
1748
|
+
align = ALIGNS[(h("align") + index) % len(ALIGNS)]
|
|
1749
|
+
font_family = FONT_FAMILIES[(h("family") + index) % len(FONT_FAMILIES)]
|
|
1750
|
+
|
|
1751
|
+
# ---- 片内版式轮换(v1.0.2,原则 5)----
|
|
1752
|
+
# 以前一章只能用一种版式:design 的 visual.layout 是单值 → grammar 把它当"全片默认",
|
|
1753
|
+
# 于是 8 段片子 8 屏都是同一个骨架(左标题右卡片 / 居中大字 / 右标题左卡片 里挑一个),
|
|
1754
|
+
# 观众看到的是"长时间重复"。现在默认派生 2–3 种版式的轮换序列,
|
|
1755
|
+
# 由 grammar 按**章节**轮换(同一章内稳定,换章才换版式)。
|
|
1756
|
+
# 用户仍可在 design.visual.layout_cycle 里显式指定(会覆盖这里)。
|
|
1757
|
+
_others = [x for x in LAYOUTS if x != layout]
|
|
1758
|
+
_rot = (h("layout_rot") + index * 3) % len(_others)
|
|
1759
|
+
_others = _others[_rot:] + _others[:_rot]
|
|
1760
|
+
cycle_len = 2 if h("layout_n") % 2 == 0 else 3
|
|
1761
|
+
layout_cycle = [layout] + _others[:max(0, cycle_len - 1)]
|
|
1762
|
+
|
|
1763
|
+
# ---- 字幕样式(v1.0.2,原则 2/5)----
|
|
1764
|
+
# 过去字幕字号/位置/背板/圆角是 filmkit 里的模块常量:任何两条片子字幕都一模一样,
|
|
1765
|
+
# 是"跨片同质化"里最稳定的一条重复。现在进设计(可选字段,写进 design.json 可改)。
|
|
1766
|
+
# 取值范围都留在"好看"的区间内:字号 40–56、背板透明度 96–208、圆角 2–24。
|
|
1767
|
+
sub_size = 40 + (h("subsize") % 5) * 4 # 40..56
|
|
1768
|
+
sub_align = "center" if h("subalign") % 4 else "left" # 多数居中,偶尔左对齐
|
|
1769
|
+
sub_y = None if h("suby") % 3 else (560 + (h("suby2") % 9) * 40) # 1/3 概率抬高
|
|
1770
|
+
subtitle = {
|
|
1771
|
+
"size": int(sub_size),
|
|
1772
|
+
"align": sub_align,
|
|
1773
|
+
"plate_alpha": int(96 + (h("subalpha") % 8) * 14), # 96..194
|
|
1774
|
+
"radius": int(2 + (h("subr") % 4) * 6), # 2..20
|
|
1775
|
+
"max_lines": 2 if h("sublines") % 4 else 1,
|
|
1776
|
+
}
|
|
1777
|
+
if sub_y is not None:
|
|
1778
|
+
subtitle["y"] = int(sub_y)
|
|
1779
|
+
_sub_fam = h("subfam") % 3
|
|
1780
|
+
if _sub_fam == 0:
|
|
1781
|
+
subtitle["family"] = font_family # 与大标题同族
|
|
1782
|
+
elif _sub_fam == 1:
|
|
1783
|
+
subtitle["family"] = "sans" # 字幕用黑体(可读性最好)
|
|
1784
|
+
|
|
1785
|
+
hue_shift = ((h("hue") % 161) - 80) / 10.0 # -8.0 .. +8.0 度
|
|
1786
|
+
target_shift = ((h("sv") % 9) - 4) / 100.0 # -0.04 .. +0.04
|
|
1787
|
+
palette = _solve_palette(family, hue_shift, target_shift)
|
|
1788
|
+
|
|
1789
|
+
density = _DENSITIES[(h("density") + index) % len(_DENSITIES)]
|
|
1790
|
+
push = _PUSHES[(h("push") + index) % len(_PUSHES)]
|
|
1791
|
+
breathe = _BREATHES[(h("breathe") + index) % len(_BREATHES)]
|
|
1792
|
+
enter = list(_ENTERS[(h("enter") + index) % len(_ENTERS)])
|
|
1793
|
+
x = _XS[(h("bx") + index) % len(_XS)]
|
|
1794
|
+
y = _YS[(h("by") + index) % len(_YS)]
|
|
1795
|
+
inset = _INSETS[(h("inset") + index) % len(_INSETS)]
|
|
1796
|
+
|
|
1797
|
+
ratio = round(1.9 + (h("ratio") % 18) * 0.1, 1) # 1.9 .. 3.6
|
|
1798
|
+
tracking = round((h("tracking") % 17) * 0.5, 1) # 0 .. 8
|
|
1799
|
+
line_gap = round(1.15 + (h("gap") % 9) * 0.05, 2) # 1.15 .. 1.55
|
|
1800
|
+
|
|
1801
|
+
segs = 4 + h("segments") % 5 # 4 .. 8
|
|
1802
|
+
target_sec = None
|
|
1803
|
+
dt = brief.get("duration_target")
|
|
1804
|
+
if dt is not None:
|
|
1805
|
+
segs = max(2, min(40, int(round(float(dt) / 7.0))))
|
|
1806
|
+
target_sec = float(dt)
|
|
1807
|
+
hold_policy = HOLD_POLICIES[h("hold") % len(HOLD_POLICIES)]
|
|
1808
|
+
|
|
1809
|
+
lead = round(0.45 + (h("lead") % 9) * 0.05, 2)
|
|
1810
|
+
tail = round(0.75 + (h("tail") % 9) * 0.05, 2)
|
|
1811
|
+
min_dur = round(2.6 + (h("min") % 11) * 0.1, 2)
|
|
1812
|
+
hold = round((h("hold_sec") % 5) * 0.1, 2)
|
|
1813
|
+
stagger = round(0.24 + (h("stagger") % 15) * 0.02, 2)
|
|
1814
|
+
|
|
1815
|
+
bpm = _MOOD_BPM[mood] + (h("bpm") % 9) - 4
|
|
1816
|
+
keys = ST.MINOR_KEYS if scale == "minor" else ST.MAJOR_KEYS
|
|
1817
|
+
key = keys[h("key") % len(keys)]
|
|
1818
|
+
music_seed = h("mseed") % 100000
|
|
1819
|
+
|
|
1820
|
+
vcount = 1 + h("vcount") % 3
|
|
1821
|
+
picks = [pool[(voice_index + j) % len(pool)] for j in range(vcount)]
|
|
1822
|
+
vmap = {kind: picks[j % vcount] for j, kind in enumerate(VOICE_KINDS)}
|
|
1823
|
+
jitter = round(0.10 + (h("jitter") % 5) * 0.05, 2)
|
|
1824
|
+
|
|
1825
|
+
did = "design-" + hashlib.sha1(
|
|
1826
|
+
("%s|%s|%s|%s" % (ctext, tag, index, attempt)).encode("utf-8")
|
|
1827
|
+
).hexdigest()[:8]
|
|
1828
|
+
# 标签描述"这配色长什么样",而不是"它属于哪一套"(没有套了)
|
|
1829
|
+
fam_label = ("深" if dark else "浅") + "色 h%03d" % int(round(hue) % 360)
|
|
1830
|
+
label = "%s·%s" % (fam_label, _MOTIF_WORD[motif])
|
|
1831
|
+
pitch = "%s×%s:%s,%s转场,%s声线,%d BPM %s。" % (
|
|
1832
|
+
fam_label, _MOTIF_WORD[motif], _MOOD_WORD[mood], _TRANS_WORD[trans],
|
|
1833
|
+
_voice_gender(picks[0]), bpm, "小调" if scale == "minor" else "大调")
|
|
1834
|
+
|
|
1835
|
+
return {
|
|
1836
|
+
"schema": SCHEMA,
|
|
1837
|
+
"id": did,
|
|
1838
|
+
"label": label,
|
|
1839
|
+
"pitch": pitch,
|
|
1840
|
+
"structure": {
|
|
1841
|
+
"segments": int(segs),
|
|
1842
|
+
"beats": _beats_for(segs),
|
|
1843
|
+
"hold_policy": hold_policy,
|
|
1844
|
+
"target_sec": target_sec,
|
|
1845
|
+
},
|
|
1846
|
+
"visual": {
|
|
1847
|
+
"palette": {k: list(palette[k]) for k in PALETTE_KEYS},
|
|
1848
|
+
"family": font_family,
|
|
1849
|
+
"typography": {"tracking": tracking, "line_gap": line_gap,
|
|
1850
|
+
"number": number, "ratio": ratio, "align": align},
|
|
1851
|
+
"motif": {"kind": motif, "density": density, "detail": {}},
|
|
1852
|
+
"transition": {"kind": trans,
|
|
1853
|
+
"dur": 0.0 if trans == "cut" else round(0.20 + (h("dur") % 5) * 0.05, 2)},
|
|
1854
|
+
"motion": {"ease": ease, "enter": enter,
|
|
1855
|
+
"push": push, "breathe_hz": breathe},
|
|
1856
|
+
"bias": {"x": x, "y": y, "inset": inset},
|
|
1857
|
+
"layout": layout,
|
|
1858
|
+
"layout_cycle": list(layout_cycle),
|
|
1859
|
+
"hud": hud,
|
|
1860
|
+
"size": "1920x1080",
|
|
1861
|
+
"fps": 30,
|
|
1862
|
+
"subtitle": subtitle,
|
|
1863
|
+
},
|
|
1864
|
+
"audio": {
|
|
1865
|
+
"voice": {"mode": "edge_id", "id": picks[0], "map": vmap,
|
|
1866
|
+
"jitter": jitter, "max_distinct": vcount},
|
|
1867
|
+
"music": {"mode": "params", "scale": scale,
|
|
1868
|
+
"bpm": int(bpm), "key": key, "mood": mood, "seed": int(music_seed)},
|
|
1869
|
+
"sfx": {"enabled": True,
|
|
1870
|
+
"density": SFX_DENSITIES[1 + h("sfx") % 3],
|
|
1871
|
+
"gain_db": -9 + h("sfxgain") % 7},
|
|
1872
|
+
},
|
|
1873
|
+
"pacing": {"lead": lead, "tail": tail, "min_dur": min_dur,
|
|
1874
|
+
"hold": hold, "item_stagger": stagger},
|
|
1875
|
+
# 认不出来的要求:随候选一起走,顶层(propose/summary)才能提示"这条没生效"。
|
|
1876
|
+
"_unmatched_constraints": list(unmatched),
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
def sample_designs(brief, n=3, seed=None):
|
|
1881
|
+
"""现场采样 n 个两两可区分的 design(brief 的 must_have/avoid 在这里硬过滤)。
|
|
1882
|
+
|
|
1883
|
+
**默认"每次都不一样"**(v1.0.1):
|
|
1884
|
+
- 不传 seed -> 每次调用都生成一个**新的 nonce**,同一 brief 跑两次得到不同设计;
|
|
1885
|
+
- 传 seed -> 固定 nonce,逐字节可复现(调试、回归、写文档示例时用)。
|
|
1886
|
+
为什么把默认反过来:v1.0 的默认是"同一 brief 永远同一结果",实测两次独立进程
|
|
1887
|
+
产出的指纹完全相同(31f64edd34e502f9 / 52d385953835379e)——那等于把"没有模板"
|
|
1888
|
+
做成了"模板藏在哈希里",用户要新片子只能改 brief 措辞。这不满足"每条视频都不一样"。
|
|
1889
|
+
|
|
1890
|
+
其余行为不变:
|
|
1891
|
+
- 所有选择由 sha1(非对称 nonce + brief 规范 JSON + index + seed + attempt) 派生,
|
|
1892
|
+
全程禁 random / time / hash()(str hash 每进程带随机盐);
|
|
1893
|
+
- must_have/avoid 先解析成"每类允许的取值",下标步进在**允许集**上轮转:
|
|
1894
|
+
约束于是不靠重采样碰运气(声线池只有 3 个女声时,"碰 3 次全中"的概率低到不可用);
|
|
1895
|
+
- 每个候选先过 validate_design,再按 brief 复核一遍约束(不信"域已经限住");
|
|
1896
|
+
- 与已接受的候选至少 2 个维度不同(design_distance 自检),否则换 attempt;
|
|
1897
|
+
- 最多 50 次仍不行 -> raise,并点名是哪条约束把可选域收窄了(绝不静默放宽)。
|
|
1898
|
+
"""
|
|
1899
|
+
_check_brief(brief)
|
|
1900
|
+
if not _is_int(n) or n < 1:
|
|
1901
|
+
raise ValueError("n 必须是正整数,收到 %r" % (n,))
|
|
1902
|
+
|
|
1903
|
+
must, avoid, domain, unmatched = _resolve_constraints(brief)
|
|
1904
|
+
hint = _narrow_hint(must, avoid)
|
|
1905
|
+
# 显式 seed = 可复现(同 seed 同结果);不给 seed = 每次新 nonce(同 brief 也不同片)。
|
|
1906
|
+
# 随机源用 os.urandom 而不是 random:这里要的是"不可预测",不是"伪随机序列",
|
|
1907
|
+
# 而且 nonce 只参与 sha1 输入,不参与任何需要序列性质的运算。
|
|
1908
|
+
if seed is None:
|
|
1909
|
+
brief = dict(brief)
|
|
1910
|
+
brief["_nonce"] = os.urandom(8).hex()
|
|
1911
|
+
elif "_nonce" not in brief:
|
|
1912
|
+
brief = dict(brief)
|
|
1913
|
+
brief["_nonce"] = ""
|
|
1914
|
+
|
|
1915
|
+
out = []
|
|
1916
|
+
for i in range(n):
|
|
1917
|
+
chosen = None
|
|
1918
|
+
problems = None
|
|
1919
|
+
too_close = None
|
|
1920
|
+
violated = None
|
|
1921
|
+
why_not = []
|
|
1922
|
+
for attempt in range(_MAX_RESAMPLE):
|
|
1923
|
+
why_not = []
|
|
1924
|
+
cand = _make_design(brief, i, seed, attempt, domain, unmatched)
|
|
1925
|
+
problems = validate_design(cand)
|
|
1926
|
+
if problems:
|
|
1927
|
+
why_not.append("没过校验:%s" % problems[0])
|
|
1928
|
+
continue
|
|
1929
|
+
violated = _constraint_problems(cand, must, avoid)
|
|
1930
|
+
if violated:
|
|
1931
|
+
why_not.append("违反 brief 约束:%s" % violated[0])
|
|
1932
|
+
continue
|
|
1933
|
+
too_close = [o["id"] for o in out
|
|
1934
|
+
if design_distance(cand, o)["n"] < 2]
|
|
1935
|
+
if too_close:
|
|
1936
|
+
why_not.append("与前序候选不足 2 维差异:%s" % " / ".join(too_close))
|
|
1937
|
+
continue
|
|
1938
|
+
# 底色必须和已接受候选**拉开色相**。为什么单独管这一条:
|
|
1939
|
+
# design_distance 会在"母题/转场/缓动/编号"上找到足够的差异,
|
|
1940
|
+
# 于是两个候选可能共用同一片底色却仍被判为"可区分"。
|
|
1941
|
+
# 实测样例(v1.0):A=近黑金·电影遮幅 / B=近黑金·细线页眉 —— 并排图里第一眼
|
|
1942
|
+
# 就是同一个片子的两种排法,用户不会认为那是两个方案。
|
|
1943
|
+
# 底色是整屏第一眼信息,必须先分叉。
|
|
1944
|
+
#
|
|
1945
|
+
# v1.0.2 修:这里以前按"配色族"(色相量化到 24° 一档)判重。问题是有
|
|
1946
|
+
# must_have 配色约束时,可选色相被压到 ±26° 窗口里,24° 分桶只剩 2–3 档,
|
|
1947
|
+
# n=3 要求 3 个不同族 → 重采样 50 次成功率约一半;"暖纸色+女声"这类
|
|
1948
|
+
# 常见组合实测 20/20 全失败(brief 本身完全合理)。现在改成直接比**底色
|
|
1949
|
+
# 色相距离**:窗口内只要彼此 ≥ _PALETTE_KEEP_GAP 度就算两片不同底,
|
|
1950
|
+
# 约束不再和判重打架,候选数量也不再受"分桶数"限制。
|
|
1951
|
+
if out:
|
|
1952
|
+
hue = _design_bg_hue(cand)
|
|
1953
|
+
clash = None
|
|
1954
|
+
min_gap = 360.0
|
|
1955
|
+
if hue is not None:
|
|
1956
|
+
for o in out:
|
|
1957
|
+
oh = _design_bg_hue(o)
|
|
1958
|
+
if oh is None:
|
|
1959
|
+
continue
|
|
1960
|
+
gap = _hue_gap(hue, oh)
|
|
1961
|
+
min_gap = min(min_gap, gap)
|
|
1962
|
+
if gap < _PALETTE_KEEP_GAP:
|
|
1963
|
+
clash = (o["id"], gap)
|
|
1964
|
+
break
|
|
1965
|
+
# 分档判重(v1.0.2 再修):brief 把配色约束到窗口里时(如"深蓝"= 210°±26°),
|
|
1966
|
+
# 52° 的窗口放不下"3 个两两差 12°"的底色 —— 硬要求会让 n=3 有近一半概率
|
|
1967
|
+
# 采样失败(实测 19/40)。所以分两档:
|
|
1968
|
+
# · 色相差 < 6°:无论别的维度多不同,并排看都是同一片底色 -> 必须换;
|
|
1969
|
+
# · 色相差 < 12°:只有在**结构差异够强**(≥3 个维度不同)时才接受,
|
|
1970
|
+
# 这正是"第一眼是不同方案"的判据(底色接近但版式/母题/转场/字体全变)。
|
|
1971
|
+
if clash is not None and (min_gap < 6.0 or design_distance(cand, _by_id(out, clash[0]))["n"] < 3):
|
|
1972
|
+
why_not.append("底色色相与前序候选 %s 只差 %.1f°(色相差太小,并排看是同一片)"
|
|
1973
|
+
% (clash[0], clash[1]))
|
|
1974
|
+
continue
|
|
1975
|
+
chosen = cand
|
|
1976
|
+
break
|
|
1977
|
+
if chosen is None:
|
|
1978
|
+
detail = ("\n 最后一次被拒的原因:%s" % why_not[0]) if why_not else ""
|
|
1979
|
+
if violated:
|
|
1980
|
+
raise ValueError(
|
|
1981
|
+
"第 %d 个候选重采样 %d 次仍不满足 brief 约束:\n - %s%s\n"
|
|
1982
|
+
" 怎么办:① 放宽 brief(去掉一条 must_have/avoid);"
|
|
1983
|
+
"② 减少候选数 --n 2;③ 用 --seed 固定后重试。"
|
|
1984
|
+
% (i + 1, _MAX_RESAMPLE, "\n - ".join(violated), hint))
|
|
1985
|
+
if problems:
|
|
1986
|
+
raise ValueError("第 %d 个候选重采样 %d 次仍没过校验:\n - %s%s%s"
|
|
1987
|
+
% (i + 1, _MAX_RESAMPLE, "\n - ".join(problems), hint, detail))
|
|
1988
|
+
raise ValueError(
|
|
1989
|
+
"第 %d 个候选重采样 %d 次仍与前序候选太像(最像 %s)%s%s\n"
|
|
1990
|
+
" 怎么办:① 减少候选数 --n 2;② 放宽 brief 里收窄可选域的约束;"
|
|
1991
|
+
"③ 用 --seed 固定后重试。"
|
|
1992
|
+
% (i + 1, _MAX_RESAMPLE, " / ".join(too_close or ["?"]), hint, detail))
|
|
1993
|
+
out.append(chosen)
|
|
1994
|
+
return out
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
# ============================================================================
|
|
1998
|
+
# 九、CLI
|
|
1999
|
+
# ============================================================================
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
def _setup_stdio():
|
|
2003
|
+
for stream in (sys.stdout, sys.stderr):
|
|
2004
|
+
try:
|
|
2005
|
+
if stream is not None and not stream.isatty():
|
|
2006
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
2007
|
+
except Exception: # noqa: BLE001
|
|
2008
|
+
pass
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
def _palette_rows(n=8):
|
|
2012
|
+
"""演示用:从连续色相空间均匀取 n 个点,各自的解出结果。
|
|
2013
|
+
|
|
2014
|
+
早先这里列的是 8 个命名配色族("深蓝/近黑金/冷薄荷…")。配色改成连续空间后
|
|
2015
|
+
没有"族"可列了,但**还是得让人看见配色能长什么样**——所以均匀采样给你看,
|
|
2016
|
+
而不是给一份可选清单。这不是模板:打印出来的每个点都可以再调(h/s/v 都是连续量)。
|
|
2017
|
+
"""
|
|
2018
|
+
rows = []
|
|
2019
|
+
for i in range(int(n)):
|
|
2020
|
+
hue = (i * 360.0 / max(1, int(n))) % 360.0
|
|
2021
|
+
dark = i % 3 != 2 # 每三个里有一个浅色,便于对照
|
|
2022
|
+
sp = _PALETTE_SPACE[dark]
|
|
2023
|
+
sat = (sp["s"][0] + sp["s"][1]) / 2.0
|
|
2024
|
+
val = (sp["v"][0] + sp["v"][1]) / 2.0
|
|
2025
|
+
row = {"hue": round(hue, 1), "sat": round(sat, 3), "val": round(val, 3),
|
|
2026
|
+
"dark": dark, "label": ("深" if dark else "浅") + "色 h%03d" % int(hue)}
|
|
2027
|
+
try:
|
|
2028
|
+
pal = _solve_palette((hue, sat, val, dark))
|
|
2029
|
+
row["ink_hex"] = _hex(pal["ink"])
|
|
2030
|
+
row["accent_hex"] = _hex(pal["accent"])
|
|
2031
|
+
row["bg_hex"] = _hex(pal["bg"])
|
|
2032
|
+
row["surface_hex"] = _hex(pal["surface"])
|
|
2033
|
+
except Exception as e: # noqa: BLE001
|
|
2034
|
+
row["ink_hex"] = row["accent_hex"] = row["bg_hex"] = row["surface_hex"] = ""
|
|
2035
|
+
row["error"] = str(e)
|
|
2036
|
+
rows.append(row)
|
|
2037
|
+
return rows
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
def main(argv=None):
|
|
2041
|
+
_setup_stdio()
|
|
2042
|
+
ap = argparse.ArgumentParser(description="video-studio 设计 DSL:校验 / 采样 / 列配色族")
|
|
2043
|
+
grp = ap.add_mutually_exclusive_group()
|
|
2044
|
+
grp.add_argument("--sample", metavar="BRIEF", default=None,
|
|
2045
|
+
help="按题材采样 N 个设计")
|
|
2046
|
+
grp.add_argument("--lint", metavar="PATH", default=None,
|
|
2047
|
+
help="校验一份 design.json")
|
|
2048
|
+
grp.add_argument("--list-palettes", action="store_true", help="列出配色族")
|
|
2049
|
+
ap.add_argument("--n", type=int, default=3, help="候选数(默认 3)")
|
|
2050
|
+
ap.add_argument("--seed", default=None, help="采样种子(同一 seed 结果可复现)")
|
|
2051
|
+
ap.add_argument("--json", action="store_true", help="只输出 JSON(stdout)")
|
|
2052
|
+
a = ap.parse_args(argv)
|
|
2053
|
+
|
|
2054
|
+
if a.list_palettes:
|
|
2055
|
+
# v1.0.2 修:这里以前读 r["family"]/r["bg"]/r["accent_hue"],而 _palette_rows()
|
|
2056
|
+
# 在 v1.0.1 改成连续色相空间后返回的是 hue/dark/ink_hex/... —— 键名对不上,
|
|
2057
|
+
# 命令直接 KeyError: 'family'(--json 分支反而正常,所以自检没发现)。
|
|
2058
|
+
# 现在按行里真实存在的键打印,并说明"这不是可选清单"。
|
|
2059
|
+
rows = _palette_rows()
|
|
2060
|
+
if a.json:
|
|
2061
|
+
print(json.dumps(rows, ensure_ascii=False, indent=1))
|
|
2062
|
+
else:
|
|
2063
|
+
print("配色采样示例(%d 个点,不是可选清单):" % len(rows))
|
|
2064
|
+
print(" 底色 = (色相 h°, 饱和 s, 明度 v) 连续空间里的一点;h 可在 0–360 任意取,"
|
|
2065
|
+
"下面只是均匀散开的样点。")
|
|
2066
|
+
for r in rows:
|
|
2067
|
+
extra = (" ERROR=%s" % r["error"]) if r.get("error") else ""
|
|
2068
|
+
print(" %-12s h=%6.1f s=%.2f v=%.2f bg=%s surface=%s ink=%s accent=%s%s"
|
|
2069
|
+
% (r["label"], r["hue"], r["sat"], r["val"],
|
|
2070
|
+
r["bg_hex"] or "-", r["surface_hex"] or "-",
|
|
2071
|
+
r["ink_hex"] or "-", r["accent_hex"] or "-", extra))
|
|
2072
|
+
return 0
|
|
2073
|
+
|
|
2074
|
+
if a.lint:
|
|
2075
|
+
try:
|
|
2076
|
+
with open(a.lint, encoding="utf-8") as fh:
|
|
2077
|
+
design = json.load(fh)
|
|
2078
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
2079
|
+
print("[designkit] 读不出 %s:%s" % (a.lint, e), file=sys.stderr)
|
|
2080
|
+
return 2
|
|
2081
|
+
bad = validate_design(design)
|
|
2082
|
+
if a.json:
|
|
2083
|
+
print(json.dumps({"path": a.lint, "ok": not bad, "problems": bad},
|
|
2084
|
+
ensure_ascii=False, indent=1))
|
|
2085
|
+
elif bad:
|
|
2086
|
+
print("设计没过校验(%d 条):" % len(bad))
|
|
2087
|
+
for s in bad:
|
|
2088
|
+
print(" -", s)
|
|
2089
|
+
else:
|
|
2090
|
+
print("设计 OK:%s" % design.get("id"))
|
|
2091
|
+
return 0 if not bad else 1
|
|
2092
|
+
|
|
2093
|
+
if a.sample is not None:
|
|
2094
|
+
try:
|
|
2095
|
+
designs = sample_designs({"topic": a.sample}, n=a.n, seed=a.seed)
|
|
2096
|
+
except (TypeError, ValueError, RuntimeError) as e:
|
|
2097
|
+
print("[designkit] %s" % e, file=sys.stderr)
|
|
2098
|
+
return 2
|
|
2099
|
+
if a.json:
|
|
2100
|
+
print(json.dumps(designs, ensure_ascii=False, indent=1))
|
|
2101
|
+
return 0
|
|
2102
|
+
print("方案(brief=%r,n=%d):" % (a.sample, len(designs)))
|
|
2103
|
+
for i, d in enumerate(designs):
|
|
2104
|
+
print(" [%s] %-14s %s" % ("ABCDEFGH"[i] if i < 8 else i + 1,
|
|
2105
|
+
d["label"], d["pitch"]))
|
|
2106
|
+
print("两两差异(n = 不同的设计维度数):")
|
|
2107
|
+
for i in range(len(designs)):
|
|
2108
|
+
for j in range(i + 1, len(designs)):
|
|
2109
|
+
dist = design_distance(designs[i], designs[j])
|
|
2110
|
+
print(" %s-%s n=%d %s"
|
|
2111
|
+
% ("ABCDEFGH"[i], "ABCDEFGH"[j], dist["n"],
|
|
2112
|
+
" / ".join(dist["axes"])))
|
|
2113
|
+
return 0
|
|
2114
|
+
|
|
2115
|
+
ap.print_help()
|
|
2116
|
+
return 0
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
if __name__ == "__main__":
|
|
2120
|
+
sys.exit(main())
|