@dsh-gis/dsh-video-studio-skill 0.9.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +424 -0
  2. package/README.md +287 -327
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +4 -15
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1316 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +1 -2
  11. package/skills/video-studio/assets/golden_frames.json +11 -8
  12. package/skills/video-studio/assets/scenes.py +67 -22
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/audio-variation.md +15 -11
  15. package/skills/video-studio/references/audio.md +36 -34
  16. package/skills/video-studio/references/change-impact.md +3 -3
  17. package/skills/video-studio/references/frame-qa.md +2 -2
  18. package/skills/video-studio/references/proposal.md +177 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/scene-grammar.md +28 -23
  21. package/skills/video-studio/references/sfx-design.md +1 -1
  22. package/skills/video-studio/references/style-packs.md +95 -257
  23. package/skills/video-studio/references/v08-quality.md +8 -8
  24. package/skills/video-studio/references/web-engine.md +1 -1
  25. package/skills/video-studio/references/word-timeline.md +2 -2
  26. package/skills/video-studio/scripts/designkit.py +2120 -0
  27. package/skills/video-studio/scripts/filmcheck.py +7 -33
  28. package/skills/video-studio/scripts/filmkit.py +153 -78
  29. package/skills/video-studio/scripts/grammar.py +67 -7
  30. package/skills/video-studio/scripts/musicgen.py +2043 -0
  31. package/skills/video-studio/scripts/preflight.py +10 -3
  32. package/skills/video-studio/scripts/previewkit.py +849 -0
  33. package/skills/video-studio/scripts/propose.py +825 -0
  34. package/skills/video-studio/scripts/qa_frames.py +45 -10
  35. package/skills/video-studio/scripts/qavmaf.py +77 -11
  36. package/skills/video-studio/scripts/render.py +472 -154
  37. package/skills/video-studio/scripts/scorekit.py +50 -1748
  38. package/skills/video-studio/scripts/selfcheck.py +658 -131
  39. package/skills/video-studio/scripts/stagewatch.py +21 -5
  40. package/skills/video-studio/scripts/stylekit.py +265 -741
  41. package/skills/video-studio/scripts/voicekit.py +502 -380
  42. package/skills/video-studio/scripts/webengine.py +15 -14
  43. package/tools/audio_similarity.py +57 -30
  44. package/tools/ci_check.py +7 -1
  45. package/tools/estimate.py +14 -8
  46. package/tools/golden_frames.py +56 -11
  47. package/tools/silhouette_qa.py +521 -168
  48. package/docs/demo-pil-sheet.png +0 -0
  49. package/docs/styles/demo-results.json +0 -76
  50. package/docs/styles/sheet-8-styles.png +0 -0
  51. package/docs/styles/sheet-ai.png +0 -0
  52. package/docs/styles/sheet-before-after.png +0 -0
  53. package/docs/styles/sheet-craft.png +0 -0
  54. package/docs/styles/sheet-night.png +0 -0
  55. package/skills/video-studio/assets/score_demo.json +0 -307
@@ -2160,10 +2160,15 @@ def _parse_frames(text: str) -> list:
2160
2160
 
2161
2161
 
2162
2162
  def _resolve_style_spec(text):
2163
- """解析 --style 的三种写法:内联 JSON / JSON 文件路径 / stylekit 风格 id。
2163
+ """解析 --style 的**两种**写法:内联 JSON / JSON 文件路径。
2164
2164
 
2165
2165
  返回 normalize_style 能吃的 dict;空字符串返回 None(= 不注入,旧行为)。
2166
2166
  解析失败一律 SystemExit(CLI 场景,带可操作提示),不做静默回落。
2167
+
2168
+ v1.0.1:**不再接受"stylekit 风格 id"这种写法**。内置风格表已删除,
2169
+ `--style magazine` 这种用法没有对象了。早先这里会调 ST.get()/ST.theme_of(),
2170
+ 而那两个函数现在只会抛 KeyError —— 与其让它抛一个"没有风格 X"的错,
2171
+ 不如直接把写法和替代方案说清楚。
2167
2172
  """
2168
2173
  s = str(text or "").strip()
2169
2174
  if not s:
@@ -2179,24 +2184,20 @@ def _resolve_style_spec(text):
2179
2184
  return json.loads(p.read_text(encoding="utf-8"))
2180
2185
  except json.JSONDecodeError as exc:
2181
2186
  raise SystemExit("[错误] --style 的 JSON 文件 %s 解析失败:%s" % (p, exc))
2182
- try:
2183
- import stylekit as ST
2184
- except Exception as exc: # noqa: BLE001
2185
- raise SystemExit("[错误] --style %r 被当成风格 id,但 stylekit.py 不在同目录(%s)。"
2186
- "要么用 --style work/style.json,要么把 stylekit.py 放在脚本旁边。"
2187
- % (s, exc))
2188
- try:
2189
- pack = ST.get(s)
2190
- except KeyError as exc:
2191
- raise SystemExit("[错误] %s" % (exc.args[0] if exc.args else exc))
2192
- return {"style": s, "style_name": pack.get("name"), "legacy": ST.is_legacy(s),
2193
- "theme": ST.theme_of(s), "layout_default": (pack.get("layouts") or ("left-stack",))[0]}
2187
+ raise SystemExit(
2188
+ "[错误] --style %r 不是 JSON、也不是存在的文件——而**按内置风格 id 取外观这条路已经没有了**。\n"
2189
+ " v1.0.1 删除了全部内置风格/主题表(本技能不提供模板)。现在注入外观只有两种写法:\n"
2190
+ " ① --style '{\"theme\": {...}, \"canvas\": {...}}' # 内联 JSON\n"
2191
+ " ② --style work/style.json # 由 render.py --stage style 落盘\n"
2192
+ " 想生成一套新外观:python propose.py --brief \"题材\" --script film.json\n"
2193
+ " 详细契约见 references/web-engine.md 的 __STYLE__ 段。" % (s,))
2194
2194
 
2195
2195
 
2196
2196
  def _add_style_args(p, *, fps=True):
2197
2197
  """给子命令加风格参数(缺省 = v0.7 旧行为:不注入、1920x1080)。"""
2198
2198
  p.add_argument("--style", default="",
2199
- help="风格:stylekit 风格 id(如 magazine)/ work/style.json / 内联 JSON;缺省不注入")
2199
+ help="外观载荷:work/style.json / 内联 JSON;缺省不注入"
2200
+ "(v1.0.1 起不接受风格 id:内置风格表已删除)")
2200
2201
  p.add_argument("--width", type=int, default=W, help="画布宽(只收 16:9 横屏偶数,默认 %d)" % W)
2201
2202
  p.add_argument("--height", type=int, default=H, help="画布高(默认 %d)" % H)
2202
2203
  if fps:
@@ -9,8 +9,9 @@
9
9
 
10
10
  四组对照(阈值来自 skills/video-studio/references/audio-variation.md §5):
11
11
 
12
- A 跨片配乐(v0.7):同风格/同 bpm/同调、只换内容指纹 -> 相关性(目标 <= 0.85);
13
- 换风格 -> 相关性(目标 <= 0.52,且不能比改造前更接近)
12
+ A 跨片配乐(v1.0.1 起用 musicgen 参数化编排):
13
+ 同参数/同 bpm/同调、只换内容指纹 -> 相关性(目标 <= 0.85);
14
+ 换编排参数(mood + 音色) -> 相关性(目标 <= 0.52)
14
15
  B 跨片音效(v0.7):同 cue 序列,legacy(不传 style/content_fp)必须逐样本相同;
15
16
  不同 content_fp 必须不同(否则"跨片音效都一样"没被修掉)
16
17
  C 片内分离(v0.7):duck 默认深度(目标 >= 3.5 dB)与配乐/音效的频谱质心对照
@@ -21,6 +22,7 @@
21
22
  from __future__ import annotations
22
23
 
23
24
  import argparse
25
+ import hashlib
24
26
  import re
25
27
  import sys
26
28
  from pathlib import Path
@@ -32,12 +34,12 @@ SCRIPTS = HERE.parent / "skills" / "video-studio" / "scripts"
32
34
  sys.path.insert(0, str(SCRIPTS))
33
35
 
34
36
  # 契约 §5 的阈值(改契约时两边一起改)
35
- THRESH_A_SAME_STYLE_MAX = 0.85 # 同风格、只换内容指纹的相关性上限
36
- THRESH_A_CROSS_STYLE_MAX = 0.52 # 换风格的相关性上限(改造前 0.534)
37
- # 实测余量提示(v0.7.2):本机稳定测到 0.514(连跑 3 次逐位相同),离阈值只剩 0.006。
38
- # 这个生成链是确定性的,所以换平台/换 numpy 若把 0.514 推到 0.52 以上,
39
- # 更可能是「风格差异化真的缩水了」而不是浮点噪声 —— 请先查 scorekit 的变奏参数,
40
- # 别第一反应去抬阈值(抬到 0.53 就离改造前 0.534 只剩 0.004,等于这条门禁白设)。
37
+ THRESH_A_SAME_STYLE_MAX = 0.85 # 同参数、只换内容指纹的相关性上限
38
+ THRESH_A_CROSS_STYLE_MAX = 0.52 # 换编排参数的相关性上限(v0.7 改造前的 0.534)
39
+ # v1.0.1:配乐改由 musicgen 参数化编排(scorekit 的 9 套风格表已删除),这两条阈值
40
+ # 仍是「跨片不能太像」的验收线:同参数换指纹要 <=0.85,换编排参数要 <=0.52。
41
+ # 生成链是确定性的,换平台/换 numpy 若把实测值推过线,先查 musicgen 的参数有没有
42
+ # 变成死旋钮,别第一反应去抬阈值。
41
43
  THRESH_C_DUCK_MIN = 3.5 # duck 默认深度下限(dB)
42
44
  THRESH_D_SNR_MIN = 12.0 # 人声段 旁白/背景 响度差下限(dB)
43
45
 
@@ -101,10 +103,35 @@ def _utf8_stdio():
101
103
  pass
102
104
 
103
105
 
104
- def _music(dur, style, fp):
105
- import scorekit as SK
106
- samples, _rep = SK.render_styled(dur, style=style, bpm=110, key="Dm",
107
- mood="steady", seed=7, fingerprint=fp)
106
+ def _music(dur, profile, fp):
107
+ """用参数化编排器现场生成一条配乐(v1.0.1:scorekit 的 9 套风格表已删除)。
108
+
109
+ profile 是「一套编排参数」,对应旧版的一个 style id —— 换成 mood + 音色 + 和弦进行:
110
+ * "steady" -> 稳重:pad=saw / bass=sine / arp=pluck / lead=bell
111
+ * "magazine" -> 换一套色调:triangle 铺底 + bell 琶音 + hopeful
112
+ fp 除了派生 seed,还决定**和弦进行走哪一档**(结构层):旧版 scorekit 的指纹会换
113
+ 进行档,这里用同一思路 —— 否则只换 seed 时两段配乐的相关性接近 1.0,A1 门禁
114
+ 就成了「永远通过」的假门禁。
115
+ """
116
+ import musicgen as MG
117
+ # 指纹 -> 两个**互相独立**的派生量:seed(音色/细节)与进行档(结构)。
118
+ # 早先用同一个 8 位片段同时取模,content-a / content-b 正好撞在一起(seed 与档位
119
+ # 全同 -> corr 恒 1.0,A1 门禁变成永远通过)。分开取片段才是真的"同一内容同一首、
120
+ # 换内容不像同一首"。
121
+ digest = hashlib.sha1(str(fp or "").encode("utf-8")).hexdigest()
122
+ seed = 7 + (int(digest[:12], 16) % (2 ** 31 - 1))
123
+ prog_idx = int(digest[12:20], 16) % 2
124
+ voices = {"steady": {"pad": "saw", "bass": "sine", "arp": "pluck", "lead": "bell"},
125
+ "magazine": {"pad": "triangle", "bass": "sine", "arp": "bell", "lead": "pluck"}}[profile]
126
+ progressions = [
127
+ {"calm": ["i", "VI", "iv", "v"], "body": ["i", "VI", "III", "VII"],
128
+ "drive": ["i", "VII", "VI", "V"], "climax": ["i", "VII", "VI", "V"]},
129
+ {"calm": ["i", "iv", "VI", "i"], "body": ["i", "III", "VII", "VI"],
130
+ "drive": ["i", "VI", "VII", "V"], "climax": ["i", "VI", "VII", "V"]},
131
+ ][prog_idx]
132
+ samples, _rep = MG.render(float(dur), bpm=110.0, key="Dm", scale="minor",
133
+ mood="steady" if profile == "steady" else "hopeful",
134
+ seed=seed, voices=voices, progressions=progressions)
108
135
  return samples
109
136
 
110
137
 
@@ -120,7 +147,7 @@ def _cue_track(content_fp=None, style=None, key=None):
120
147
 
121
148
 
122
149
  def _voice_priority_snr(sr=48000, guard_snr_db=12.0):
123
- """量"人声比背景响多少":用真实 scorekit 配乐 + 类人声,按 st_audio 的默认参数混音。
150
+ """量"人声比背景响多少":用真实 musicgen 配乐 + 类人声,按 st_audio 的默认参数混音。
124
151
 
125
152
  返回 (人声活跃区 SNR dB, 间隙背景 RMS, 人声处背景额外让位 dB)。
126
153
  口径与 `filmkit.voice_snr` 一致:只统计人声活跃区(含间隙会稀释问题)。
@@ -130,16 +157,16 @@ def _voice_priority_snr(sr=48000, guard_snr_db=12.0):
130
157
  """
131
158
  import filmkit as K
132
159
  import numpy as np
133
- import scorekit as SK
160
+ import musicgen as MG
134
161
  n = int(6.0 * sr)
135
162
  t = np.arange(n) / float(sr)
136
163
  voice = np.zeros(n, dtype=np.float64)
137
164
  for a, b in ((0.2, 2.6), (3.4, 5.8)): # 两段"说话",中间留间隙
138
165
  i, j = int(a * sr), int(b * sr)
139
166
  voice[i:j] = 0.12 * np.sin(2 * np.pi * 220 * t[i:j])
140
- # 真实配乐(scorekit 输出 RMS 约 0.15,比"类人声"响 —— 正是需要侧链/兜底的场景)
141
- music, _ = SK.render_styled(6.0, style="tech-deep", bpm=110, key="Am",
142
- mood="steady", seed=7, fingerprint="d-group")
167
+ # 真实配乐(musicgen 输出峰值 -6 dBFS,比"类人声"响 —— 正是需要侧链/兜底的场景)
168
+ music, _ = MG.render(6.0, bpm=110.0, key="Am", scale="minor",
169
+ mood="steady", seed=7)
143
170
  music = np.asarray(music, dtype=np.float32)
144
171
  out = K.mix_film(voice.astype(np.float32), music, None, total=6.0, fade=0.0,
145
172
  music_gain_db=-12.0, voice_duck_db=6.0,
@@ -174,29 +201,29 @@ def main(argv=None):
174
201
  import sfxkit as SX
175
202
 
176
203
  print("=" * 72)
177
- print("A. 跨片配乐(差异来源 = 风格 / bpm / 调 / 内容指纹 / seed)")
204
+ print("A. 跨片配乐(差异来源 = 编排参数 / bpm / 调 / 内容指纹 / seed)")
178
205
  print("=" * 72)
179
- a1 = _music(args.dur, "tech-deep", "content-a")
180
- a2 = _music(args.dur, "tech-deep", "content-b")
206
+ a1 = _music(args.dur, "steady", "content-a")
207
+ a2 = _music(args.dur, "steady", "content-b")
181
208
  a3 = _music(args.dur, "magazine", "content-a")
182
- a4 = _music(args.dur, "tech-deep", "content-a") # 同指纹复渲染,验证确定性
209
+ a4 = _music(args.dur, "steady", "content-a") # 同指纹复渲染,验证确定性
183
210
  same_style = _corr(a1, a2)
184
211
  cross_style = _corr(a1, a3)
185
212
  rms_a1 = _rms(a1)
186
213
  print(" 配乐非静音(RMS > -60 dBFS) : %s (rms=%.4f,静音时相关系数无定义)"
187
214
  % (rms_a1 > 0.001, rms_a1))
188
- print(" 同风格·同bpm·同调,只换内容指纹 : corr = %.3f (目标 <= %.2f)"
215
+ print(" 同参数·同bpm·同调,只换内容指纹 : corr = %.3f (目标 <= %.2f)"
189
216
  % (same_style, THRESH_A_SAME_STYLE_MAX))
190
- print(" 换风格(tech-deep -> magazine) : corr = %.3f (目标 <= %.2f)"
217
+ print(" 换编排参数(steady -> magazine) : corr = %.3f (目标 <= %.2f)"
191
218
  % (cross_style, THRESH_A_CROSS_STYLE_MAX))
192
219
  print(" 同指纹两次渲染逐样本相同 : %s" % bool(np.array_equal(a1, a4)))
193
220
  if args.strict:
194
221
  # A0 是前置条件:若配乐渲染成静音/常量,A1/A2 的相关系数没有定义(NaN),
195
222
  # 必须显式判 FAIL,而不是让 NaN 比较悄悄变成 False 后只剩一句 corr=nan。
196
223
  _ok("A0 配乐非静音", rms_a1 > 0.001, "rms=%.4f" % rms_a1)
197
- _ok("A1 同风格换指纹可区分", same_style <= THRESH_A_SAME_STYLE_MAX,
224
+ _ok("A1 同参数换指纹可区分", same_style <= THRESH_A_SAME_STYLE_MAX,
198
225
  "corr=%.3f" % same_style)
199
- _ok("A2 换风格更不像", cross_style <= THRESH_A_CROSS_STYLE_MAX,
226
+ _ok("A2 换编排参数更不像", cross_style <= THRESH_A_CROSS_STYLE_MAX,
200
227
  "corr=%.3f" % cross_style)
201
228
  _ok("A3 配乐可复现", bool(np.array_equal(a1, a4)), "同指纹逐样本一致")
202
229
 
@@ -256,10 +283,10 @@ def main(argv=None):
256
283
  if args.out:
257
284
  out = Path(args.out)
258
285
  out.mkdir(parents=True, exist_ok=True)
259
- from scorekit import save as music_save
260
- music_save(out / "music_tech_deep_a.wav", a1)
261
- music_save(out / "music_tech_deep_b.wav", a2)
262
- music_save(out / "music_magazine.wav", a3)
286
+ import musicgen as MG
287
+ MG.save(out / "music_steady_a.wav", a1)
288
+ MG.save(out / "music_steady_b.wav", a2)
289
+ MG.save(out / "music_magazine.wav", a3)
263
290
  for t in ("whoosh", "riser", "impact"):
264
291
  SX.save(out / ("sfx_%s.wav" % t), SX.render(t, seed=1000))
265
292
  print("\n对照样本已写入: %s" % out)
@@ -276,4 +303,4 @@ def main(argv=None):
276
303
 
277
304
 
278
305
  if __name__ == "__main__":
279
- raise SystemExit(main())
306
+ raise SystemExit(main())
package/tools/ci_check.py CHANGED
@@ -59,7 +59,13 @@ LOCAL_MODULES = {"filmkit", "sfxkit", "scorekit", "webengine", "qa_frames", "ren
59
59
  # v0.8.0:渲染前资源闸门、全片 VMAF/SSIM 门禁、ASR 回读门禁
60
60
  "preflight", "qavmaf", "qaasr",
61
61
  # v0.9.0:场景语法(内容 -> 构图选型)。纯标准库,可被 scenes.py 反向 import
62
- "grammar"}
62
+ "grammar",
63
+ # v1.0:设计 DSL / 离线预览 / 提案器(D3 改造的第一期)
64
+ "designkit", "previewkit", "propose",
65
+ # v1.0.1:presetkit 已删除(连同 presets/ 一起);这里不能再列它,
66
+ # 否则 ci_check 会去 import 一个不存在的模块
67
+ # v1.0 / P2:参数化配乐编排器(替代 9 套 MUSIC_STYLES 表)
68
+ "musicgen"}
63
69
  MAX_FILE_BYTES = 400 * 1024
64
70
  # 体积检查忽略的目录后缀:.team 是本地调研/自测工作区(gitignore),不随包发布;
65
71
  # 媒体文件本来就该大(样例片/参考视频),不该拿"能不能塞进上下文"的尺子量。
package/tools/estimate.py CHANGED
@@ -50,9 +50,14 @@ WEB_SEC_PER_FRAME = (0.080, 0.130) # web 引擎:Edge 无头 PNG 截图 80–1
50
50
  WEB_STARTUP_SEC = 6.0 # 浏览器冷启动 + CDP 连接(12 s 片 28–30 s 含启动)
51
51
  X264_FPS = 141.0 # libx264 -preset medium -crf 17 ≈ 4.7× 实时(仅 reencode_video 用)
52
52
  TTS_SEC_PER_LINE = (2.0, 4.0) # edge-tts:每句每 worker 的墙钟(联网,波动大):实测 2.2 s,留到 4.0
53
- SCORE_REFS = ((29.0, 3.6, 6.5), (58.0, 4.5, 6.5), (389.0, 8.0, 18.0))
54
- # ^ (片长 s, 耗时低, 耗时高):前两个点是本机实测(29 s / 58 s 各 3 次),
55
- # 第三个点是片长外推锚点(不是本机实测),只用来保证长片不会被系统性算小
53
+ # 配乐耗时锚点 (片长 s, 耗时低, 耗时高)。
54
+ #
55
+ # v1.0.1 换成 musicgen 实测值:30 s / 60 s / 120 s 分别 0.94 / 1.93 / 3.68 s(≈31× 实时),
56
+ # 比 scorekit 时代快一个数量级。为什么现在敢改:以前配乐走 scorekit 的 9 套风格表,
57
+ # 那个引擎要逐层展开事件并做多次 IIR,实测 29 s 片 3.6–6.5 s;现在的参数化编排器
58
+ # 是单遍事件合成,实测抖动也小(同参数逐采样确定,耗时只随片长线性涨)。
59
+ # 区间仍给 0.7–1.5 系数包络:CI 机器与共享 CPU 会慢,报小了会让人以为卡住。
60
+ SCORE_REFS = ((29.0, 0.9, 1.6), (58.0, 1.8, 3.0), (389.0, 11.5, 19.0))
56
61
  SFX_SEC_PER_CUE = 0.20 # 单条短音效 合成+落位(24 配方单次 ≤1.5 s 是上限口径)
57
62
  SFX_MIN_SEC = 0.8 # 实测 18 条 cue 的模板片 sfx 墙钟 0.8 s(含 ffmpeg 写盘)
58
63
  LAYOUT_BASE, LAYOUT_PER_LINE = 0.05, 0.01 # 排轴 + srt/ass + 安全区/字形自查(实测 29 s 片 <0.15 s)
@@ -81,7 +86,7 @@ CALIBRATION = dict(
81
86
  "(是否同一台未核实,只保证这些数字是在上述 24 逻辑核机器上测的)"),
82
87
  measured_at="2026-11-10",
83
88
  sdk="video-studio v0.7.1 scripts(ffmpeg 自带 x264)",
84
- film="29.011 s / 870 帧 / 模板片(assets/film.json + assets/scenes.py)",
89
+ film="29.011 s / 870 帧 / 测试样片(assets/film.json + assets/scenes.py,仅用于基准校准)",
85
90
  method="render.py --stage all --jobs 2,逐 stage 取 [time] 行墙钟;同片重复 2 次",
86
91
  observed=dict(tts=2.2, layout=0.0, cues=0.0, score=6.3, sfx=0.8, audio=0.7,
87
92
  frames=9.7, mux=1.9, qa=6.8, total=28.4),
@@ -152,8 +157,9 @@ def load_script(path, as_json=False):
152
157
  """读剧本;坏输入给一句话错误(退出码 2),不甩 traceback。"""
153
158
  p = Path(path).expanduser()
154
159
  if not p.exists():
155
- _die("找不到剧本 %s(--script 指到 film.json;模板在 skills/video-studio/assets/film.json)"
156
- % p, as_json)
160
+ _die("找不到剧本 %s(--script 指向你自己的 film.json;"
161
+ "仓库里只有一个用于测试的样例 skills/video-studio/assets/film.json,"
162
+ "它不是要抄的模板)" % p, as_json)
157
163
  try:
158
164
  raw = p.read_text(encoding="utf-8")
159
165
  except OSError as e:
@@ -479,7 +485,7 @@ def build_report(script_path, script, args):
479
485
  n_cues = len(sfx_cfg.get("cues") or [])
480
486
  sfx_on = bool(sfx_cfg.get("enabled", True)) and n_cues > 0
481
487
  # 配乐口径对齐 filmkit.music_plan:mode=off/none 或 enabled=false 才算关;
482
- # 只有 mode=score(或没写 mode 但写了 bpm)才会真跑 scorekit。
488
+ # 只有 mode=score(或没写 mode 但写了 bpm)才会真跑配乐引擎(musicgen)。
483
489
  mode = str(mus.get("mode") or "").strip().lower()
484
490
  has_bpm = "bpm" in mus or "bpm" in script
485
491
  music_on = bool(mus.get("enabled", True)) and mode not in ("off", "none")
@@ -570,7 +576,7 @@ def build_report(script_path, script, args):
570
576
  dict(stage="score", lo=score_lo, hi=score_hi, measured=bool(score_on),
571
577
  note=("以实测点 29 s≈3.6–6.3 s / 58 s≈4.5–5.0 s 为锚(--stage all 里与其它阶段争 CPU 会更慢;389 s 只是外推锚点)"
572
578
  if score_on else
573
- ("mode=%s:不跑 scorekit(%s)"
579
+ ("mode=%s:不跑配乐引擎(%s)"
574
580
  % (mode or "-", "外部配乐/和弦床/关掉配乐")))),
575
581
  dict(stage="sfx", lo=sfx_lo, hi=sfx_hi, measured=False,
576
582
  note=("%d 条音效(实测 18 条 0.8 s;单条上限口径 ≤1.5 s,长环境声 wind/rain 另计)"
@@ -69,6 +69,11 @@ SCRIPTS = os.path.join(SKILL, "scripts")
69
69
  ASSETS = os.path.join(SKILL, "assets")
70
70
 
71
71
  DEFAULT_SCRIPT = os.path.join(ASSETS, "film.json")
72
+ # v1.0 / D3:金帧基线绑"固定 fixture 设计"而不是内置风格。
73
+ # 为什么:金帧要守的是"同一份设计 + 同一份场景代码,像素不许漂",
74
+ # 不是"某套内置风格长什么样"——后者会随风格表下沉到 presets 而失去意义。
75
+ # 没有这个文件时回落到老口径(读 film.json 的 style),不阻塞老项目。
76
+ DEFAULT_DESIGN = os.path.join(ASSETS, "design_fixture.json")
72
77
  DEFAULT_SCENES = os.path.join(ASSETS, "scenes.py")
73
78
  DEFAULT_GOLDEN = os.path.join(ASSETS, "golden_frames.json")
74
79
 
@@ -216,11 +221,39 @@ def build_timeline(K, script):
216
221
  return tl
217
222
 
218
223
 
219
- def resolve_plan(ST, script):
220
- """解析风格计划并挂到 script["_style"](与 render.resolve_style 同口径,含 theme_overrides)。
224
+ def _design_plan(design_path):
225
+ """读 fixture 设计并转成计划;读不到返回 None(回落到 stylekit 老口径)。"""
226
+ if not design_path or not os.path.exists(str(design_path)):
227
+ return None
228
+ try:
229
+ with open(design_path, encoding="utf-8") as fh:
230
+ design = json.load(fh)
231
+ except (OSError, json.JSONDecodeError) as e:
232
+ raise RuntimeError("fixture 设计读不出来(%s):%s" % (design_path, e)) from e
233
+ sys.path.insert(0, SCRIPTS)
234
+ import designkit as DK
235
+ bad = DK.validate_design(design)
236
+ if bad:
237
+ raise RuntimeError("fixture 设计没过校验(%s):%s" % (design_path, ";".join(bad)))
238
+ return DK.design_to_plan(design)
239
+
240
+
241
+ def resolve_plan(ST, script, design_path=None):
242
+ """解析计划并挂到 script["_style"]。
243
+
244
+ v1.0 / D3 起优先用 **fixture 设计**(design_path,默认 assets/design_fixture.json):
245
+ 金帧守的是"固定设计 + 固定场景代码 -> 像素不漂",与"哪套内置风格"无关。
246
+ 没有 fixture 时回落到 stylekit 老口径,不阻塞老项目。
221
247
 
222
248
  主题体检失败在这里就抛错(不要等渲到第 3 帧才炸)。
223
249
  """
250
+ plan = _design_plan(design_path)
251
+ if plan is not None:
252
+ script["_style"] = plan
253
+ bad = ST.theme_lint(plan.get("theme") or {})
254
+ if bad:
255
+ raise RuntimeError("fixture 设计主题体检失败:%s" % ";".join(bad))
256
+ return plan
224
257
  plan = ST.resolve_script_style(script)
225
258
  bad = ST.theme_lint(plan.get("theme") or ST.THEMES[plan["theme_id"]])
226
259
  if bad:
@@ -384,7 +417,7 @@ def default_frame_specs(tl):
384
417
 
385
418
 
386
419
  # ---------------------------------------------------------------- 基线读写
387
- def _golden_payload(script_path, scenes_path, K, plan, tl, rendered):
420
+ def _golden_payload(script_path, scenes_path, K, plan, tl, rendered, design_path=None):
388
421
  font_fp, fonts = font_fingerprint(K, plan)
389
422
  env_fp, env = env_fingerprint()
390
423
  return {
@@ -398,6 +431,7 @@ def _golden_payload(script_path, scenes_path, K, plan, tl, rendered):
398
431
  "fps": int(tl["fps"]),
399
432
  "script": _rel(script_path),
400
433
  "scenes": _rel(scenes_path),
434
+ "design": _rel(design_path) if design_path else "",
401
435
  "font_fp": font_fp,
402
436
  "fonts": fonts,
403
437
  "env_fp": env_fp,
@@ -407,6 +441,9 @@ def _golden_payload(script_path, scenes_path, K, plan, tl, rendered):
407
441
  "scenes_sha1": _sha1_file(scenes_path),
408
442
  "filmkit_sha1": _sha1_file(os.path.join(SCRIPTS, "filmkit.py")),
409
443
  "stylekit_sha1": _sha1_file(os.path.join(SCRIPTS, "stylekit.py")),
444
+ # 设计 fixture 进指纹:改了基准设计却不重录基线 = "改了但基线不报警"
445
+ "design_sha1": _sha1_file(design_path) if design_path else "",
446
+ "designkit_sha1": _sha1_file(os.path.join(SCRIPTS, "designkit.py")),
410
447
  "golden_tool_sha1": _sha1_file(os.path.abspath(__file__)),
411
448
  },
412
449
  "frames": [dict(sp, sha256=sha, scene=scene_id, frame=int(f))
@@ -414,13 +451,13 @@ def _golden_payload(script_path, scenes_path, K, plan, tl, rendered):
414
451
  }
415
452
 
416
453
 
417
- def write_golden(script_path, scenes_path, golden_path, frames=None):
454
+ def write_golden(script_path, scenes_path, golden_path, frames=None, design_path=None):
418
455
  """--write:渲染金帧并写基线。返回 result(status=pass,written=True)。"""
419
456
  K, ST = _modules()
420
457
  script = _load_script(script_path)
421
458
  scenes_mod = _load_scenes(scenes_path)
422
459
  tl = build_timeline(K, script)
423
- plan = resolve_plan(ST, script)
460
+ plan = resolve_plan(ST, script, design_path=design_path)
424
461
  if frames:
425
462
  specs = [dict(frame=int(f), scene=str(_scene_at(tl, int(f) / float(tl["fps"]))[0]
426
463
  .get("id") or ""),
@@ -432,7 +469,8 @@ def write_golden(script_path, scenes_path, golden_path, frames=None):
432
469
  specs = default_frame_specs(tl)
433
470
  rendered = render_frames(script, scenes_mod, plan, tl, [sp["frame"] for sp in specs])
434
471
  plan = dict(plan, specs=specs)
435
- data = _golden_payload(script_path, scenes_path, K, plan, tl, rendered)
472
+ data = _golden_payload(script_path, scenes_path, K, plan, tl, rendered,
473
+ design_path=design_path)
436
474
  golden_path = os.path.abspath(str(golden_path))
437
475
  d = os.path.dirname(golden_path)
438
476
  if d:
@@ -475,7 +513,7 @@ def _fmt_fp_pair(actual, expect):
475
513
  return "%s… vs %s…" % (str(actual)[:12], str(expect)[:12])
476
514
 
477
515
 
478
- def check_golden(script_path, scenes_path, golden_path):
516
+ def check_golden(script_path, scenes_path, golden_path, design_path=None):
479
517
  """--check:渲染基线里的帧号并与基线比对(默认命令)。"""
480
518
  golden, why = _load_golden(golden_path)
481
519
  if golden is None:
@@ -498,7 +536,7 @@ def check_golden(script_path, scenes_path, golden_path):
498
536
  script = _load_script(script_path)
499
537
  scenes_mod = _load_scenes(scenes_path)
500
538
  tl = build_timeline(K, script)
501
- plan = resolve_plan(ST, script)
539
+ plan = resolve_plan(ST, script, design_path=design_path)
502
540
  font_fp, fonts = font_fingerprint(K, plan)
503
541
  env_fp, env = env_fingerprint()
504
542
  # 先做"能不能比"的判定,再渲(省得换机时白渲三帧)
@@ -551,7 +589,9 @@ def check_golden(script_path, scenes_path, golden_path):
551
589
  cur_inputs = dict(script_sha1=_sha1_file(script_path),
552
590
  scenes_sha1=_sha1_file(scenes_path),
553
591
  filmkit_sha1=_sha1_file(os.path.join(SCRIPTS, "filmkit.py")),
554
- stylekit_sha1=_sha1_file(os.path.join(SCRIPTS, "stylekit.py")))
592
+ stylekit_sha1=_sha1_file(os.path.join(SCRIPTS, "stylekit.py")),
593
+ design_sha1=(_sha1_file(design_path) if design_path else ""),
594
+ designkit_sha1=_sha1_file(os.path.join(SCRIPTS, "designkit.py")))
555
595
  stale = [k for k, v in cur_inputs.items()
556
596
  if inputs.get(k) and inputs.get(k) != v]
557
597
  if changed:
@@ -608,6 +648,9 @@ def main(argv=None):
608
648
  g.add_argument("--check", action="store_true", help="校验(默认)")
609
649
  g.add_argument("--write", action="store_true", help="生成/更新基线")
610
650
  ap.add_argument("--script", default=DEFAULT_SCRIPT, help="film.json(默认模板片)")
651
+ ap.add_argument("--design", default=DEFAULT_DESIGN,
652
+ help="固定 fixture 设计(默认 assets/design_fixture.json);"
653
+ "传空串回落到老的 stylekit 口径")
611
654
  ap.add_argument("--scenes", default=DEFAULT_SCENES, help="scenes.py(默认模板场景)")
612
655
  ap.add_argument("--golden", default=DEFAULT_GOLDEN,
613
656
  help="基线 JSON(默认 skills/video-studio/assets/golden_frames.json)")
@@ -627,12 +670,14 @@ def main(argv=None):
627
670
  raise SystemExit("--frames 必须是逗号分隔的整数帧号,收到 %r" % a.frames)
628
671
  if not frames:
629
672
  raise SystemExit("--frames 里没有有效帧号")
630
- res = write_golden(a.script, a.scenes, a.golden, frames)
673
+ res = write_golden(a.script, a.scenes, a.golden, frames,
674
+ design_path=(a.design or None))
631
675
  else:
632
676
  if a.frames:
633
677
  raise SystemExit("--frames 只用于 --write;--check 的帧号由基线决定"
634
678
  "(要换帧号请 --write --frames ... 重录)")
635
- res = check_golden(a.script, a.scenes, a.golden)
679
+ res = check_golden(a.script, a.scenes, a.golden,
680
+ design_path=(a.design or None))
636
681
  except SystemExit:
637
682
  raise
638
683
  except Exception as e: # noqa: BLE001