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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +473 -0
  2. package/README.md +263 -327
  3. package/THIRD_PARTY_NOTICES.md +2 -2
  4. package/package.json +9 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +31 -42
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1216 -1123
  9. package/skills/video-studio/assets/design_fixture.json +141 -0
  10. package/skills/video-studio/assets/film.json +2 -4
  11. package/skills/video-studio/assets/golden_frames.json +13 -10
  12. package/skills/video-studio/assets/scenes.py +153 -65
  13. package/skills/video-studio/assets/voice_loudness.json +16 -0
  14. package/skills/video-studio/references/appearance.md +123 -0
  15. package/skills/video-studio/references/audio-variation.md +74 -93
  16. package/skills/video-studio/references/audio.md +64 -66
  17. package/skills/video-studio/references/change-impact.md +5 -5
  18. package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
  19. package/skills/video-studio/references/frame-qa.md +48 -16
  20. package/skills/video-studio/references/pipeline.md +4 -4
  21. package/skills/video-studio/references/pitfalls.md +22 -3
  22. package/skills/video-studio/references/proposal.md +160 -0
  23. package/skills/video-studio/references/quality-gates.md +11 -11
  24. package/skills/video-studio/references/scene-authoring.md +2 -2
  25. package/skills/video-studio/references/scene-grammar.md +40 -42
  26. package/skills/video-studio/references/sfx-design.md +8 -6
  27. package/skills/video-studio/references/web-engine.md +8 -8
  28. package/skills/video-studio/references/word-timeline.md +9 -9
  29. package/skills/video-studio/requirements.txt +9 -13
  30. package/skills/video-studio/scripts/designkit.py +2115 -0
  31. package/skills/video-studio/scripts/filmcheck.py +22 -48
  32. package/skills/video-studio/scripts/filmkit.py +2980 -2904
  33. package/skills/video-studio/scripts/gates.py +4 -4
  34. package/skills/video-studio/scripts/grammar.py +73 -13
  35. package/skills/video-studio/scripts/musicgen.py +2042 -0
  36. package/skills/video-studio/scripts/preflight.py +14 -7
  37. package/skills/video-studio/scripts/previewkit.py +848 -0
  38. package/skills/video-studio/scripts/propose.py +825 -0
  39. package/skills/video-studio/scripts/qa_frames.py +73 -39
  40. package/skills/video-studio/scripts/qaasr.py +26 -7
  41. package/skills/video-studio/scripts/qavmaf.py +89 -23
  42. package/skills/video-studio/scripts/render.py +545 -231
  43. package/skills/video-studio/scripts/scorekit.py +55 -1755
  44. package/skills/video-studio/scripts/selfcheck.py +815 -215
  45. package/skills/video-studio/scripts/sfxkit.py +27 -26
  46. package/skills/video-studio/scripts/stagewatch.py +24 -8
  47. package/skills/video-studio/scripts/stylekit.py +207 -888
  48. package/skills/video-studio/scripts/voicekit.py +508 -387
  49. package/skills/video-studio/scripts/webengine.py +71 -72
  50. package/tools/audio_similarity.py +64 -37
  51. package/tools/ci_check.py +20 -14
  52. package/tools/estimate.py +34 -29
  53. package/tools/golden_frames.py +72 -28
  54. package/tools/silhouette_qa.py +520 -168
  55. package/docs/demo-pil-sheet.png +0 -0
  56. package/docs/styles/demo-results.json +0 -76
  57. package/docs/styles/sheet-8-styles.png +0 -0
  58. package/docs/styles/sheet-ai.png +0 -0
  59. package/docs/styles/sheet-before-after.png +0 -0
  60. package/docs/styles/sheet-craft.png +0 -0
  61. package/docs/styles/sheet-night.png +0 -0
  62. package/skills/video-studio/assets/score_demo.json +0 -307
  63. package/skills/video-studio/references/style-packs.md +0 -283
@@ -0,0 +1,141 @@
1
+ {
2
+ "schema": 1,
3
+ "id": "fixture-golden",
4
+ "label": "金帧基准设计",
5
+ "pitch": "验收基准:固定设计,用于像素回归(不是给人看的方案)",
6
+ "structure": {
7
+ "segments": 4,
8
+ "beats": [
9
+ "hook",
10
+ "body",
11
+ "body",
12
+ "outro"
13
+ ],
14
+ "hold_policy": "readable",
15
+ "target_sec": 30.0
16
+ },
17
+ "visual": {
18
+ "palette": {
19
+ "bg": [
20
+ 7,
21
+ 14,
22
+ 26
23
+ ],
24
+ "bg2": [
25
+ 17,
26
+ 33,
27
+ 54
28
+ ],
29
+ "surface": [
30
+ 26,
31
+ 42,
32
+ 64
33
+ ],
34
+ "ink": [
35
+ 230,
36
+ 216,
37
+ 207
38
+ ],
39
+ "muted": [
40
+ 178,
41
+ 157,
42
+ 143
43
+ ],
44
+ "dim": [
45
+ 153,
46
+ 135,
47
+ 122
48
+ ],
49
+ "accent": [
50
+ 230,
51
+ 106,
52
+ 23
53
+ ],
54
+ "accent2": [
55
+ 92,
56
+ 230,
57
+ 216
58
+ ],
59
+ "good": [
60
+ 71,
61
+ 178,
62
+ 98
63
+ ],
64
+ "bad": [
65
+ 178,
66
+ 97,
67
+ 89
68
+ ],
69
+ "rule": [
70
+ 255,
71
+ 255,
72
+ 255
73
+ ]
74
+ },
75
+ "family": "sans",
76
+ "typography": {
77
+ "tracking": 0.0,
78
+ "line_gap": 1.3,
79
+ "number": "dim",
80
+ "ratio": 1.5,
81
+ "align": "left"
82
+ },
83
+ "motif": {
84
+ "kind": "brackets",
85
+ "density": 1.0,
86
+ "detail": {}
87
+ },
88
+ "transition": {
89
+ "kind": "dip",
90
+ "dur": 0.28
91
+ },
92
+ "motion": {
93
+ "ease": "out3",
94
+ "enter": [
95
+ 0.45,
96
+ 0.85
97
+ ],
98
+ "push": 0.04,
99
+ "breathe_hz": 0.85
100
+ },
101
+ "bias": {
102
+ "x": 0,
103
+ "y": 0,
104
+ "inset": 0
105
+ },
106
+ "layout": "left-stack",
107
+ "hud": "chip-left",
108
+ "size": "1920x1080",
109
+ "fps": 30
110
+ },
111
+ "audio": {
112
+ "voice": {
113
+ "mode": "edge_id",
114
+ "id": "zh-CN-YunyangNeural",
115
+ "map": {},
116
+ "jitter": 0.0,
117
+ "max_distinct": 1
118
+ },
119
+ "music": {
120
+ "mode": "params",
121
+ "style": "tech-deep",
122
+ "scale": "minor",
123
+ "bpm": 96.0,
124
+ "key": "Dm",
125
+ "mood": "steady",
126
+ "seed": 7
127
+ },
128
+ "sfx": {
129
+ "enabled": false,
130
+ "density": "none",
131
+ "gain_db": -6
132
+ }
133
+ },
134
+ "pacing": {
135
+ "lead": 0.62,
136
+ "tail": 0.92,
137
+ "min_dur": 3.0,
138
+ "hold": 0.0,
139
+ "item_stagger": 0.3
140
+ }
141
+ }
@@ -1,8 +1,7 @@
1
1
  {
2
- "_comment": "v0.5 模板:style 决定画风+配乐+旁白+节奏。'auto' = 按内容自动选;写死风格 id 可锁定(tech-deep/magazine/paper/vivid/mono/cinema/lo-fi/epic)。删掉 style 字段 = 回到 v0.4 行为(逐样本兼容)。节奏(lead/tail/min_dur/hold)默认由风格给,想覆盖就自己加字段。",
2
+ "_comment": "自测/演示样例剧本(仓库测试夹具,不是给用户抄的模板)。外观由 work/design.json 决定(propose.py --brief 现场生成后 --confirm 冻结),这里没有 style 字段。文本仅用于跑通全链与金帧/门禁回归,换题材请自己写 lines[]。",
3
3
  "title": "测试片 demo",
4
4
  "end_text": "完",
5
- "style": "auto",
6
5
  "voice": "zh-CN-YunxiNeural",
7
6
  "rate": "+6%",
8
7
  "pitch": "+0Hz",
@@ -18,7 +17,6 @@
18
17
  "music": {
19
18
  "enabled": true,
20
19
  "mode": "score",
21
- "style": "auto",
22
20
  "mood": "auto",
23
21
  "bpm": "auto",
24
22
  "key": "auto",
@@ -55,7 +53,7 @@
55
53
  "enabled": true,
56
54
  "gain_db": 0,
57
55
  "content_seed": true,
58
- "_comment": "v0.7 去固定化(references/audio-variation.md §3.5):模板只留 1 条转场 whoosh 作示例,具体提示点建议交给 assets/scenes.py 的 cues() 钩子按画面内容生成(hook=impact+riser;body=ui_blip 按条目文本定音高 + answer_chime + 段尾 whoosh;quote=reverse_swell+impact;outro=answer_chime+sub_drop)。content_seed=true(默认)= 每条 cue 的 seed 由 style/content_fp 派生(不同片子同一位置也不同),显式 false 回到 v0.6 的 seed_base+i 旧行为。",
56
+ "_comment": "夹具只留 1 条转场 whoosh 作示例(见 references/audio-variation.md §3.4);具体提示点交给 assets/scenes.py 的 cues() 钩子按画面内容生成(hook=impact+riser;body=ui_blip 按条目文本定音高 + answer_chime + 段尾 whoosh;quote=reverse_swell+impact;outro=answer_chime+sub_drop)。content_seed=true(默认)= 每条 cue 的 seed 由 style/content_fp 派生(不同片子同一位置也不同),显式 false 时按 seed_base+i 只跟 cue 顺序。",
59
57
  "cues": [
60
58
  {
61
59
  "at": "l02-0.15",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "generator": "golden_frames.py v0.8.0",
4
- "comment": "金帧基线:字体指纹+环境指纹一致时,每一帧像素 sha256 必须相同;换机/字体/Pillow 版本不同判 skipped(不误报),同环境像素变了才是 fail。改动模板(film.json / scenes.py / stylekit 主题)后请 --write 重录并提交本文件。",
3
+ "generator": "golden_frames.py v1.0.4",
4
+ "comment": "金帧基线:字体指纹+环境指纹一致时,每一帧像素 sha256 必须相同;换机/字体/Pillow 版本不同判 skipped(不误报),同环境像素变了才是 fail。改动夹具(film.json / scenes.py / 设计)后请 --write 重录并提交本文件。",
5
5
  "canvas": [
6
6
  1920,
7
7
  1080
@@ -9,6 +9,7 @@
9
9
  "fps": 30,
10
10
  "script": "skills/video-studio/assets/film.json",
11
11
  "scenes": "skills/video-studio/assets/scenes.py",
12
+ "design": "skills/video-studio/assets/design_fixture.json",
12
13
  "font_fp": "05c853d0eb28bcca3125d81c1ce27c9436f66a55",
13
14
  "fonts": [
14
15
  {
@@ -33,11 +34,13 @@
33
34
  "platform": "Windows 11"
34
35
  },
35
36
  "inputs": {
36
- "script_sha1": "2f375a7298ea861684a2d553cb6617ed4351d3e5",
37
- "scenes_sha1": "aab9df135ce5a7a3f2750d275dfcdac48405d4a4",
38
- "filmkit_sha1": "a582ad1a2b53cf1ae163ab3052d054bc196c7d49",
39
- "stylekit_sha1": "4bcbce2048b89c66352455f6e82c0943cc2e0508",
40
- "golden_tool_sha1": "17cdc5d64705d54c157cf86f76899393a5d0b8c4"
37
+ "script_sha1": "6f70eabff4a90ba4c488aada0af81e806c3cce92",
38
+ "scenes_sha1": "13902dfa5a23edec00eb5306dbc5297e6403ac1c",
39
+ "filmkit_sha1": "752eeda4b47a739925b60d4d6b98fee99261d905",
40
+ "stylekit_sha1": "bff8f89bf09ff64e12a96d7a1dbb241319553db4",
41
+ "design_sha1": "f87cc13e4c55040d9de5675933c01aa8220134d1",
42
+ "designkit_sha1": "785d699349bfef56b9acfbb94dd4f2b92ab3444e",
43
+ "golden_tool_sha1": "1807499ab7d15d466aeba3aef361c95fe4e5c387"
41
44
  },
42
45
  "frames": [
43
46
  {
@@ -45,21 +48,21 @@
45
48
  "scene": "l01",
46
49
  "t_local": 1.2,
47
50
  "label": "hook",
48
- "sha256": "ee2a70067aeaee0c158c7eef6c26c1228354bf247f05f7330c991ef1946aaaae"
51
+ "sha256": "af738e3252e22abd3034c794b515ba0fde0d25eb41c6fa6ae8e1efdbff055a21"
49
52
  },
50
53
  {
51
54
  "frame": 270,
52
55
  "scene": "l03",
53
56
  "t_local": 1.6,
54
57
  "label": "body",
55
- "sha256": "ead954c01f5498782b3a31b8e42638d4f8f42e2faca97df476e0d78a1f9f72f8"
58
+ "sha256": "b86f97ab6a9120446700bc2c790944ebe7db6fbc24ed20871f79426a94a0980e"
56
59
  },
57
60
  {
58
61
  "frame": 369,
59
62
  "scene": "l04",
60
63
  "t_local": 1.5,
61
64
  "label": "outro",
62
- "sha256": "10463a35429185e7b816fd5ede7107d529e26f4ec8d8640fba84d9897cc77f66"
65
+ "sha256": "1850ff2313378c1fe0e7579aeb433c1d1fce072478f5924b913244095d2991f0"
63
66
  }
64
67
  ]
65
68
  }
@@ -1,20 +1,26 @@
1
1
  # -*- coding: utf-8 -*-
2
- """scenes.py — 场景绘制模板(v0.5.0:画风随内容变化)。
2
+ """scenes.py — 技能内置的场景渲染器(默认渲染路径 + 自定义场景的范例)。
3
+
4
+ ⚠ 这**不是**给用户拷走改的模板:render.py 不写 --scenes 时会自动回落到本文件,
5
+ 用户工程目录里只需要一个 film.json。想改画面时,在剧本同目录放一个自己的 scenes.py
6
+ (或 --scenes 显式指定),它会被优先发现;本文件就是那份自写场景的**参考实现**:
7
+ 10 类构图 / 8 种母题 / 6 种转场 / 35 字段主题 / 版式轮换 / 字幕参数全部从计划里读。
3
8
 
4
9
  唯一约定:提供 draw_frame(scene, t_local, dur, t_global, script, frame) -> PIL.Image
5
- scene : timeline.json 里的一段(含 id/text/sub/start/end/dur)
10
+ scene : timeline.json 里的一段(含 id/text/sub/start/end/dur 与 grammar 注入的
11
+ scene_type / scene_variant / scene_decor / scene_layout / scene_reason)
6
12
  t_local : 本段内已经过秒数
7
13
  dur : 本段总秒数
8
14
  t_global: 全片秒数
15
+ script : 剧本 dict(含 _style 计划、_total 总时长)
9
16
  frame : 全片帧号(确定性:同一帧号必须画出同一张图)
10
17
 
11
- v0.5.0 画风主题化
12
- ----------------
13
- 剧本里写了 style(或 render.py 把 stylekit 解析出的计划塞进 script["_style"])时,
14
- 本文件从 stylekit 的主题包里取配色 / 材质 / 字体族 / 版式 / 缓动 / HUD / 镜头缓推,
15
- 8 套风格各长一个样(深蓝科技 / 暖白杂志 / 手绘纸感 / 活力撞色 / 极简黑白 /
16
- 电影青橙 / 暖灰薄荷 / 深黑鎏金)。没写 style 的老项目 = legacy:
17
- 走下面原封不动的 v0.4 绘制函数,逐字节可复现(基线三帧 sha256 由 selfcheck 比对)。
18
+ 画风从哪来
19
+ ----------
20
+ render.py 把 designkit.design_to_plan() 的计划塞进 script["_style"](主题 / 视觉语言 /
21
+ 版式轮换 / 配乐 / 声线 / 节奏 / 字幕样式)。本文件从这份计划里取配色、材质、字体族、
22
+ 版式、缓动、HUD、镜头缓推——**没有任何内置风格表**:
23
+ 同一份代码画出来是什么样,完全由现场采样并确认过的 design.json 决定。
18
24
 
19
25
  铁律(决定画质与可复现性):
20
26
  1. 不许用 random / time.time();要随机用 K.hash2 / K.hash3 / vnoise(seed)
@@ -56,10 +62,10 @@ import filmkit as K
56
62
  from filmkit import W, H, clamp, smooth, ease_out, rng
57
63
 
58
64
  # ============================================================================
59
- # 零、v0.4 兼容层(legacy 逐字节基线,别动这里的任何一行)
65
+ # 零、legacy 绘制器(逐字节基线,别动这里的任何一行)
60
66
  # ============================================================================
61
67
  #
62
- # legacy = 老项目(film.json 没有 style 字段)。这组函数是 v0.4 的原文,
68
+ # legacy = 老项目(film.json 没有 style 字段)。这组函数是逐字节基线,
63
69
  # 一个字符都不许改:Lead 的基线三帧 sha256 直接对拍这里的输出。
64
70
  # 新主题化代码只在 _legacy_* 之外生长,互不影响。
65
71
 
@@ -237,7 +243,7 @@ _LEGACY_DRAWERS = {
237
243
 
238
244
 
239
245
  # ============================================================================
240
- # 一、风格计划 / 主题(v0.5)
246
+ # 一、风格计划 / 主题
241
247
  # ============================================================================
242
248
 
243
249
  _PLAN_CACHE = {}
@@ -271,11 +277,31 @@ def _plan(script):
271
277
  return got
272
278
 
273
279
 
280
+ # 主题里这些键的值是 RGB,统一要 (r,g,b) 元组。JSON 往返会把它变成 list,
281
+ # 而 list 不能当缓存键 —— _soft_dot / _bg 之类的 key 里都带着颜色,一旦错位就是
282
+ # "unhashable type: 'list'"。实测触发路径:--stage frames --jobs N 把 script 写成
283
+ # _script_snapshot.json,子进程读回来重建,于是 theme.bg 是 [8,8,10]。
284
+ # 修在**入口**(_scene_theme)而不是每个缓存点:缓存点会越加越多,入口只有一个。
285
+ _THEME_RGB_KEYS = ("bg", "bg2", "surface", "ink", "muted", "dim",
286
+ "accent", "accent2", "good", "bad", "rule", "vignette_tint")
287
+
288
+
289
+ def _freeze_theme(th):
290
+ """把主题里的颜色值统一成元组(就地不重复建字面量的前提下返回新 dict)。"""
291
+ out = dict(th)
292
+ for k in _THEME_RGB_KEYS:
293
+ v = out.get(k)
294
+ if isinstance(v, (list, tuple)) and len(v) >= 3:
295
+ out[k] = (int(v[0]), int(v[1]), int(v[2]))
296
+ # scale / card / qa 是嵌套 dict,外面只读不建缓存键,原样保留
297
+ return out
298
+
299
+
274
300
  def _scene_theme(plan, scene):
275
301
  """本段用哪套主题:全片主题 + 可选的 scene["theme"] 覆盖(只许同明暗族)。"""
276
302
  base = plan.get("theme")
277
303
  if isinstance(base, dict):
278
- th = dict(base)
304
+ th = _freeze_theme(base)
279
305
  else:
280
306
  th = dict(ST.THEMES[plan["theme_id"]])
281
307
  sid = scene.get("theme")
@@ -293,7 +319,7 @@ def _scene_theme(plan, scene):
293
319
  "场景 %r 想从主题 %s 换到 %s,两者明暗族不同(同片换族会触发闪烁门禁),已忽略"
294
320
  % (scene.get("id"), th.get("id"), sid))
295
321
  return th
296
- return dict(alt)
322
+ return _freeze_theme(alt)
297
323
 
298
324
 
299
325
  # ---------------- 包内小工具 ----------------
@@ -332,14 +358,14 @@ def _ease(name):
332
358
  _EASES = {}
333
359
 
334
360
 
335
- # ---------------- 字体:family 是 v0.5 新加的参数(旧 filmkit 没有,自动回退) ----------------
361
+ # ---------------- 字体:family 参数(旧 filmkit 没有就自动回退) ----------------
336
362
 
337
363
  _FONT_CACHE = {}
338
364
  _FAMILY_OK = None
339
365
 
340
366
 
341
367
  def _family_ok():
342
- """filmkit.font 是否支持 family=...(Lead 在 v0.5 加;老版本没有就回退旧签名)。"""
368
+ """filmkit.font 是否支持 family=...(老版本没有就回退旧签名)。"""
343
369
  global _FAMILY_OK
344
370
  if _FAMILY_OK is None:
345
371
  try:
@@ -427,7 +453,7 @@ class _View(object):
427
453
 
428
454
  为什么不是整帧重采样:实测 1080p 双线性整帧变换 29ms/帧(预算 15ms),
429
455
  而整帧按 push 放大又会把 150px 边距的内容推出 SAFE。所以分两层:
430
- 背景层做**真缩放**(EXTENT + NEAREST,1.7~3.5ms,见 _bg_plate);
456
+ 背景层做**真缩放**(端点预烘 + blend,约 4.4ms,见 _plate_zoom/_bg_plate);
431
457
  前景层做同向**视差**(0.35x,位置与卡片尺寸随镜头收放,字形不重采样保清晰)。
432
458
  """
433
459
  __slots__ = ("s", "cx", "cy")
@@ -573,15 +599,63 @@ def _bg(th):
573
599
  return im
574
600
 
575
601
 
576
- def _bg_plate(th, s):
577
- """逐帧背景:按镜头缩放取景(NEAREST 重采样,实测 1.7~3.5ms/帧)。"""
578
- base = _bg(th)
602
+ _ZOOM_ORDER = []
603
+
604
+
605
+ def _zoom_tag(th, extra=None):
606
+ """取景端点的缓存标签:主题决定底图,extra 用来区分装饰母题档位。"""
607
+ return _hashable(("zoom", th.get("id"), th["bg"], th["bg2"], th["texture"],
608
+ th["grain"], th["grain_freq"], th["vignette"],
609
+ th["vignette_tint"], th["texture_alpha"], extra))
610
+
611
+
612
+ def _plate_zoom(src, s, th, tag):
613
+ """按镜头缓推取景:端点预烘 + 逐帧 blend。
614
+
615
+ 为什么不再用 NEAREST 直采:底图把颗粒(`_grain_layer`)一起烘了进去,颗粒是
616
+ 逐像素的高频图案;NEAREST 在缓慢缩放时采样网格会整像素跳变,颗粒图案随之整体
617
+ 错位 1px,观感就是"满屏噪点在沸腾"——用户实测反馈是"明显的抖动感",而背景-only
618
+ 的帧差只有 4~6 灰阶,单帧闪烁门禁根本抓不到。
619
+
620
+ 做法:s=1.0 与 s=1+push 两个端点各烘一张 BILINEAR 底版(每张约 60ms,只做一次),
621
+ 逐帧用 `Image.blend` 在两端点间线性插值(实测 4.4ms/帧)。端点内部是**平滑重采样**,
622
+ 没有 NEAREST 的网格跳变,帧差降到 0~1 灰阶;同时保留颗粒质感(不是把 grain 抹掉)。
623
+
624
+ 上限取 1+push:`_push_scale` 恒 <= 1+push,超出的 s 一律钳到端点上,不会外推。
625
+ """
579
626
  if s <= 1.0008:
580
- return base.copy()
581
- bw, bh = W / s, H / s
582
- x0 = (W - bw) * 0.5
583
- y0 = (H - bh) * 0.5
584
- return base.transform((W, H), Image.EXTENT, (x0, y0, x0 + bw, y0 + bh), Image.NEAREST)
627
+ return src.copy()
628
+ cap = 1.0 + max(0.0, float(th.get("push") or _DEFAULT_PUSH))
629
+ if cap <= 1.0008:
630
+ return src.copy()
631
+ s = min(float(s), cap)
632
+ key = _hashable(("zpts", tag, round(cap, 6)))
633
+ pair = _CACHE.get(key)
634
+ if pair is None:
635
+ lo = src.copy()
636
+ bw, bh = W / cap, H / cap
637
+ x0 = (W - bw) * 0.5
638
+ y0 = (H - bh) * 0.5
639
+ hi = src.transform((W, H), Image.EXTENT, (x0, y0, x0 + bw, y0 + bh), Image.BILINEAR)
640
+ pair = (lo, hi)
641
+ _CACHE[key] = pair
642
+ _ZOOM_ORDER.append(key)
643
+ while len(_ZOOM_ORDER) > 4: # 一对端点约 12MB,限量防多片同进程膨胀
644
+ old = _ZOOM_ORDER.pop(0)
645
+ if old != key:
646
+ _CACHE.pop(old, None)
647
+ lo, hi = pair
648
+ w = (s - 1.0) / (cap - 1.0)
649
+ if w <= 0.0:
650
+ return lo.copy()
651
+ if w >= 1.0:
652
+ return hi.copy()
653
+ return Image.blend(lo, hi, w)
654
+
655
+
656
+ def _bg_plate(th, s):
657
+ """逐帧背景:按镜头缩放取景(端点预烘 + blend,约 4.4ms/帧;见 _plate_zoom)。"""
658
+ return _plate_zoom(_bg(th), s, th, _zoom_tag(th, "bg"))
585
659
 
586
660
 
587
661
  # ---------------- 版式几何(全部从 ST.LAYOUTS 取) ----------------
@@ -604,7 +678,7 @@ def _panel_x(lay):
604
678
  def _panel_boxes(lay, th, n, y_floor, v):
605
679
  """条目卡片布局:选列数 + 压缩卡片高度,保证①不压标题②不越字幕带(856)。
606
680
 
607
- panel_gap 在 stylekit 里是**行距(pitch)**不是间隙:v0.4 用 240 + i*118 排卡片,
681
+ panel_gap 在 stylekit 里是**行距(pitch)**不是间隙:按 240 + i*118 排卡片时,
608
682
  所以 pitch 至少得比卡片高度多 6px,否则卡片会叠在一起。条目多时先加列,再压高度,
609
683
  最后才截断——静默重叠或画到字幕带下面比少画两条更糟。
610
684
  """
@@ -748,14 +822,14 @@ def _hud(img, th, chapter, progress, title):
748
822
 
749
823
 
750
824
  # ============================================================================
751
- # 三、场景语法(v0.9:10 类构图 × 8 套视觉语言)
825
+ # 三、场景语法(10 类构图 × 8 套视觉语言)
752
826
  # ============================================================================
753
827
  #
754
828
  # 为什么重写这一节
755
829
  # ---------------
756
- # v0.5 的 _draw_hook / _draw_body / _draw_outro 是"一套骨架 + 换色":背景板 → 眉标 →
830
+ # 旧的 _draw_hook / _draw_body / _draw_outro 是"一套骨架 + 换色":背景板 → 眉标 →
757
831
  # 大字 → 副标题 → 强调线 → HUD,三种 kind 只差字号与位置。于是 8 套风格换完只剩配色不同。
758
- # v0.9 把"画什么"(scene type,由 grammar.py 按内容选)与"怎么画"(视觉语言,由 stylekit
832
+ # 现在把"画什么"(scene type,由 grammar.py 按内容选)与"怎么画"(视觉语言,由 stylekit
759
833
  # 的 VISUAL_LANGUAGES 给)拆开:
760
834
  # · 10 类构图各自有独立的视觉重心与留白(hero 偏左上 / statement 正中 / compare 强制对称 …)
761
835
  # · 每套风格有自己的装饰母题(括角 / 手绘内框 / letterbox / 信号条 …)与转场签名
@@ -889,9 +963,9 @@ def _motif_layer(th, lang):
889
963
  """把该风格独有的装饰图形烘焙成一张全屏 RGBA 图层(不含动态)。
890
964
 
891
965
  为什么烘焙:这些图形是静态的(括角、页眉线、letterbox、信号条),
892
- 逐帧重画一次就是 3–8ms。注意:v0.9.0 这里写的是"paste 只要 0.3ms",
893
- 实测全屏 RGBA paste 是 **5.6ms**(Pillow 13 / 1080p),所以 v0.9.1 起
894
- 装饰直接烘焙进底版(见 _plate),逐帧只 copy 底版,不再单独 paste。
966
+ 逐帧重画一次就是 3–8ms。注意:全屏 RGBA paste 实测是 **5.6ms**
967
+ (Pillow 13 / 1080p),所以装饰直接烘焙进底版(见 _plate),
968
+ 逐帧只 copy 底版,不再单独 paste。
895
969
  """
896
970
  mf = lang.get("motif") or _DEFAULT_LANG["motif"]
897
971
  key = _motif_key(th, lang)
@@ -1018,10 +1092,10 @@ _PLATE_ORDER = []
1018
1092
 
1019
1093
 
1020
1094
  def _plate(th, lang, scene, s_bg):
1021
- """背景板 + 装饰母题合成后的整帧底版(v0.9.1)。
1095
+ """背景板 + 装饰母题合成后的整帧底版。
1022
1096
 
1023
1097
  这是单帧预算里最大的一处优化:装饰是静态的,逐帧单独 paste 一张全屏 RGBA 要 5.6ms,
1024
- 合成进底版后逐帧只剩 copy(1.3ms)或 EXTENT 缩放(1.7–2.0ms)。
1098
+ 合成进底版后逐帧只剩 copy(1.3ms)或端点 blend 取景(约 4.4ms,见 _plate_zoom)。
1025
1099
  顺带一个好处:镜头缓推现在会把装饰一起推——装饰本来就该跟着镜头走,
1026
1100
  以前"背景推、装饰不动"在强 push 的风格(cinema 0.12 / epic 0.14)里其实是穿帮。
1027
1101
 
@@ -1046,12 +1120,7 @@ def _plate(th, lang, scene, s_bg):
1046
1120
  old = _PLATE_ORDER.pop(0)
1047
1121
  if old != key:
1048
1122
  _CACHE.pop(old, None)
1049
- if s_bg <= 1.0008:
1050
- return got.copy()
1051
- bw, bh = W / s_bg, H / s_bg
1052
- x0 = (W - bw) * 0.5
1053
- y0 = (H - bh) * 0.5
1054
- return got.transform((W, H), Image.EXTENT, (x0, y0, x0 + bw, y0 + bh), Image.NEAREST)
1123
+ return _plate_zoom(got, s_bg, th, _zoom_tag(th, key))
1055
1124
 
1056
1125
 
1057
1126
  def _apply_motif(img, th, lang, scene):
@@ -1069,7 +1138,7 @@ def _apply_veil(img, th, lang, scene, t_local, s_bg):
1069
1138
  """段首从背景"长出来"的过场。不做跨场景 cross-dissolve(draw_frame 拿不到上一段帧),
1070
1139
  只做"从本段背景板渐显 / 擦入",观感是 dip 而不是 dissolve。
1071
1140
 
1072
- 为什么必须做:v0.9 起相邻两段可能是完全不同的构图(数字卡 → 左右对比),
1141
+ 为什么必须做:相邻两段可能是完全不同的构图(数字卡 → 左右对比),
1073
1142
  整屏瞬切会精确触发 qa_frames 的单帧闪烁门禁(8x8 分块灰度差 ≥6.0),
1074
1143
  观众看着也像"换了一部片"。
1075
1144
  """
@@ -1194,13 +1263,19 @@ def _draw_hero(scene, t, dur, tg, script, frame, plan, th):
1194
1263
  lh = int(size * gap)
1195
1264
  ksize = int(th["scale"]["kicker"][0])
1196
1265
  kicker = scene.get("kicker") or "00 · 开场"
1266
+ y = float(lay["title_y"]) + bdy
1197
1267
 
1268
+ # kicker 的纵向位置必须**跟着标题字号走**,不能直接用布局表里的固定 y。
1269
+ # 为什么:布局表给的是 left-stack 的 title_y=300 / kicker_y=250,只差 50px;
1270
+ # 而 hero 的标题字号是 96px(display 档),字形往上顶穿 kicker —— 实测封面上
1271
+ # "00 · 开场"直接压在"价值与代价来自同一个选择"中间。
1272
+ # 按字号算间距后,换字体/换字号档(visual 语言包会改 ratio)都不会再压。
1273
+ _ky = min(float(lay["kicker_y"]), y - size * 1.05 - ksize * 0.35)
1198
1274
  p0 = ease(rng(t, 0.05, 0.85))
1199
1275
  _draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
1200
- float(lay["kicker_y"]) + _entrance_y(th, p0), ksize, "bold",
1276
+ _ky + _entrance_y(th, p0), ksize, "bold",
1201
1277
  th["accent"], align, p0, trk * 0.5)
1202
1278
 
1203
- y = float(lay["title_y"]) + bdy
1204
1279
  for i, ln in enumerate(lines):
1205
1280
  p = ease(rng(t, 0.12 + 0.13 * i, 0.88 + 0.13 * i))
1206
1281
  _draw_text(img, th, v, ln, ax + _entrance_x(th, p, align),
@@ -1209,7 +1284,7 @@ def _draw_hero(scene, t, dur, tg, script, frame, plan, th):
1209
1284
 
1210
1285
  sub = scene.get("lead") or scene.get("sub") or ""
1211
1286
  # 副标题与大字重复时不要画第二遍:模板片里 big 与 sub 都是"零素材,全代码生成",
1212
- # 照抄会让画面出现两行一样的字(v0.9 实测第一版就是这样)。
1287
+ # 照抄会让画面出现两行一样的字(实测踩到过)。
1213
1288
  if sub and (str(sub).strip() == str(big).strip()
1214
1289
  or (len(str(sub)) >= 6 and str(sub).strip() in str(big).strip())):
1215
1290
  sub = ""
@@ -1386,7 +1461,7 @@ def _draw_list(scene, t, dur, tg, script, frame, plan, th):
1386
1461
  body = max(22, int(th["scale"]["body"][0]) - 1)
1387
1462
  # 行高:优先"每条 96–118px",条目多时往下压,硬下限 56px(再低中文就糊成一片)。
1388
1463
  # 为什么不是固定行高:模板片 5 条要点在 9.3s 的段里,固定 118px 会溢出 856 硬边界,
1389
- # 于是第 5 条被静默截断(v0.9 第一版实测),观众看到"五步"只列出四步。
1464
+ # 于是第 5 条被静默截断(实测踩到过),观众看到"五步"只列出四步。
1390
1465
  avail = 856.0 - top
1391
1466
  row_h = avail / max(1, len(items)) if items else 118.0
1392
1467
  row_h = min(118.0, max(56.0, row_h - 6.0))
@@ -1513,10 +1588,23 @@ def _draw_stat(scene, t, dur, tg, script, frame, plan, th):
1513
1588
  d.rectangle([v.x(bx + i * (bw + gapb)), v.y(760), v.x(bx + i * (bw + gapb) + bw),
1514
1589
  v.y(768)], fill=_rgba(th["accent"] if on else th["rule"],
1515
1590
  int((150 if on else 34))))
1591
+ # 次要统计:**数值 + 单位 + 标签**都要画出来。
1592
+ # 这里必须把数值也画出来:只画 s["label"] 时 stats[] 里的第 2、3 项(例如
1593
+ # "338207 次 / 工具调用"、"22.62 B / Token 重放")在画面上只剩两个光秃秃的词,
1594
+ # 并且行距 30px < 字号,两行直接叠在一起 —— 观众看到的是"工具调用/Token 重放"两行字
1595
+ # 糊在一块、数字完全消失。数字卡的价值就在数字,丢数字等于这段白做。
1596
+ nsize = int(th["scale"]["body"][0] - 6)
1597
+ step = int(nsize * 1.75) # 行距必须大于字号,否则相邻两行会压在一起
1516
1598
  for j, s in enumerate(notes):
1517
1599
  pn = ease(rng(t, 0.6 + 0.15 * j, 1.3 + 0.15 * j))
1518
- txt = str(s.get("label") or "")
1519
- _draw_text(img, th, v, txt, W * 0.5, 800.0 + j * 30, int(th["scale"]["body"][0] - 6),
1600
+ val = str(s.get("value") if s.get("value") is not None else "").strip()
1601
+ unit = str(s.get("unit") or "").strip()
1602
+ lbl = str(s.get("label") or "").strip()
1603
+ head = val + ((" " + unit) if unit else "")
1604
+ txt = (head + " " + lbl) if head else lbl
1605
+ if not txt:
1606
+ continue
1607
+ _draw_text(img, th, v, txt, W * 0.5, 796.0 + j * step, nsize,
1520
1608
  "reg", th["muted"], "center", pn)
1521
1609
 
1522
1610
  img = _apply_veil(img, th, lang, scene, t, s_bg)
@@ -1570,7 +1658,7 @@ def _draw_compare(scene, t, dur, tg, script, frame, plan, th):
1570
1658
 
1571
1659
  # 两栏内容垂直居中(上界 360,下界 800):只有一两条时不会全挤在顶部留一大片空白。
1572
1660
  # 栏头:优先 sides 里作者写的 head;没有就**不要**写"左/右"这种零信息标签
1573
- # (v0.9 第一版实测:观众看到"左 / 右"会以为画面坏了),改画一个 VS 徽标在中缝上方。
1661
+ # (实测:观众看到"左 / 右"会以为画面坏了),改画一个 VS 徽标在中缝上方。
1574
1662
  cols = [("left", 150.0, 700.0, -1), ("right", W - 850.0, 700.0, 1)]
1575
1663
  max_rows = max(len((sides.get("left") or {}).get("items") or []),
1576
1664
  len((sides.get("right") or {}).get("items") or []), 1)
@@ -1717,7 +1805,7 @@ def _draw_quote(scene, t, dur, tg, script, frame, plan, th):
1717
1805
  by = str(by or "")
1718
1806
 
1719
1807
  # 巨型引号:**用字形**,不用矢量线。
1720
- # v0.9 第一版画的是两条短线拼成的「《」,实测看起来像播放器的"快退"图标,
1808
+ # 用两条短线拼「《」实测看起来像播放器的"快退"图标,
1721
1809
  # 观众会以为点错了什么。雅黑粗体的 U+201C 是有字形的(preflight 查过不缺口),
1722
1810
  # 直接放大 150px 就是最标准的引语符号。
1723
1811
  pq = ease(rng(t, 0.0, 0.45))
@@ -1924,7 +2012,7 @@ def _draw_outro(scene, t, dur, tg, script, frame, plan, th):
1924
2012
  gap = _gap(th, lang)
1925
2013
  ph = _phase(scene)
1926
2014
 
1927
- # 光点环:数量随视觉语言母题走(epic 18 / cinema 12 / mono 0),且比 v0.5 更少
2015
+ # 光点环:数量随视觉语言母题走(epic 18 / cinema 12 / mono 0)
1928
2016
  mf = str((lang.get("motif") or {}).get("kind") or "minimal")
1929
2017
  ring_n = {"rays": 18, "letterbox": 12, "brackets": 10, "signal": 8,
1930
2018
  "handdrawn": 6, "slabs": 12, "hairlines": 6, "minimal": 0}.get(mf, 8)
@@ -2003,18 +2091,18 @@ _KIND_TO_TYPE = {
2003
2091
  def draw_frame(scene, t_local, dur, t_global, script, frame):
2004
2092
  """按帧画一张 1920x1080 RGB 图。
2005
2093
 
2006
- 派发顺序(v0.9):
2007
- 1. legacy(film.json 没有 style 字段)-> v0.4 三个老绘制器,逐字节不变;
2008
- 2. 非 legacy -> 按 scene_type(grammar.py 选好、render.py 写进 timeline)派发到
2009
- 10 类构图之一;没有 scene_type 的旧 timeline 按 kind 映射到兼容类型。
2094
+ 只有一条路:按 scene_type(grammar.py 选好、render.py 写进 timeline)
2095
+ 派发到 10 类构图之一;没有 scene_type 的旧 timeline 按 kind 映射到兼容类型。
2096
+
2097
+ 原来的第 1 条分支(legacy —— film.json 不写 style 就走三个老绘制器)**已删除**:
2098
+ stylekit 不再产出 legacy 计划(不写设计会直接报错),那条分支永远走不到。
2099
+ 留着死分支比删掉更危险——它会让"legacy 还能用"的错觉一直存在。
2100
+
2010
2101
  未命中的类型回落到 list("有条目就画条目"是最不会丢信息的兜底),
2011
2102
  并 warn 一次——静默画错构图比报错更糟。
2012
2103
  """
2013
2104
  plan = _plan(script)
2014
2105
  kind = scene.get("kind") or "body"
2015
- if plan.get("legacy"):
2016
- fn = _LEGACY_DRAWERS.get(kind) or _legacy_scene_body
2017
- return fn(scene, t_local, dur, t_global, script, frame)
2018
2106
  th = _scene_theme(plan, scene)
2019
2107
  stype = str(scene.get("scene_type") or "").strip()
2020
2108
  if not stype:
@@ -2028,7 +2116,7 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
2028
2116
  return fn(scene, t_local, dur, t_global, script, frame, plan, th)
2029
2117
 
2030
2118
 
2031
- # ---------------- 提示点(v0.3.0 起可选;v0.7 按 kind 与内容生成) ----------------
2119
+ # ---------------- 提示点(按 kind 与内容生成) ----------------
2032
2120
  def _sha1_int(*parts):
2033
2121
  """把若干文本片段拼起来取 sha1 前 8 位(提示点"内容派生"的稳定来源)。"""
2034
2122
  raw = "|".join(str(p) for p in parts).encode("utf-8")
@@ -2038,8 +2126,8 @@ def _sha1_int(*parts):
2038
2126
  def _item_pitch(text, base=1760.0):
2039
2127
  """条目文本 -> ui_blip 音高(sha1 派生,不用序号)。
2040
2128
 
2041
- v0.7 契约 §3.4:body 条目的 pitch 必须由**条目文本**派生,不再按序号
2042
- (旧实现 1760+110*i 每片都一样)——同一句文案换到别的片子里仍是同一个音高,
2129
+ 契约 §3.4:body 条目的 pitch 必须由**条目文本**派生,不再按序号
2130
+ (1760+110*i 每片都一样)——同一句文案换到别的片子里仍是同一个音高,
2043
2131
  不同文案落到 -5~+7 半音的不同音级;万一两条碰撞,_dedupe_segment 会再升半音。
2044
2132
  """
2045
2133
  off = _sha1_int("pitch", text) % 13 - 5
@@ -2049,7 +2137,7 @@ def _item_pitch(text, base=1760.0):
2049
2137
  def _cue_gain_decay(gain, repeat):
2050
2138
  """同段内同类型 cue 第 repeat 次重复(0 起)时的增益:每次再低 2 dB(-2/-4/-6/…)。
2051
2139
 
2052
- v0.7 契约 §3.4:不许再出现"每段都一模一样的三连击"——同类型重复必须**严格递减**
2140
+ 契约 §3.4:不许出现"每段都一模一样的三连击"——同类型重复必须**严格递减**
2053
2141
  (第 2 次 -2、第 3 次 -4、第 4 次 -6……),且 pitch 必须不同。
2054
2142
  """
2055
2143
  return round(float(gain) - 2.0 * int(repeat), 2)
@@ -2085,11 +2173,11 @@ def cues(tl, script):
2085
2173
  好处是"声音跟着画面的像素走":这里算的是卡片落位、数字滚完的时刻,
2086
2174
  画面一改,声音自动跟着改,不用回来手工对时间。
2087
2175
 
2088
- v0.5:条目落位步长与场景绘制共用 plan["pacing"]["item_stagger"],
2089
- 两边读同一个数,否则音效会错位(这是 v0.4 写死 0.32 留下的坑)。
2176
+ 条目落位步长与场景绘制共用 plan["pacing"]["item_stagger"],
2177
+ 两边读同一个数,否则音效会错位(写死 0.32 时踩过这个坑)。
2090
2178
 
2091
- v0.7(references/audio-variation.md §3.4):提示点改为按**场景 kind 与内容**生成,
2092
- 不再是"每段 ui_blip 三连 + 结尾 answer_chime"的同质化固定套路(跨片音效雷同的
2179
+ 见 references/audio-variation.md §3.3:提示点按**场景 kind 与内容**生成,
2180
+ 不是"每段 ui_blip 三连 + 结尾 answer_chime"的同质化固定套路(跨片音效雷同的
2093
2181
  来源之一):
2094
2182
  hook : 段首 impact(size 按字数 0.9~1.3)+ 落定 riser
2095
2183
  body : 条目落位 ui_blip(pitch 由条目文本 sha1 派生)+ items>=3 补 answer_chime