@dsh-gis/dsh-video-studio-skill 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +84 -108
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/package.json +1 -1
  5. package/schemas/design.schema.json +5 -5
  6. package/schemas/film.schema.json +31 -31
  7. package/skills/video-studio/SKILL.md +168 -268
  8. package/skills/video-studio/assets/film.json +2 -3
  9. package/skills/video-studio/assets/golden_frames.json +11 -11
  10. package/skills/video-studio/assets/scenes.py +92 -49
  11. package/skills/video-studio/references/{style-packs.md → appearance.md} +17 -15
  12. package/skills/video-studio/references/audio-variation.md +73 -96
  13. package/skills/video-studio/references/audio.md +40 -44
  14. package/skills/video-studio/references/change-impact.md +2 -2
  15. package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +9 -11
  16. package/skills/video-studio/references/frame-qa.md +47 -15
  17. package/skills/video-studio/references/pipeline.md +4 -4
  18. package/skills/video-studio/references/pitfalls.md +22 -3
  19. package/skills/video-studio/references/proposal.md +19 -36
  20. package/skills/video-studio/references/quality-gates.md +11 -11
  21. package/skills/video-studio/references/scene-authoring.md +2 -2
  22. package/skills/video-studio/references/scene-grammar.md +17 -24
  23. package/skills/video-studio/references/sfx-design.md +7 -5
  24. package/skills/video-studio/references/web-engine.md +7 -7
  25. package/skills/video-studio/references/word-timeline.md +7 -7
  26. package/skills/video-studio/requirements.txt +9 -13
  27. package/skills/video-studio/scripts/designkit.py +84 -89
  28. package/skills/video-studio/scripts/filmcheck.py +19 -19
  29. package/skills/video-studio/scripts/filmkit.py +2949 -2948
  30. package/skills/video-studio/scripts/gates.py +4 -4
  31. package/skills/video-studio/scripts/grammar.py +10 -10
  32. package/skills/video-studio/scripts/musicgen.py +3 -4
  33. package/skills/video-studio/scripts/preflight.py +5 -5
  34. package/skills/video-studio/scripts/previewkit.py +11 -12
  35. package/skills/video-studio/scripts/propose.py +11 -11
  36. package/skills/video-studio/scripts/qa_frames.py +34 -35
  37. package/skills/video-studio/scripts/qaasr.py +26 -7
  38. package/skills/video-studio/scripts/qavmaf.py +19 -19
  39. package/skills/video-studio/scripts/render.py +137 -141
  40. package/skills/video-studio/scripts/scorekit.py +21 -23
  41. package/skills/video-studio/scripts/selfcheck.py +208 -135
  42. package/skills/video-studio/scripts/sfxkit.py +27 -26
  43. package/skills/video-studio/scripts/stagewatch.py +4 -4
  44. package/skills/video-studio/scripts/stylekit.py +77 -282
  45. package/skills/video-studio/scripts/voicekit.py +43 -44
  46. package/skills/video-studio/scripts/webengine.py +62 -64
  47. package/tools/audio_similarity.py +12 -12
  48. package/tools/ci_check.py +16 -16
  49. package/tools/estimate.py +24 -25
  50. package/tools/golden_frames.py +17 -18
  51. package/tools/silhouette_qa.py +13 -14
@@ -1,4 +1,4 @@
1
- # 词级时间轴与词锚点(v0.8.0)
1
+ # 词级时间轴与词锚点
2
2
 
3
3
  > 本页是 `work/words.json` 的**契约**、`cue at` 词锚点的**语义**,以及四个在真实跑里踩过的坑。
4
4
  > 设计前提一句话:**句子时长仍以 TTS 实测时长为权威**;词级时间戳只是附加值,
@@ -28,8 +28,8 @@ timeline.json 的 scenes[].words_abs (句内词表平移到绝对时间)
28
28
  | 防呆 | 为什么 |
29
29
  |---|---|
30
30
  | 显式 `boundary="WordBoundary"` + `stream()` | 用 `save()` 会把词事件丢掉;不传 `boundary` 时默认 `SentenceBoundary`,**一条词事件都收不到**(实测 edge-tts 7.3.1 / zh-CN:词事件数 = 0) |
31
- | 音频先写 `.part` 再 `os.replace` | 旧写法直接 `open(目标, "wb")`,一次断网/超时就把**已缓存的好音频截成 0 字节**。现在失败只丢本次新音频,旧缓存还在 |
32
- | 词表独立缓存键 `tts_words_key` | 并进 `tts_key_v2` 会让**所有老项目的音频缓存一次性失效**(必须整片重新联网合成)。分开记 -> 老音频照旧复用,只在"这次显式要词级"时补采一次 |
31
+ | 音频先写 `.part` 再 `os.replace` | 直接 `open(目标, "wb")` 时,一次断网/超时就把**已缓存的好音频截成 0 字节**;先写 `.part` 再替换,失败只丢本次新音频,旧缓存还在 |
32
+ | 词表独立缓存键 `tts_words_key` | 并进 `tts_key_v2` 会让**全部已有音频缓存一次性失效**(必须整片重新联网合成)。分开记 -> 已有音频照旧复用,只在"这次显式要词级"时补采一次 |
33
33
 
34
34
  ### 1.1 `work/words.json` 契约
35
35
 
@@ -68,9 +68,9 @@ G10 会因此记 `skipped` 或 `fail`(看是全部为空还是个别句为空
68
68
  | `words_summary(words, dur=None)` | 出 `n / covered / gaps / tail / mono / start`(G10 判据全在这里算) | — |
69
69
  | `sub_words_at(scenes, t)` | 当前上屏字幕对应的 `words_abs`,没有就 `None` | 无词级 -> `None`(调用方回退整句) |
70
70
  | `wrap_by_words(text, f, max_w, words)` | 按真实词边界折行;词表与原文对不上(有尾差/切不开)就返回 `None` | `None` -> `fit_sub_lines` 回退字符折行 |
71
- | `fit_sub_lines(..., words=)` / `draw_subtitle(..., words=)` | 排版入口,多一个可选参数 | `words=None` 时与 v0.7 逐像素一致 |
71
+ | `fit_sub_lines(..., words=)` / `draw_subtitle(..., words=)` | 排版入口,多一个可选参数 | `words=None` 时排版与不带词级时逐像素一致 |
72
72
  | `build_timeline(..., fps=)` | 给每段补 `words_abs`(句内词表平移到绝对时间) | 该句没有 `words` 就没有 `words_abs` |
73
- | `cue_time(at, scenes=, total=)` | 解析 `at`;v0.8 增词锚点 | 见 §3 |
73
+ | `cue_time(at, scenes=, total=)` | 解析 `at`(含 `l03:w2` 词锚点) | 见 §3 |
74
74
 
75
75
  ---
76
76
 
@@ -88,7 +88,7 @@ G10 会因此记 `skipped` 或 `fail`(看是全部为空还是个别句为空
88
88
  `[WARN] cue at='l03:w99':l03 只有 13 个词,w99 不存在,退化为句偏移`,然后按 `l03+偏移` 处理。
89
89
  —— 所以**改完台词一定要看 stdout 有没有这条 WARN**,否则你会以为自己精确打到了某个词。
90
90
  - 场景 id 本身不存在时才 `raise ValueError`(先跑 layout,或者改用 `abs:秒`)。
91
- - 只有 `abs:12.5` / `12.5` / `l03` / `l03+0.35` 这几种老写法时行为与 v0.7 **完全一致**。
91
+ - `abs:12.5` / `12.5` / `l03` / `l03+0.35` 这几种写法不含词锚点,行为与不带词级时**完全一致**。
92
92
 
93
93
  ---
94
94
 
@@ -141,7 +141,7 @@ python render.py ... --stage qa # 看 QC-REPORT.md 的 G10 行
141
141
  ## 6. 关掉它
142
142
 
143
143
  - 单片:`film.json` 写 `"words": false` -> `--stage tts` 不再收词事件,G10 `skipped`(写明"本片未启用"),
144
- 字幕退回整句折行。**老项目想完全保持 v0.7 行为就写这一行。**
144
+ 字幕退回整句折行。**不要词级能力就写这一行。**
145
145
  - 只跳过门禁不跳过能力:没有开关——G10 就是词级数据的体检,没数据就 skipped,不会 fail。
146
146
  - 词事件拿不到(服务端变了):不阻断出片,G10 `skipped` / `fail` 二选一看覆盖面,
147
147
  meta 里 `words_ok=false`、`words_events=0` 是排查入口。
@@ -24,32 +24,28 @@ opencv-python-headless>=4.8
24
24
  # 装完还要下载浏览器:python -m playwright install chromium
25
25
  # playwright>=1.40
26
26
 
27
- # ---- 可选:v0.8.0 ASR 回读门禁(qaasr.py)----
27
+ # ---- 可选:ASR 回读门禁(qaasr.py)----
28
28
  # 把成片音轨转回文字与剧本对账,抓"TTS 缓存串了 / 混音丢了句子 / 音轨是旧的"。
29
29
  # 缺了会依次降级:faster-whisper -> ffmpeg 的 whisper 滤镜(需 ggml 模型)-> skipped。
30
30
  # 实测(CPU-only, int8):small 模型加载 2.0–3.3 s、转写 RTF≈0.17;29 s 模板片约 6 s。
31
31
  # faster-whisper>=1.0
32
32
 
33
- # ---- v0.6.0 新增文件:都是纯标准库,**不需要**任何 pip 包 ----
34
- # scripts/gates.py 门禁账本 / QC 报告 / 人工签核(必需:render.py 直接 import)
35
- # scripts/stagewatch.py 阶段新鲜度(必需:同上)
36
- # scripts/filmcheck.py film.json 契约校验(可选:ci_check 第 12 项会调)
37
- # tools/estimate.py 开渲前成本预演(可选)
38
- # 若你从旧版本升级,记得把 gates.py / stagewatch.py 一起拷到工作目录,
39
- # 否则 render.py 会 ModuleNotFoundError。
40
-
41
- # ---- v0.8.0 新增文件(同样不需要 pip 包;升级记得一起拷)----
33
+ # ---- 纯标准库模块(**不需要**任何 pip 包,也不用拷到工程目录)----
34
+ # scripts/gates.py 门禁账本 / QC 报告 / 人工签核(render.py 直接 import)
35
+ # scripts/stagewatch.py 阶段新鲜度(render.py 直接 import)
36
+ # scripts/filmcheck.py film.json 契约校验(ci_check 第 12 项会调)
42
37
  # scripts/preflight.py 渲染前资源闸门(可选:缺了就记 skipped,门禁不冒充通过)
43
38
  # scripts/qavmaf.py 全片 VMAF/SSIM(可选:缺了就 skipped)
44
39
  # scripts/qaasr.py ASR 回读(可选:缺了就 skipped)
45
- # tools/golden_frames.py 金帧回归(可选:仓库工具,不进工作目录)
46
- # 注意:这三个都**不是** render.py 的硬依赖(用了 _try_module),少拷只会让对应门禁
40
+ # tools/estimate.py 开渲前成本预演(仓库工具)
41
+ # tools/golden_frames.py 金帧回归(仓库工具)
42
+ # 三个可选门禁都**不是** render.py 的硬依赖(用了 _try_module),缺了只让对应门禁
47
43
  # 记 skipped,不会 ModuleNotFoundError;但 qa 报告里会明确写"本次没查"。
48
44
 
49
45
  # ---- 不需要 ----
50
46
  # moviepy / pydub / soundfile / librosa / manim:本技能一行都不 import。
51
47
  # ffmpeg 不是 pip 包:系统 PATH 里要有(含 libx264 / aac / loudnorm),
52
48
  # 或用环境变量 FILMKIT_FFMPEG 指向可执行文件。
53
- # v0.6.0 起探测是 fail-closed 的:每个候选真跑 `-version`,跑不起来的会被跳过
49
+ # 探测是 fail-closed 的:每个候选真跑 `-version`,跑不起来的会被跳过
54
50
  # (防的是"存在但缺 xfade 的旧版 ffmpeg 被静默选中")。`FILMKIT_FFMPEG_STRICT=1`
55
51
  # 可把"找不到可用 ffmpeg"从回退裸命令改成直接报错。
@@ -1,5 +1,5 @@
1
1
  # -*- coding: utf-8 -*-
2
- """designkit.py — 设计 DSL(v1.0):把"这一版长什么样"写成可校验、可采样的 JSON。
2
+ """designkit.py — 设计 DSL:把"这一版长什么样"写成可校验、可采样的 JSON。
3
3
 
4
4
  设计原则(与 stylekit 一致的纪律)
5
5
  ----------------------------------
@@ -8,7 +8,7 @@
8
8
  2. 确定性:只用 sha1 派生,禁 random / time / hash()(str hash 每进程带随机盐)。
9
9
  同一份 brief 永远采样出同一组候选。
10
10
  3. fail-closed:设计缺字段、枚举越界、对比度不达标 -> 立刻报错,**绝不静默回落默认值**
11
- (静默回落 = 所有片子又长得一样,这正是本次改造要根除的毛病)。
11
+ (静默回落 = 所有片子又长得一样)。
12
12
 
13
13
  design.json 的形状(schema=1)
14
14
  -----------------------------
@@ -46,13 +46,13 @@ design.json 的形状(schema=1)
46
46
 
47
47
  配色不是"随便取色":语义色由对比度求解得到
48
48
  -----------------------------------------
49
- 实测(本轮):纯 HSV 随机取色的 theme_lint 通过率 21/60 = 35%,不可用。
50
- 改成"色相族 + (s,v) 网格求解,取最接近设计意图且满足对比度的色"后:60/60 = 100%。
49
+ 实测:纯 HSV 随机取色的 theme_lint 通过率只有 21/60 = 35%,不可用;
50
+ 按"色相 + (s,v) 网格求解,取最接近设计意图且满足对比度的色"可达 60/60 = 100%。
51
51
  网格是 s,v ∈ {0,0.1,...,1.0} 的 11×11;可行解按 |Δs|+|Δv| 取最小(并列取先遇到者,确定性)。
52
52
  7 个语义色的对比度门槛直接取 stylekit.LINT_LIMITS(与 theme_lint 同一份)。
53
53
 
54
- 注意:**设计里存的是求解后的 11 个颜色**,不是配色族名——这样 design.json 是自足的,
55
- 换机器/换版本也不会因为"rs 里没有那个族"而漂移;配色族只用于采样期的求解过程。
54
+ 注意:**设计里存的是求解后的 11 个颜色**,不是配色名——这样 design.json 是自足的,
55
+ 换机器/换版本不会因为"哪儿没有那个名字"而漂移;词 -> 色相锚点只用于采样期的求解过程。
56
56
 
57
57
  对外接口
58
58
  --------
@@ -62,7 +62,7 @@ design.json 的形状(schema=1)
62
62
  design_fingerprint(design) -> str
63
63
  design_distance(a, b) -> dict # 用于"伪多方案"门禁
64
64
 
65
- brief.must_have / avoid 是真过滤(v1.1)
65
+ brief.must_have / avoid 是真过滤
66
66
  ----------------------------------------
67
67
  词表见 _CONSTRAINT_WORDS:配色 / 母题 / 转场 / 情绪 / 声线,中文按子串认、英文按整词认。
68
68
  must_have 是 AND(候选不许违反任何一条),avoid 是否定(违反一条就出局)。
@@ -114,13 +114,13 @@ FPSES = (24, 25, 30, 50, 60)
114
114
  HOLD_POLICIES = ("none", "readable", "long")
115
115
  SFX_DENSITIES = ("none", "sparse", "normal", "rich")
116
116
 
117
- # 配色:**没有命名家族**(v1.0.1)。
117
+ # 配色:**没有命名家族**。
118
118
  #
119
119
  # 决定这件事的依据很直白:本技能只提供"生成视频的技术",不提供模板。既然没有模板,
120
120
  # 就不该存在"深蓝 / 近黑金 / 冷薄荷…"这样一份**可选清单** —— 有清单就意味着
121
- # 用户的片子只能在 8 个预设外观里挑,第 9 种永远不存在。
121
+ # 用户的片子只能在少数几个预设外观里挑,下一个永远不存在。
122
122
  #
123
- # 现在的模型是**连续色相空间**:
123
+ # 模型是**连续色相空间**:
124
124
  # · 底色 = (色相 h ∈ [0,360), 饱和度 s, 明度 v) 三个连续量 -> 直接算出 bg/bg2/surface;
125
125
  # · 其余 8 个语义色(ink/muted/dim/accent/accent2/good/bad/rule)由对比度网格求解;
126
126
  # · 全部通过 theme_lint 才算数(对比度不足就重采,绝不悄悄放行)。
@@ -144,13 +144,13 @@ def _palette_from_hue(hue, sat, val, dark):
144
144
  return bg, bg2, surface
145
145
 
146
146
 
147
- # 没有"配色族"这个概念了(v1.0.1)。这个空 dict 只是为了让老调用点
147
+ # 没有"配色族"这个概念:这个空 dict 只是为了让按名遍历的调用点
148
148
  # (`for name in PALETTE_FAMILIES` 之类)不 AttributeError —— 它必须保持为空:
149
149
  # 一旦有人往里加东西,就等于偷偷恢复了一份"可选外观清单"。
150
150
  PALETTE_FAMILIES = {}
151
151
 
152
152
  # 但 brief 里仍然会有人说"要深蓝 / 近黑 / 薄荷"。这些词要能生效,否则用户的
153
- # 自然语言要求就落空了(那正是 v1.0 的毛病)。
153
+ # 自然语言要求就落空了。
154
154
  # 做法:词 -> **色相锚点 + 明暗**,不是"族名 -> 一套现成外观"。锚点只是采样的起点,
155
155
  # 色相仍然是连续量(可在锚点附近 ±HUE_WINDOW 内浮动)。
156
156
  _PALETTE_ANCHOR = {
@@ -165,10 +165,9 @@ _PALETTE_ANCHOR = {
165
165
  }
166
166
  _HUE_WINDOW = 26.0 # 锚点附近 ±26°(再宽就不像"深蓝"了,再窄等于恢复成枚举)
167
167
  # 多个候选之间的"底色必须看得出不一样"的色相最小间距(度)。
168
- # 为什么是 12° 而不是"配色族":v1.0.1 删掉风格表后没有族可枚举;12° 是并排看图时
169
- # 人眼能稳定分辨的色相下限附近(再小就成了同一片底色的细微偏色)。约束收窄时
170
- # 可选色相窗口只有 52° 宽,12° 间距仍能放下 3–4 个候选,不会像"24° 分桶"那样
171
- # 在 n=3 时大半概率采样失败。
168
+ # 为什么是 12° 而不是分桶:12° 是并排看图时人眼能稳定分辨的色相下限附近
169
+ # (再小就成了同一片底色的细微偏色)。约束收窄时可选色相窗口只有 52° 宽,
170
+ # 12° 间距仍能放下 3–4 个候选,不会像"24° 分桶"那样在 n=3 时大半概率采样失败。
172
171
  _PALETTE_KEEP_GAP = 12.0
173
172
 
174
173
 
@@ -194,9 +193,8 @@ _GOOD_HUE = 135.0
194
193
  _BAD_HUE = 5.0
195
194
 
196
195
  # 情绪 -> BPM / 层门限。注意这里**没有"情绪 -> 曲风"的映射**:
197
- # v1.0 曾有一张 _MUSIC_BY_MOOD 把 7 种情绪映射到 7 套 scorekit 风格表,
198
- # 那是把"配乐模板"伪装成参数。v1.0.1 起配乐由 musicgen 现场编排,
199
- # 情绪只决定 BPM 与织体门限,不再指向任何现成风格。
196
+ # 把情绪映射到一套配乐风格表,等于把"配乐模板"伪装成参数。
197
+ # 配乐由 musicgen 现场编排,情绪只决定 BPM 与织体门限,不指向任何现成风格。
200
198
  _MOOD_BPM = {
201
199
  "calm": 78, "reflective": 86, "hopeful": 92, "steady": 104,
202
200
  "driving": 124, "tense": 98, "uplifting": 118,
@@ -219,10 +217,10 @@ _TRANS_WORD = {
219
217
  # 声线池:全部是可被 static 校验的 zh-CN-XxxNeural(本模块不 import voicekit——
220
218
  # 那会拉起 edge_tts。格式只做正则,不做"这个 id 是否真的存在"的联网校验)。
221
219
  #
222
- # v1.0 / P4:**只从"有响度补偿数据的音色"里采样**。
220
+ # **只从"有响度补偿数据的音色"里采样**。
223
221
  # 为什么:voicekit 的响度补偿是一张按 voice id 的数据表(VOICE_LOUDNESS / assets/voice_loudness.json);
224
222
  # 采样器如果产出表里没有的音色,那行的 gain_db 会回落 0 —— 等于"这个候选的旁白电平没校准"。
225
- # 实测(改造前):5 个题材 × 3 候选共产出 7 个音色,其中 **4 个没有补偿数据**。
223
+ # 实测:5 个题材 × 3 候选共产出 7 个音色,其中 **4 个没有补偿数据**。
226
224
  # 两处各存一份清单必然漂移,所以这里直接读共享 JSON(不 import voicekit)。
227
225
  _VOICE_LOUDNESS_PATH = os.path.normpath(
228
226
  os.path.join(HERE, os.pardir, "assets", "voice_loudness.json"))
@@ -287,7 +285,7 @@ _STRUCT_KEYS = ("segments", "beats", "hold_policy", "target_sec")
287
285
  def _theme_field_keys():
288
286
  """主题字段清单:从 _theme 构造器现场造一份样板再取键。
289
287
 
290
- 为什么不再从 ST.THEMES[某套内置主题] 取:v1.0.1 删光了内置主题,没有"参照物"了。
288
+ 为什么不从某套内置主题取:内置主题表已经不存在,没有"参照物"。
291
289
  也不手抄一份常量 —— 手抄的清单一定会在某次改构造器时漂移,
292
290
  然后表现成"某个颜色字段取不到"这种半夜才能查出来的 KeyError。
293
291
  """
@@ -308,16 +306,16 @@ def _theme_field_keys():
308
306
 
309
307
 
310
308
  # 模块加载时立刻算出来:它只是把构造器跑一遍取键名,没有副作用。
311
- # (曾经写成空元组占位、忘了回填,于是所有 design 都报"主题字段集合不一致"——
309
+ # (占位成空元组而忘了回填,所有 design 都会报"主题字段集合不一致"——
312
310
  # 断言本身是对的,错在参照物是空的。)
313
311
  _THEME_FIELD_KEYS = _theme_field_keys()
314
312
 
315
313
 
316
314
  _VISUAL_KEYS = ("palette", "family", "typography", "motif", "transition", "motion",
317
315
  "bias", "layout", "layout_cycle", "hud", "size", "fps", "subtitle")
318
- # 字幕参数(v1.0.2):以前字幕字号 / 位置 / 背板 / 圆角全写死在 filmkit.draw_subtitle,
319
- # 于是**任何两条片子**的字幕带都一模一样 —— 恰好是用户原则 5 说的"大面积长时间重复"。
320
- # 现在这些参数进设计 DSL(可选,缺省 = 过去的行为,老 design.json 逐像素不变)。
316
+ # 字幕参数:字号 / 位置 / 背板 / 圆角若全写死在渲染器里,
317
+ # **任何两条片子**的字幕带都一模一样 —— 恰好是原则 5 说的"大面积长时间重复"。
318
+ # 这些参数进设计 DSL(可选,缺省 = 最朴素的字幕带,不写 subtitle 的 design.json 逐像素不变)。
321
319
  _SUBTITLE_DEFAULTS = {
322
320
  "size": 46, "y": None, "align": "center", "plate_alpha": 170,
323
321
  "radius": 10, "family": None, "color": None, "max_lines": 2,
@@ -404,7 +402,7 @@ def _slug(text):
404
402
 
405
403
 
406
404
  # ============================================================================
407
- # 三、配色求解:色相族 + (s,v) 网格(本轮实测唯一 100% 通过的做法)
405
+ # 三、配色求解:色相锚点 + (s,v) 网格(实测唯一 100% 通过的做法)
408
406
  # ============================================================================
409
407
 
410
408
 
@@ -430,14 +428,14 @@ def _solve_role(hue, s0, v0, need, refs):
430
428
 
431
429
 
432
430
  def _solve_palette(family, hue_shift=0.0, target_shift=0.0):
433
- """一个配色族 -> 11 个颜色(语义色全部求解;无解直接报错,不回落)。
431
+ """一个配色锚点 -> 11 个颜色(语义色全部求解;无解直接报错,不回落)。
434
432
 
435
- hue_shift / target_shift 是采样器给同族不同候选留的小扰动(确定性,幅度很小),
433
+ hue_shift / target_shift 是采样器给不同候选留的小扰动(确定性,幅度很小),
436
434
  让"同族不同设计"也有色相差异;扰动不会改变可行性(网格里始终有 v=0/v=1 的端点)。
437
435
  """
438
- # 只接受 (色相, 饱和度, 明度, 暗色)。v1.0.1 之前还接受 8 个命名族("deep-blue" 之类),
439
- # 那条兼容分支已经删除:design.json 里存的是**算好的 RGB**,从不存族名,
440
- # 所以"老 design 带族名"这件事根本不存在——留着就是一个永远走不到、只能误导人的分支。
436
+ # 只接受 (色相, 饱和度, 明度, 暗色),不接受命名族("deep-blue" 之类):
437
+ # design.json 里存的是**算好的 RGB**,从不存名字,
438
+ # 所以"design 带名字"这件事根本不存在——留着分支只会在别处误导人。
441
439
  if not isinstance(family, (tuple, list)) or len(family) < 4:
442
440
  raise ValueError("配色必须是 (色相, 饱和度, 明度, 暗色) 四元组;收到 %r" % (family,))
443
441
  hue, sat, val = float(family[0]), float(family[1]), float(family[2])
@@ -505,7 +503,7 @@ def _safe_bias(bias):
505
503
 
506
504
 
507
505
  def _theme_id(design_id):
508
- # 设计 id 已经是 "design-xxxxxxxx";这里只做字符规整,不再叠前缀。
506
+ # 设计 id 已经是 "design-xxxxxxxx";这里只做字符规整,不叠前缀。
509
507
  return _slug(design_id)
510
508
 
511
509
 
@@ -554,7 +552,7 @@ def _theme_of_design(design):
554
552
  dict(style=vis["hud"]),
555
553
  qa,
556
554
  )
557
- # 主题的字段清单:v1.0.1 起没有内置主题可参照,改成从构造器现场造一份样板取键。
555
+ # 主题的字段清单:没有内置主题可参照,从构造器现场造一份样板取键。
558
556
  # 为什么还要求"字段完全一致":scenes.py 按固定键名取色,少一个键就是 KeyError,
559
557
  # 多一个键则说明构造器与消费端脱节 —— 两边必须严丝合缝。
560
558
  ref = set(_THEME_FIELD_KEYS)
@@ -599,7 +597,7 @@ _VISUAL_PROBE_ID = "__designkit_probe__"
599
597
 
600
598
 
601
599
  def _visual_lint_language(lang):
602
- """对任意 language 字典做体检(v1.0.1:直接调纯函数,不再往 stylekit 的表里临时注册)。"""
600
+ """对任意 language 字典做体检(直接调纯函数,不往 stylekit 的表里临时注册)。"""
603
601
  return ST.visual_lint_language(lang)
604
602
 
605
603
 
@@ -718,14 +716,14 @@ def _validate_music(mu, bad):
718
716
  mode = mu.get("mode")
719
717
  if mode not in ("params", "file", "off"):
720
718
  bad.append("audio.music.mode=%r 必须是 params / file / off" % (mode,))
721
- # v1.0.1:**不再要求 style**,也不再限定成 8 个内置名。
719
+ # **不要求 style**,也不限定成任何内置名。
722
720
  # 配乐由 musicgen 现场编排,它吃的是 bpm/key/scale/mood/seed + 可选的
723
- # progressions/motif/drums/voices —— 没有"曲风"这个概念了。
724
- # 老 design.json 里残留的 style 字段仍被接受(不校验取值),但不参与渲染,
725
- # 避免"升级后老设计全部报错"。
721
+ # progressions/motif/drums/voices —— 没有"曲风"这个概念。
722
+ # design.json 里残留的 style 字段仍被接受(不校验取值),但不参与渲染,
723
+ # 避免"带了这个键的设计全部报错"。
726
724
  _need(mu, ("scale", "bpm", "key", "mood", "seed"), "audio.music", bad)
727
725
  if "style" in mu and not isinstance(mu["style"], str):
728
- bad.append("audio.music.style 若存在必须是字符串(v1.0.1 起不参与渲染)")
726
+ bad.append("audio.music.style 若存在必须是字符串(不参与渲染)")
729
727
  if "scale" in mu:
730
728
  _enum_of(mu["scale"], SCALES, "audio.music.scale", bad)
731
729
  if "mood" in mu:
@@ -944,9 +942,9 @@ def validate_design(design):
944
942
  elif vis["fps"] not in FPSES:
945
943
  bad.append("visual.fps=%r 不在 %s 里" % (vis["fps"], FPSES))
946
944
 
947
- # 版式轮换(v1.0.2):片内按章节轮换版式,防"全片一张版式"。
948
- # 不写 = 由 design_to_plan 按设计指纹自动派生一套轮换(老 design.json 行为不变,
949
- # 因为 plan 里没有 layout_cycle 时 grammar 仍走 layout_default)。
945
+ # 版式轮换:片内按章节轮换版式,防"全片一张版式"。
946
+ # 不写 = 由 design_to_plan 按设计指纹自动派生一套轮换(plan 里没有 layout_cycle
947
+ # 时 grammar 仍走 layout_default,与不写这个字段等价)。
950
948
  if "layout_cycle" in vis and vis["layout_cycle"] is not None:
951
949
  cyc = vis["layout_cycle"]
952
950
  if not isinstance(cyc, (list, tuple)) or not cyc:
@@ -960,7 +958,7 @@ def validate_design(design):
960
958
  if len(set(str(x) for x in cyc)) < 2:
961
959
  bad.append("visual.layout_cycle 至少要有 2 种不同版式(只有一种等于不轮换)")
962
960
 
963
- # 字幕样式(v1.0.2):可选;不写 = 过去写死的值(逐像素不变)。
961
+ # 字幕样式:可选;不写 = 最朴素字幕带的默认值(逐像素不变)。
964
962
  sub = vis.get("subtitle")
965
963
  if sub is not None:
966
964
  if not isinstance(sub, dict):
@@ -1113,18 +1111,18 @@ def design_to_plan(design):
1113
1111
  "theme": th,
1114
1112
  "language": lang,
1115
1113
  "layout_default": vis["layout"],
1116
- # 片内版式轮换(v1.0.2):grammar 在 chapter_layout 为空时按章节轮换这个序列。
1117
- # 没有这个键(老 design.json)时 grammar 行为与过去完全一致。
1114
+ # 片内版式轮换:grammar 在 chapter_layout 为空时按章节轮换这个序列。
1115
+ # 没有这个键(设计里不写 layout_cycle)时 grammar 走 layout_default。
1118
1116
  "layout_cycle": list(vis.get("layout_cycle") or []),
1119
1117
  "chapter_layout": {},
1120
- # 字幕样式(v1.0.2):render/filmkit 只读这一份,不再各自写默认值。
1118
+ # 字幕样式:render/filmkit 只读这一份,不各自写默认值。
1121
1119
  "subtitle": subtitle_of(design),
1122
1120
  # 目标时长(可能为 null):只用于"实测 vs 目标"提示,不改变 TTS 决定的真实时长。
1123
1121
  "target_sec": (None if design.get("structure", {}).get("target_sec") is None
1124
1122
  else float(design["structure"]["target_sec"])),
1125
1123
  "music": {
1126
- # v1.0.1:不再有 "style" 这个键 —— 配乐由 musicgen 现场编排,
1127
- # 没有"曲风表"可指向。老设计里残留的 style 字段被忽略。
1124
+ # 没有 "style" 这个键 —— 配乐由 musicgen 现场编排,
1125
+ # 没有"曲风表"可指向。设计里残留的 style 字段被忽略。
1128
1126
  "scale": mu["scale"],
1129
1127
  "bpm": float(mu["bpm"]),
1130
1128
  "bpm_source": "design",
@@ -1148,11 +1146,11 @@ def design_to_plan(design):
1148
1146
  "cps_band": (3.6, 5.6),
1149
1147
  },
1150
1148
  },
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 为准)。
1149
+ # ---- 声音参数 ----
1150
+ # design.audio.music.gain_db / duck_db 与 audio.sfx.* **必须真的被读**:
1151
+ # 只校验不读,用户改了 design.json 里的音量/音效开关,渲染结果一模一样(实测
1152
+ # enabled=false 仍出 8 条音效)。所以原样交给 render,由它填进 script 的
1153
+ # music/sfx 缺省位(film.json 显式写了就仍以 film.json 为准)。
1156
1154
  "audio_overrides": {
1157
1155
  "music": {k: float(mu[k]) for k in ("gain_db", "duck_db") if mu.get(k) is not None},
1158
1156
  "sfx": {k: (bool(au["sfx"][k]) if k == "enabled" else
@@ -1232,11 +1230,10 @@ def _voice_key(design):
1232
1230
  def _music_key(design):
1233
1231
  """design -> 配乐身份("调式/速度档/情绪"复合键)。
1234
1232
 
1235
- v1.0.2 修:这里以前读 audio.music.style —— 那是"配乐风格表"时代的字段,
1236
- v1.0.1 删掉风格表后采样器不再产出它,于是这一维**恒为 None**,两个候选
1237
- 永远"配乐相同",SKILL §0.5 承诺的"候选至少在含配乐在内的 2 个维度上不同"
1238
- 实际少了一维(也是重采样失败率偏高的原因之一)。
1239
- 现在用参数化配乐真正决定听感的三件事做签名:调式 + BPM 档(±6 归一)+ 情绪。
1233
+ 不能读 audio.music.style:那是"配乐风格表"的字段,采样器不产出它,读了这一维
1234
+ 就**恒为 None**,两个候选永远"配乐相同",SKILL §0.5 承诺的"候选至少在含配乐
1235
+ 在内的 2 个维度上不同"会实际少一维(也是重采样失败率偏高的原因之一)。
1236
+ 签名用参数化配乐真正决定听感的三件事:调式 + BPM 档(±6 归一)+ 情绪。
1240
1237
  """
1241
1238
  mu = _dig(design, ("audio", "music")) or {}
1242
1239
  if not isinstance(mu, dict):
@@ -1356,7 +1353,7 @@ _CONSTRAINT_WORDS = (
1356
1353
  )
1357
1354
 
1358
1355
  _CATEGORY_VALUES = {
1359
- # 配色没有"可选值清单"了(连续色相空间),这里只保留空元组以兼容旧约束解析路径:
1356
+ # 配色没有"可选值清单"(连续色相空间),这里只保留空元组以兼容约束解析路径:
1360
1357
  # brief 里写"深蓝"这类词会走 _CONSTRAINT_WORDS -> 被解释成色相区间(见 _resolve_constraints)。
1361
1358
  "palette": (),
1362
1359
  "motif": tuple(MOTIFS),
@@ -1434,7 +1431,7 @@ def _resolve_constraints(brief):
1434
1431
 
1435
1432
  must {类别: (取值, 命中的词, 原句)}
1436
1433
  avoid {类别: [(取值, 命中的词, 原句), ...]}
1437
- domain {类别: 允许的取值元组;None = 这一类没被约束(保持 v1.0 的原采样域)}
1434
+ domain {类别: 允许的取值元组;None = 这一类没被约束(保持完整采样域)}
1438
1435
  unmatched 一句都没认出来的原句(按出现顺序去重)——交给顶层提示用户"这条没生效"
1439
1436
  """
1440
1437
  must_raw = [str(x) for x in (brief.get("must_have") or [])]
@@ -1538,10 +1535,10 @@ def _voices_for(genders):
1538
1535
 
1539
1536
 
1540
1537
  def _palette_family_of(design):
1541
- """design -> 配色族名(反查 bg/bg2/surface 三个锚点;认不出返回 None)。
1538
+ """design -> 配色分桶名(反查 bg 的色相与明暗;认不出返回 None)。
1542
1539
 
1543
1540
  为什么反查而不是在 design 里多存一个 family 字段:design.json 要保持"自足",
1544
- 配色一旦存了族名,改族名就会让老设计漂移;三个底色是采样时原样写进去的,反查无歧义。
1541
+ 配色一旦存了桶名,改桶就会让既有设计漂移;底色是采样时原样写进去的,反查无歧义。
1545
1542
  """
1546
1543
  pal = _dig(design, ("visual", "palette"))
1547
1544
  if not isinstance(pal, dict):
@@ -1675,7 +1672,7 @@ def _voice_gender_of(vid):
1675
1672
 
1676
1673
 
1677
1674
  def _voice_gender(vid):
1678
- """pitch 文案用的单字:女 / 男(认不出的仍按男声写,与 v1.0 的输出一致)。"""
1675
+ """pitch 文案用的单字:女 / 男(认不出的按男声写,输出口径固定)。"""
1679
1676
  return "女" if _voice_gender_of(vid) == "female" else "男"
1680
1677
 
1681
1678
 
@@ -1692,7 +1689,7 @@ def _make_design(brief, index, seed, attempt, domain=None, unmatched=()):
1692
1689
  cb = _brief_canon(brief)
1693
1690
  ctext = _canonical(cb)
1694
1691
  # nonce 是"这一次生成本身"的身份:默认每次调用新生成,传入 seed 时固定。
1695
- # 它必须参与哈希,否则同一 brief 的第二次生成与第一次逐位相同(v1.0 的 bug)。
1692
+ # 它必须参与哈希,否则同一 brief 的第二次生成与第一次逐位相同。
1696
1693
  nonce = brief.get("_nonce") if isinstance(brief, dict) else None
1697
1694
  tag = "%s|%s" % ("" if seed is None else str(seed), nonce or "")
1698
1695
 
@@ -1700,7 +1697,7 @@ def _make_design(brief, index, seed, attempt, domain=None, unmatched=()):
1700
1697
  return _digest(salt, ctext, tag, index, attempt)
1701
1698
 
1702
1699
  dom = domain or {}
1703
- # 配色 = 连续色相空间里的一个点(不再是 8 个名字之一)。
1700
+ # 配色 = 连续色相空间里的一个点(不是若干命名外观之一)。
1704
1701
  # 色相按黄金角 137.5° 步进:相邻候选的色相必不接近(比固定步长更难撞色),
1705
1702
  # 且整体分布均匀 —— 这就是"每条片底色都不同"的来源。
1706
1703
  dark = h("dark") % 100 < 62 # 约 62% 暗色片(贴合"工程技术片"气质)
@@ -1748,10 +1745,10 @@ def _make_design(brief, index, seed, attempt, domain=None, unmatched=()):
1748
1745
  align = ALIGNS[(h("align") + index) % len(ALIGNS)]
1749
1746
  font_family = FONT_FAMILIES[(h("family") + index) % len(FONT_FAMILIES)]
1750
1747
 
1751
- # ---- 片内版式轮换(v1.0.2,原则 5)----
1752
- # 以前一章只能用一种版式:design 的 visual.layout 是单值 → grammar 把它当"全片默认",
1748
+ # ---- 片内版式轮换(原则 5)----
1749
+ # visual.layout 是单值 → grammar 会把它当"全片默认",
1753
1750
  # 于是 8 段片子 8 屏都是同一个骨架(左标题右卡片 / 居中大字 / 右标题左卡片 里挑一个),
1754
- # 观众看到的是"长时间重复"。现在默认派生 2–3 种版式的轮换序列,
1751
+ # 观众看到的是"长时间重复"。所以默认派生 2–3 种版式的轮换序列,
1755
1752
  # 由 grammar 按**章节**轮换(同一章内稳定,换章才换版式)。
1756
1753
  # 用户仍可在 design.visual.layout_cycle 里显式指定(会覆盖这里)。
1757
1754
  _others = [x for x in LAYOUTS if x != layout]
@@ -1760,9 +1757,9 @@ def _make_design(brief, index, seed, attempt, domain=None, unmatched=()):
1760
1757
  cycle_len = 2 if h("layout_n") % 2 == 0 else 3
1761
1758
  layout_cycle = [layout] + _others[:max(0, cycle_len - 1)]
1762
1759
 
1763
- # ---- 字幕样式(v1.0.2,原则 2/5)----
1764
- # 过去字幕字号/位置/背板/圆角是 filmkit 里的模块常量:任何两条片子字幕都一模一样,
1765
- # 是"跨片同质化"里最稳定的一条重复。现在进设计(可选字段,写进 design.json 可改)。
1760
+ # ---- 字幕样式(原则 2/5)----
1761
+ # 字幕字号/位置/背板/圆角若是渲染器里的模块常量,任何两条片子字幕都一模一样,
1762
+ # 是"跨片同质化"里最稳定的一条重复。所以进设计(可选字段,写进 design.json 可改)。
1766
1763
  # 取值范围都留在"好看"的区间内:字号 40–56、背板透明度 96–208、圆角 2–24。
1767
1764
  sub_size = 40 + (h("subsize") % 5) * 4 # 40..56
1768
1765
  sub_align = "center" if h("subalign") % 4 else "left" # 多数居中,偶尔左对齐
@@ -1880,10 +1877,10 @@ def _make_design(brief, index, seed, attempt, domain=None, unmatched=()):
1880
1877
  def sample_designs(brief, n=3, seed=None):
1881
1878
  """现场采样 n 个两两可区分的 design(brief 的 must_have/avoid 在这里硬过滤)。
1882
1879
 
1883
- **默认"每次都不一样"**(v1.0.1):
1880
+ **默认"每次都不一样"**:
1884
1881
  - 不传 seed -> 每次调用都生成一个**新的 nonce**,同一 brief 跑两次得到不同设计;
1885
1882
  - 传 seed -> 固定 nonce,逐字节可复现(调试、回归、写文档示例时用)。
1886
- 为什么把默认反过来:v1.0 的默认是"同一 brief 永远同一结果",实测两次独立进程
1883
+ 默认必须是"每次都不一样":若默认"同一 brief 永远同一结果",实测两次独立进程
1887
1884
  产出的指纹完全相同(31f64edd34e502f9 / 52d385953835379e)——那等于把"没有模板"
1888
1885
  做成了"模板藏在哈希里",用户要新片子只能改 brief 措辞。这不满足"每条视频都不一样"。
1889
1886
 
@@ -1938,16 +1935,15 @@ def sample_designs(brief, n=3, seed=None):
1938
1935
  # 底色必须和已接受候选**拉开色相**。为什么单独管这一条:
1939
1936
  # design_distance 会在"母题/转场/缓动/编号"上找到足够的差异,
1940
1937
  # 于是两个候选可能共用同一片底色却仍被判为"可区分"。
1941
- # 实测样例(v1.0):A=近黑金·电影遮幅 / B=近黑金·细线页眉 —— 并排图里第一眼
1938
+ # 实测样例:A=近黑金·电影遮幅 / B=近黑金·细线页眉 —— 并排图里第一眼
1942
1939
  # 就是同一个片子的两种排法,用户不会认为那是两个方案。
1943
1940
  # 底色是整屏第一眼信息,必须先分叉。
1944
1941
  #
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
- # 约束不再和判重打架,候选数量也不再受"分桶数"限制。
1942
+ # 判重直接比**底色色相距离**,不按 24° 分桶:有 must_have 配色约束时,
1943
+ # 可选色相被压到 ±26° 窗口里,24° 分桶只剩 2–3 档,n=3 要求 3 个不同族 →
1944
+ # 重采样 50 次成功率约一半;"暖纸色+女声"这类常见组合实测 20/20 全失败
1945
+ # (brief 本身完全合理)。比色相距离则窗口内只要彼此 ≥ _PALETTE_KEEP_GAP 度
1946
+ # 就算两片不同底,约束不与判重打架,候选数量也不受"分桶数"限制。
1951
1947
  if out:
1952
1948
  hue = _design_bg_hue(cand)
1953
1949
  clash = None
@@ -1962,7 +1958,7 @@ def sample_designs(brief, n=3, seed=None):
1962
1958
  if gap < _PALETTE_KEEP_GAP:
1963
1959
  clash = (o["id"], gap)
1964
1960
  break
1965
- # 分档判重(v1.0.2 再修):brief 把配色约束到窗口里时(如"深蓝"= 210°±26°),
1961
+ # 分档判重:brief 把配色约束到窗口里时(如"深蓝"= 210°±26°),
1966
1962
  # 52° 的窗口放不下"3 个两两差 12°"的底色 —— 硬要求会让 n=3 有近一半概率
1967
1963
  # 采样失败(实测 19/40)。所以分两档:
1968
1964
  # · 色相差 < 6°:无论别的维度多不同,并排看都是同一片底色 -> 必须换;
@@ -2011,9 +2007,9 @@ def _setup_stdio():
2011
2007
  def _palette_rows(n=8):
2012
2008
  """演示用:从连续色相空间均匀取 n 个点,各自的解出结果。
2013
2009
 
2014
- 早先这里列的是 8 个命名配色族("深蓝/近黑金/冷薄荷…")。配色改成连续空间后
2015
- 没有"族"可列了,但**还是得让人看见配色能长什么样**——所以均匀采样给你看,
2016
- 而不是给一份可选清单。这不是模板:打印出来的每个点都可以再调(h/s/v 都是连续量)。
2010
+ 配色是连续色相空间,没有"族"可列,但**还是得让人看见配色能长什么样**——
2011
+ 所以均匀采样给你看,而不是给一份可选清单。这不是模板:打印出来的每个点
2012
+ 都可以再调(h/s/v 都是连续量)。
2017
2013
  """
2018
2014
  rows = []
2019
2015
  for i in range(int(n)):
@@ -2052,10 +2048,9 @@ def main(argv=None):
2052
2048
  a = ap.parse_args(argv)
2053
2049
 
2054
2050
  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
- # 现在按行里真实存在的键打印,并说明"这不是可选清单"。
2051
+ # 按 _palette_rows() 真实返回的键(hue/dark/ink_hex/...)打印,不要按老键名
2052
+ # (family/bg/accent_hue)取:键名对不上会 KeyError,而 --json 分支不读这些键、
2053
+ # 照样正常,自检容易漏掉。同时说明"这不是可选清单"。
2059
2054
  rows = _palette_rows()
2060
2055
  if a.json:
2061
2056
  print(json.dumps(rows, ensure_ascii=False, indent=1))