@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/README.md +287 -304
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/package.json +11 -10
  5. package/schemas/design.schema.json +722 -0
  6. package/schemas/film.schema.json +171 -15
  7. package/schemas/proposal.schema.json +133 -0
  8. package/skills/video-studio/SKILL.md +1316 -1066
  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 +12 -9
  12. package/skills/video-studio/assets/scenes.py +2207 -1190
  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 +2 -2
  20. package/skills/video-studio/references/scene-grammar.md +311 -0
  21. package/skills/video-studio/references/sfx-design.md +1 -1
  22. package/skills/video-studio/references/style-packs.md +107 -168
  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 +104 -33
  28. package/skills/video-studio/scripts/filmkit.py +153 -78
  29. package/skills/video-studio/scripts/grammar.py +619 -0
  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 +505 -142
  37. package/skills/video-studio/scripts/scorekit.py +50 -1748
  38. package/skills/video-studio/scripts/selfcheck.py +851 -115
  39. package/skills/video-studio/scripts/stagewatch.py +21 -5
  40. package/skills/video-studio/scripts/stylekit.py +293 -570
  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 +9 -1
  45. package/tools/estimate.py +14 -8
  46. package/tools/golden_frames.py +56 -11
  47. package/tools/silhouette_qa.py +1263 -0
  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-craft.png +0 -0
  53. package/docs/styles/sheet-night.png +0 -0
  54. package/skills/video-studio/assets/score_demo.json +0 -307
@@ -1,1190 +1,2207 @@
1
- # -*- coding: utf-8 -*-
2
- """scenes.py — 场景绘制模板(v0.5.0:画风随内容变化)。
3
-
4
- 唯一约定:提供 draw_frame(scene, t_local, dur, t_global, script, frame) -> PIL.Image
5
- scene : timeline.json 里的一段(含 id/text/sub/start/end/dur)
6
- t_local : 本段内已经过秒数
7
- dur : 本段总秒数
8
- t_global: 全片秒数
9
- frame : 全片帧号(确定性:同一帧号必须画出同一张图)
10
-
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
- 1. 不许用 random / time.time();要随机用 K.hash2 / K.hash3 / vnoise(seed)
21
- 2. 每帧从零画,不依赖上一帧的变量
22
- 3. 静态背景预渲染一次并缓存(见 _bg()),不要每帧重算渐变 / 纹理 / 暗角
23
- 4. 文字排版一律用本文件的 _wrap / _text_w(带缓存):直接调 wrap_cn 会让单帧慢 2-3 倍
24
- 5. 所有正文落在 K.SAFE 内(y <= 974),字幕由 render.py 统一画在 y >= 872
25
- 6. 每段至少一个动态元素,静止不要超过 6 秒;镜头缓推 3-15%
26
- 7. 缺字形会画成方块:用 K.glyph_missing_chars 自查,别写 ✓ × ↵ 这类符号(改用矢量线条)
27
- 8. 性能:1080p 单帧预算 ~15ms。重活(渐变/暗角/纹理/精灵)全部预烘焙进 _CACHE;
28
- 不许每帧全屏高斯模糊 / 全屏双线性 resize(实测 30ms/帧,直接吃掉预算)
29
- """
30
- from __future__ import annotations
31
-
32
- import hashlib
33
- import math
34
- import os
35
- import sys
36
- import warnings
37
-
38
- from PIL import Image, ImageDraw, ImageFilter
39
- import numpy as np
40
-
41
- # stylekit 的单向依赖:stylekit 不 import 本文件(也不 import filmkit),不会成环。
42
- # 用户会把 scenes.py 与 scripts/*.py 拷到同一目录,那种情况下直接 import 就行;
43
- # 本仓库里是 assets/scenes.py 与 scripts/stylekit.py 两个目录,所以兜底加 ../scripts。
44
- try:
45
- import stylekit as ST
46
- except ImportError:
47
- _SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "scripts")
48
- _SCRIPTS = os.path.normpath(_SCRIPTS)
49
- if _SCRIPTS not in sys.path:
50
- sys.path.insert(0, _SCRIPTS)
51
- import stylekit as ST
52
-
53
- import filmkit as K
54
- # 只 import 模板**当下真的用到**的名字(多余导入会被 pyflakes 记一笔,也容易让人以为哪都能用)。
55
- # 写自己的场景时,缓动/噪声直接走模块:K.lerp / K.ease_in / K.ease_in_out / K.vnoise / K.hash2 …
56
- from filmkit import W, H, clamp, smooth, ease_out, rng
57
-
58
- # ============================================================================
59
- # 零、v0.4 兼容层(legacy 逐字节基线,别动这里的任何一行)
60
- # ============================================================================
61
- #
62
- # legacy = 老项目(film.json 没有 style 字段)。这组函数是 v0.4 的原文,
63
- # 一个字符都不许改:Lead 的基线三帧 sha256 直接对拍这里的输出。
64
- # 新主题化代码只在 _legacy_* 之外生长,互不影响。
65
-
66
- # ---------------- 主题(legacy 专用;新代码走 stylekit.STYLES / THEMES) ----------------
67
- THEME = dict(
68
- bg_top=(7, 14, 26), bg_bot=(17, 33, 54),
69
- ink=(240, 245, 252), muted=(150, 174, 200), dim=(96, 118, 144),
70
- accent=(243, 132, 60), accent2=(86, 170, 232),
71
- good=(108, 178, 110), bad=(226, 96, 92),
72
- )
73
-
74
- _CACHE = {}
75
-
76
-
77
- def _vgrad(w, h, top, bot):
78
- """竖直渐变背景 + 极轻网点(确定性),缓存复用。"""
79
- key = ("bg", w, h, top, bot)
80
- if key in _CACHE:
81
- return _CACHE[key]
82
- a = np.linspace(0, 1, h, dtype=np.float32)[:, None]
83
- arr = np.array(top, dtype=np.float32)[None, :] * (1 - a[..., None]) \
84
- + np.array(bot, dtype=np.float32)[None, :] * a[..., None]
85
- arr = np.repeat(arr, w, axis=1)
86
- yy, xx = np.mgrid[0:h, 0:w]
87
- arr = np.clip(arr + (((np.sin(xx * 0.31) * np.sin(yy * 0.31)) > 0.955)
88
- .astype(np.float32) * 5.0)[..., None], 0, 255)
89
- im = Image.fromarray(arr.astype(np.uint8), "RGB")
90
- _CACHE[key] = im
91
- return im
92
-
93
-
94
- def _vignette_mask(w, h, strength=0.40):
95
- key = ("vig", w, h, strength)
96
- if key in _CACHE:
97
- return _CACHE[key]
98
- yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
99
- r = np.sqrt(((xx - w / 2.0) / (w / 2.0)) ** 2 + ((yy - h / 2.0) / (h / 2.0)) ** 2) / 1.4142
100
- m = np.clip((r - 0.40) / 0.60, 0, 1) ** 1.6 * strength
101
- mask = Image.fromarray((m * 255).astype(np.uint8), "L")
102
- _CACHE[key] = mask
103
- return mask
104
-
105
-
106
- def _soft_dot(img, x, y, r, color, alpha=1.0):
107
- """柔边圆点精灵(预渲染一次,之后只 paste)。"""
108
- if r < 0.6 or alpha <= 0.004:
109
- return img
110
- key = ("dot", int(round(r * 2)), color)
111
- if key not in _CACHE:
112
- s = int(r * 2 + 6)
113
- im = Image.new("RGBA", (s, s), (0, 0, 0, 0))
114
- ImageDraw.Draw(im).ellipse([3, 3, s - 3, s - 3], fill=color + (255,))
115
- _CACHE[key] = im.filter(ImageFilter.GaussianBlur(max(1.0, r * 0.22)))
116
- spr = _CACHE[key]
117
- if alpha < 0.985:
118
- spr = spr.copy()
119
- spr.putalpha(spr.split()[3].point(lambda v: int(v * alpha)))
120
- img.paste(spr, (int(x - spr.width / 2), int(y - spr.height / 2)), spr)
121
- return img
122
-
123
-
124
- def _legacy_bg():
125
- """预烘焙背景:渐变 + 暗角一次算好,每帧只 copy(实测省 ~4.5ms/帧)。"""
126
- if "baked" not in _CACHE:
127
- base = _vgrad(W, H, THEME["bg_top"], THEME["bg_bot"])
128
- _CACHE["baked"] = Image.composite(Image.new("RGB", (W, H), (0, 0, 0)),
129
- base, _vignette_mask(W, H, 0.40))
130
- return _CACHE["baked"]
131
-
132
-
133
- def _legacy_hud(img, chapter, progress, title):
134
- d = ImageDraw.Draw(img, "RGBA")
135
- d.rectangle([0, 0, int(W * clamp(progress)), 3], fill=THEME["accent"])
136
- d.rectangle([0, 0, W, 3], fill=(255, 255, 255, 11))
137
- f = K.font(27, "reg")
138
- tw = K.text_w_cached(chapter, 27, "reg")
139
- d.rounded_rectangle([64, 42, 64 + tw + 46, 96], radius=27,
140
- fill=(4, 10, 20, 150), outline=(255, 255, 255, 40))
141
- d.ellipse([64 + 16, 62, 64 + 26, 72], fill=THEME["accent"])
142
- d.text((64 + 34, 56), chapter, font=f, fill=(228, 236, 246))
143
- f2 = K.font(23, "reg")
144
- tw2 = K.text_w_cached(title, 23, "reg")
145
- d.text((W - 64 - tw2, 58), title, font=f2, fill=(255, 255, 255, 96))
146
- return img
147
-
148
-
149
- def _legacy_cover(img, t, dur, kicker, big, sub, progress, title):
150
- d = ImageDraw.Draw(img, "RGBA")
151
- p = ease_out(rng(t, 0.0, 0.9))
152
- p2 = ease_out(rng(t, 0.25, 1.3))
153
- d.text((150, 300 - 26 * (1 - p)), kicker, font=K.font(30, "bold"),
154
- fill=THEME["accent"] + (255,))
155
- y = 352
156
- for ln in K.wrap_cn_cached(big, 84, 1560, "bold"):
157
- d.text((150, y), ln, font=K.font(84, "bold"), fill=THEME["ink"] + (255,))
158
- y += 104
159
- if sub:
160
- for j, ln in enumerate(K.wrap_cn_cached(sub, 38, 1400, "light")):
161
- d.text((150, y + 26 + j * 56), ln, font=K.font(38, "light"),
162
- fill=THEME["muted"] + (int(242 * p2),))
163
- return _legacy_hud(img, "01 · 开场", progress, title)
164
-
165
-
166
- def _legacy_scene_hook(scene, t, dur, tg, script, frame):
167
- img = _legacy_bg().copy()
168
- d = ImageDraw.Draw(img, "RGBA")
169
- cx, cy = W // 2, H // 2 - 40
170
- p = smooth(rng(t, 0.15, 2.6))
171
- for i in range(18):
172
- a = (i / 18.0) * 2 * math.pi + 0.2
173
- rr = 60 + 460 * p * (0.55 + 0.45 * K.hash2(i, 11))
174
- x2, y2 = cx + math.cos(a) * rr, cy + math.sin(a) * rr * 0.62
175
- d.line([cx, cy, x2, y2], fill=(255, 255, 255, int(70 * p)), width=1)
176
- if K.hash2(i, 9) > 0.45:
177
- img = _soft_dot(img, x2, y2, 3 + 5 * K.hash2(i, 5), THEME["accent2"], 0.55 * p)
178
- img = _soft_dot(img, cx, cy, 26 + 6 * math.sin(t * 2.2), THEME["accent"], 0.9 * rng(t, 0.1, 0.7))
179
- return _legacy_cover(img, t, dur, "00 · 开场", script.get("title", ""),
180
- scene.get("lead") or "零素材,全代码生成", tg / max(0.1, script.get("_total", 1.0)),
181
- script.get("title", ""))
182
-
183
-
184
- def _legacy_scene_body(scene, t, dur, tg, script, frame):
185
- img = _legacy_bg().copy()
186
- d = ImageDraw.Draw(img, "RGBA")
187
- p = ease_out(rng(t, 0.0, 0.8))
188
- d.text((150, 250 + int(14 * (1 - p))), scene.get("kicker", ""),
189
- font=K.font(28, "bold"), fill=THEME["accent"] + (int(255 * p),))
190
- for i, ln in enumerate(K.wrap_cn_cached(scene.get("big") or scene["text"][:18], 64, 820, "bold")):
191
- d.text((150, 300 + i * 78), ln, font=K.font(64, "bold"), fill=THEME["ink"])
192
- if scene.get("sub"):
193
- for i, ln in enumerate(K.wrap_cn_cached(scene["sub"], 34, 820)):
194
- d.text((150, 420 + i * 48), ln, font=K.font(34, "reg"), fill=THEME["muted"])
195
-
196
- for i, it in enumerate(scene.get("items") or []):
197
- p_i = ease_out(rng(t, 0.45 + 0.32 * i, 1.15 + 0.32 * i))
198
- if p_i <= 0:
199
- continue
200
- x, y = 1030 + int(46 * (1 - p_i)), 240 + i * 118
201
- d.rounded_rectangle([x, y, x + 740, y + 96], radius=14,
202
- fill=(255, 255, 255, int(16 * p_i)),
203
- outline=(255, 255, 255, int(28 * p_i)))
204
- col = THEME["good"] if it.get("ok", True) else THEME["bad"]
205
- img = _soft_dot(img, x + 34, y + 48, 11, col, p_i)
206
- d = ImageDraw.Draw(img, "RGBA")
207
- d.text((x + 60, y + 28), it["text"], font=K.font(32, "reg"),
208
- fill=THEME["ink"] + (int(255 * p_i),))
209
- return _legacy_hud(img, scene.get("chapter") or "02 · 正文",
210
- tg / max(0.1, script.get("_total", 1.0)), script.get("title", ""))
211
-
212
-
213
- def _legacy_scene_outro(scene, t, dur, tg, script, frame):
214
- img = _legacy_bg().copy()
215
- d = ImageDraw.Draw(img, "RGBA")
216
- p = ease_out(rng(t, 0.0, 1.0))
217
- cx, cy = W // 2, H // 2 - 30
218
- for i in range(26):
219
- a = (i / 26.0) * 2 * math.pi + t * 0.15
220
- rr = 500 * (1 - p) + 40 + 18 * K.hash2(i, 4)
221
- img = _soft_dot(img, cx + math.cos(a) * rr, cy + math.sin(a) * rr * 0.5,
222
- 3, THEME["accent2"], 0.5 * (1 - p * 0.7))
223
- d = ImageDraw.Draw(img, "RGBA")
224
- lines = K.wrap_cn_cached(scene.get("big") or script.get("end_text") or "完", 96, 1500, "bold")
225
- alpha = int(255 * ease_out(rng(t, 0.3, 1.5)))
226
- for i, ln in enumerate(lines):
227
- d.text(((W - K.text_w_cached(ln, 96, "bold")) / 2, 380 + i * 118), ln,
228
- font=K.font(96, "bold"), fill=THEME["ink"] + (alpha,))
229
- return _legacy_hud(img, "99 · 收尾", 1.0, script.get("title", ""))
230
-
231
-
232
- _LEGACY_DRAWERS = {
233
- "hook": _legacy_scene_hook,
234
- "body": _legacy_scene_body,
235
- "outro": _legacy_scene_outro,
236
- }
237
-
238
-
239
- # ============================================================================
240
- # 一、风格计划 / 主题(v0.5)
241
- # ============================================================================
242
-
243
- _PLAN_CACHE = {}
244
- _THEME_WARNED = set()
245
- _DEFAULT_PUSH = 0.04
246
-
247
-
248
- def _warn_once(key, msg):
249
- if key in _THEME_WARNED:
250
- return
251
- _THEME_WARNED.add(key)
252
- warnings.warn(msg, RuntimeWarning, stacklevel=3)
253
-
254
-
255
- def _plan(script):
256
- """取整片的风格计划:render.py 会把解析好的计划放进 script["_style"]。
257
-
258
- 没有就自己解一次(纯函数,同参数同结果)。每帧都会调用,所以只留一条缓存;
259
- 键里带 id(script) 之外的稳定字段,避免脚本对象被回收后 id 复用串档。
260
- """
261
- p = script.get("_style")
262
- if isinstance(p, dict):
263
- return p
264
- key = (id(script), script.get("title"), len(script.get("lines") or []),
265
- script.get("style"), bool(script.get("pacing")))
266
- got = _PLAN_CACHE.get(key)
267
- if got is None:
268
- got = ST.resolve_script_style(script)
269
- _PLAN_CACHE.clear()
270
- _PLAN_CACHE[key] = got
271
- return got
272
-
273
-
274
- def _scene_theme(plan, scene):
275
- """本段用哪套主题:全片主题 + 可选的 scene["theme"] 覆盖(只许同明暗族)。"""
276
- base = plan.get("theme")
277
- if isinstance(base, dict):
278
- th = dict(base)
279
- else:
280
- th = dict(ST.THEMES[plan["theme_id"]])
281
- sid = scene.get("theme")
282
- if not sid or str(sid) == str(th.get("id")):
283
- return th
284
- alt = ST.THEMES.get(str(sid))
285
- if alt is None:
286
- _warn_once(("unknown", str(sid)),
287
- "场景 %r 指定了未知主题 %r,已忽略(可用:%s)"
288
- % (scene.get("id"), sid, " / ".join(ST.STYLE_IDS)))
289
- return th
290
- if not ST.family_compatible(th, alt):
291
- # 跨明暗族 = 两部片,不是两段:段界会整屏换底,QA 的闪烁门禁必炸。
292
- _warn_once(("family", str(th.get("id")), str(sid)),
293
- "场景 %r 想从主题 %s 换到 %s,两者明暗族不同(同片换族会触发闪烁门禁),已忽略"
294
- % (scene.get("id"), th.get("id"), sid))
295
- return th
296
- return dict(alt)
297
-
298
-
299
- # ---------------- 包内小工具 ----------------
300
-
301
- def _mix(a, b, p):
302
- """两色线性插值(p=0 -> a,p=1 -> b)。"""
303
- q = clamp(p)
304
- return (int(round(a[0] + (b[0] - a[0]) * q)),
305
- int(round(a[1] + (b[1] - a[1]) * q)),
306
- int(round(a[2] + (b[2] - a[2]) * q)))
307
-
308
-
309
- def _rgba(c, alpha=255):
310
- """RGB + 0..255 透明度 -> 4 元组(PIL 的 RGBA 画笔要它)。"""
311
- return (int(c[0]), int(c[1]), int(c[2]),
312
- int(max(0, min(255, round(alpha)))))
313
-
314
-
315
- def _ease(name):
316
- """风格包里的缓动名 -> 函数(四个都满足 f(0)=0 / f(1)=1)。"""
317
- got = _EASES.get(name)
318
- if got is not None:
319
- return got
320
- if name == "out5":
321
- got = lambda p: 1.0 - (1.0 - clamp(p)) ** 5
322
- elif name == "inOut3":
323
- got = K.ease_in_out
324
- elif name == "outBack":
325
- got = K.ease_out_back
326
- else:
327
- got = K.ease_out
328
- _EASES[name] = got
329
- return got
330
-
331
-
332
- _EASES = {}
333
-
334
-
335
- # ---------------- 字体:family 是 v0.5 新加的参数(旧 filmkit 没有,自动回退) ----------------
336
-
337
- _FONT_CACHE = {}
338
- _FAMILY_OK = None
339
-
340
-
341
- def _family_ok():
342
- """filmkit.font 是否支持 family=...(Lead 在 v0.5 加;老版本没有就回退旧签名)。"""
343
- global _FAMILY_OK
344
- if _FAMILY_OK is None:
345
- try:
346
- K.font(16, "reg", "sans")
347
- _FAMILY_OK = True
348
- except TypeError:
349
- _FAMILY_OK = False
350
- except Exception: # noqa: BLE001 - 字体族实现有 bug 时退回旧签名
351
- _FAMILY_OK = False
352
- return _FAMILY_OK
353
-
354
-
355
- def _font(th, size, weight="reg"):
356
- size = max(6, int(size))
357
- fam = str(th.get("family") or "sans")
358
- key = (size, weight, fam)
359
- f = _FONT_CACHE.get(key)
360
- if f is None:
361
- if _family_ok():
362
- try:
363
- f = K.font(size, weight, fam)
364
- except Exception: # noqa: BLE001 - 某族字体缺失时退回到默认字体
365
- f = K.font(size, weight)
366
- else:
367
- f = K.font(size, weight)
368
- _FONT_CACHE[key] = f
369
- return f
370
-
371
-
372
- _TW_CACHE = {}
373
- _WRAP_CACHE = {}
374
-
375
-
376
- def _text_w(text, size, weight, th):
377
- """带缓存的行宽(按主题字体族;老 filmkit 回退到 K.text_w_cached)。"""
378
- if not text:
379
- return 0.0
380
- size = max(6, int(size))
381
- fam = str(th.get("family") or "sans")
382
- key = (text, size, weight, fam)
383
- got = _TW_CACHE.get(key)
384
- if got is None:
385
- if len(_TW_CACHE) > 40000:
386
- _TW_CACHE.clear()
387
- if _family_ok():
388
- got = K.text_w(text, _font(th, size, weight))
389
- else:
390
- got = K.text_w_cached(text, size, weight)
391
- _TW_CACHE[key] = got
392
- return got
393
-
394
-
395
- def _wrap(text, size, max_w, weight, th):
396
- """带缓存的换行(同上)。"""
397
- if not text:
398
- return []
399
- size = max(6, int(size))
400
- max_w = max(80, int(max_w))
401
- fam = str(th.get("family") or "sans")
402
- key = (text, size, max_w, weight, fam)
403
- got = _WRAP_CACHE.get(key)
404
- if got is None:
405
- if len(_WRAP_CACHE) > 20000:
406
- _WRAP_CACHE.clear()
407
- if _family_ok():
408
- got = K.wrap_cn(text, _font(th, size, weight), max_w)
409
- else:
410
- got = K.wrap_cn_cached(text, size, max_w, weight)
411
- _WRAP_CACHE[key] = got
412
- return got
413
-
414
-
415
- def _fit_size(text, max_w, size, weight, th, floor=16):
416
- """按卡片可用宽度缩字号(条目文字长短不一,不能撑破卡片)。"""
417
- s = int(size)
418
- while s > floor and _text_w(text, s, weight, th) > max_w:
419
- s -= 2
420
- return s
421
-
422
-
423
- # ---------------- 镜头缓推 + 前景视差 ----------------
424
-
425
- class _View(object):
426
- """设计坐标 -> 帧坐标。
427
-
428
- 为什么不是整帧重采样:实测 1080p 双线性整帧变换 29ms/帧(预算 15ms),
429
- 而整帧按 push 放大又会把 150px 边距的内容推出 SAFE。所以分两层:
430
- 背景层做**真缩放**(EXTENT + NEAREST,1.7~3.5ms,见 _bg_plate);
431
- 前景层做同向**视差**(0.35x,位置与卡片尺寸随镜头收放,字形不重采样保清晰)。
432
- """
433
- __slots__ = ("s", "cx", "cy")
434
-
435
- def __init__(self, s):
436
- self.s = float(s)
437
- self.cx = W * 0.5
438
- self.cy = H * 0.5
439
-
440
- def x(self, x):
441
- return int(round(self.cx + (float(x) - self.cx) * self.s))
442
-
443
- def y(self, y):
444
- return int(round(self.cy + (float(y) - self.cy) * self.s))
445
-
446
- def r(self, r):
447
- return max(0.0, float(r) * self.s)
448
-
449
-
450
- def _push_scale(th, t_local, dur):
451
- """镜头缓推:t=0 -> 1.0,t=dur -> 1+push,单调平滑(端点必须收敛,不许抖)。"""
452
- u = clamp(t_local / max(0.05, float(dur)))
453
- return 1.0 + float(th.get("push") or _DEFAULT_PUSH) * smooth(u)
454
-
455
-
456
- def _view(th, s_bg):
457
- return _View(1.0 + (s_bg - 1.0) * 0.35)
458
-
459
-
460
- # ---------------- 背景:按主题烘焙一次(渐变 + 材质 + 颗粒 + 暗角) ----------------
461
-
462
- def _grid_xy():
463
- yy = np.arange(H, dtype=np.float32)[:, None]
464
- xx = np.arange(W, dtype=np.float32)[None, :]
465
- return yy, xx
466
-
467
-
468
- def _paper_noise(th):
469
- """纸纹:纤维条纹(vnoise)+ 多尺度斑块(hash2 小图上采样)。一次烘焙,不进逐帧路径。"""
470
- freq = float(th.get("grain_freq") or 0.27)
471
- xs = np.arange(W, dtype=np.float32) * freq * 0.35
472
- fiber = np.array([K.vnoise(float(v), 3) for v in xs], dtype=np.float32) - 0.5
473
- fine = np.array([K.vnoise(float(v) * 2.3, 9) for v in xs], dtype=np.float32) - 0.5
474
- rows, cols = 27, 48
475
- blobs = np.array([[K.hash2(r * 31 + 7, c * 17 + 3) for c in range(cols)]
476
- for r in range(rows)], dtype=np.float32) - 0.5
477
- blob_img = Image.fromarray(((blobs + 0.5) * 255.0).astype(np.uint8), "L")
478
- blobs = np.asarray(blob_img.resize((W, H), Image.BICUBIC), dtype=np.float32) / 255.0 - 0.5
479
- return blobs * 0.55 + fiber[None, :] * 0.30 + fine[None, :] * 0.15
480
-
481
-
482
- def _texture_layer(th, yy, xx):
483
- """背景材质层(灰度偏移;深色主题提亮、浅色主题压暗)。"""
484
- kind = str(th.get("texture") or "none")
485
- alpha = float(th.get("texture_alpha") or 0.0)
486
- if kind == "none" or alpha <= 0.0:
487
- return None
488
- sign = 1.0 if ST.is_dark(th) else -1.0
489
- amp = sign * 255.0 * alpha * 0.44
490
- if kind == "dots":
491
- f = float(th.get("grain_freq") or 0.31)
492
- layer = ((np.sin(xx * f) * np.sin(yy * f)) > 0.955).astype(np.float32)
493
- elif kind == "grid":
494
- step = 88.0
495
- layer = (((xx % step) < 1.0) | ((yy % step) < 1.0)).astype(np.float32)
496
- elif kind == "scanlines":
497
- layer = ((yy % 4.0) < 1.0).astype(np.float32)
498
- elif kind == "paper":
499
- layer = _paper_noise(th)
500
- else:
501
- return None
502
- return layer * amp
503
-
504
-
505
- def _grain_layer(th, yy, xx):
506
- """确定性颗粒(坐标 hash,等价 vnoise 的 2D 版本;不用 random)。"""
507
- g = float(th.get("grain") or 0.0)
508
- if g <= 0.05:
509
- return None
510
- xi = np.arange(W, dtype=np.int64)[None, :]
511
- yi = np.arange(H, dtype=np.int64)[:, None]
512
- v = (xi * 374761393 + yi * 668265263) & 0xFFFFFFFF
513
- v = (v ^ (v >> 13)) & 0xFFFFFFFF
514
- v = (v * 1274126177) & 0xFFFFFFFF
515
- v = (v ^ (v >> 16)) & 0xFFFFFFFF
516
- noise = (v & 0xFFFF).astype(np.float32) / 65535.0 - 0.5
517
- flat = np.empty((H, W), dtype=np.float32)
518
- np.add(noise * (g * 1.2), 0.0, out=flat)
519
- return flat
520
-
521
-
522
- def _vignette_layer(th, yy, xx):
523
- """暗角:浅色主题压向 vignette_tint(米灰),绝不压向纯黑。"""
524
- strength = float(th.get("vignette") or 0.0)
525
- if strength <= 0.0:
526
- return None
527
- r = np.sqrt(((xx - W * 0.5) / (W * 0.5)) ** 2 + ((yy - H * 0.5) / (H * 0.5)) ** 2) / 1.4142
528
- m = np.clip((r - 0.40) / 0.60, 0.0, 1.0) ** 1.6 * strength
529
- return m
530
-
531
-
532
- def _hashable(v):
533
- """把 list 递归转成 tuple:主题从 JSON 读回来时元组会变成 list,而 list 不能当 dict 键。
534
-
535
- 为什么需要它:--jobs >1 时 render.py 的 worker 从剧本快照读 script,主题色的
536
- (r,g,b) 元组变成 [r,g,b],于是下面 _bg 的缓存键抛 unhashable type: "list"。
537
- render.py 已按剧本重解风格,但这层防御留着——scenes.py 是用户会拷走改的模板。
538
- """
539
- if isinstance(v, (list, tuple)):
540
- return tuple(_hashable(x) for x in v)
541
- if isinstance(v, dict):
542
- return tuple(sorted((k, _hashable(x)) for k, x in v.items()))
543
- return v
544
-
545
-
546
- def _bg(th):
547
- """预烘焙背景:主题色渐变 + 材质 + 颗粒 + 暗角,一次算好,逐帧只 copy / 变换。"""
548
- key = _hashable(("baked", th.get("id"), th["bg"], th["bg2"], th["texture"], th["grain"],
549
- th["vignette"], th["vignette_tint"], th["texture_alpha"],
550
- th["grain_freq"]))
551
- got = _CACHE.get(key)
552
- if got is not None:
553
- return got
554
- yy, xx = _grid_xy()
555
- a = np.linspace(0.0, 1.0, H, dtype=np.float32)[:, None, None]
556
- top = np.array(th["bg"], dtype=np.float32)[None, None, :]
557
- bot = np.array(th["bg2"], dtype=np.float32)[None, None, :]
558
- arr = np.repeat(top * (1.0 - a) + bot * a, W, axis=1)
559
- tex = _texture_layer(th, yy, xx)
560
- if tex is not None:
561
- arr = arr + tex[:, :, None]
562
- grain = _grain_layer(th, yy, xx)
563
- if grain is not None:
564
- arr = arr + grain[:, :, None]
565
- np.clip(arr, 0.0, 255.0, out=arr)
566
- vig = _vignette_layer(th, yy, xx)
567
- if vig is not None:
568
- tint = np.array(th["vignette_tint"], dtype=np.float32)[None, None, :]
569
- arr = arr * (1.0 - vig)[:, :, None] + tint * vig[:, :, None]
570
- np.clip(arr, 0.0, 255.0, out=arr)
571
- im = Image.fromarray(arr.astype(np.uint8), "RGB")
572
- _CACHE[key] = im
573
- return im
574
-
575
-
576
- def _bg_plate(th, s):
577
- """逐帧背景:按镜头缩放取景(NEAREST 重采样,实测 1.7~3.5ms/帧)。"""
578
- base = _bg(th)
579
- 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)
585
-
586
-
587
- # ---------------- 版式几何(全部从 ST.LAYOUTS 取) ----------------
588
-
589
- def _title_anchor(lay):
590
- """标题锚点 + 对齐方式:left-stack 左对齐 / split-right 右对齐 / center-stack 居中。"""
591
- if lay.get("center"):
592
- return W * 0.5, "center"
593
- if lay.get("title_x") is None:
594
- return float(W - 150), "right"
595
- return float(lay["title_x"]), "left"
596
-
597
-
598
- def _panel_x(lay):
599
- if lay.get("panel_x") is None:
600
- return (W - float(lay["panel_w"])) * 0.5
601
- return float(lay["panel_x"])
602
-
603
-
604
- def _panel_boxes(lay, th, n, y_floor, v):
605
- """条目卡片布局:选列数 + 压缩卡片高度,保证①不压标题②不越字幕带(856)。
606
-
607
- panel_gap 在 stylekit 里是**行距(pitch)**不是间隙:v0.4 用 240 + i*118 排卡片,
608
- 所以 pitch 至少得比卡片高度多 6px,否则卡片会叠在一起。条目多时先加列,再压高度,
609
- 最后才截断——静默重叠或画到字幕带下面比少画两条更糟。
610
- """
611
- if n <= 0:
612
- return []
613
- px = _panel_x(lay)
614
- pw = float(lay["panel_w"])
615
- base_pitch = max(float(lay["panel_gap"]), float(lay["panel_h"]) + 6.0)
616
- ch_base = float(lay["panel_h"])
617
- y0 = max(float(lay["panel_y"]), float(y_floor))
618
- ylim = v.cy + (856.0 - v.cy) / max(0.2, v.s)
619
- avail = max(80.0, ylim - y0)
620
- if lay.get("center"):
621
- cand = (1, 2) if n <= 2 else ((2, 3) if n <= 6 else (3, 2))
622
- else:
623
- cand = (1, 2) if n <= 5 else ((2, 1) if n <= 10 else (2,))
624
- chosen = None
625
- for cols in cand:
626
- rows = int(math.ceil(n / float(cols)))
627
- for ch in (ch_base, ch_base * 0.82, 54.0):
628
- pitch = min(base_pitch, (avail - ch) / max(1, rows - 1)) if rows > 1 else base_pitch
629
- if pitch < ch + 6.0:
630
- continue
631
- if (rows - 1) * pitch + ch <= avail + 0.5:
632
- chosen = (cols, rows, ch, pitch)
633
- break
634
- if chosen:
635
- break
636
- if chosen is None:
637
- cols = cand[-1]
638
- ch, pitch = 54.0, 60.0
639
- rows = int(max(1, (avail + 6.0) // pitch))
640
- chosen = (cols, rows, ch, pitch)
641
- cols, rows, ch, pitch = chosen
642
- cap = rows * cols
643
- if n > cap:
644
- _warn_once(("panel", lay.get("layout"), n),
645
- "本段有 %d 个条目,版式只放得下 %d 个(其余已省略);"
646
- "请拆成多段或减少 items" % (n, cap))
647
- gx = 40.0 if cols > 1 else 0.0
648
- cw = (pw - gx * (cols - 1)) / cols
649
- boxes = []
650
- for i in range(min(n, cap)):
651
- r, c = divmod(i, cols)
652
- boxes.append((px + c * (cw + gx), y0 + r * pitch, cw, ch))
653
- return boxes
654
-
655
-
656
- # ---------------- 文字绘制(位置过视差,字号不重采样) ----------------
657
-
658
- def _draw_text(img, th, v, text, x, y, size, weight, color, align="left",
659
- alpha=1.0, tracking=0.0):
660
- text = "" if text is None else str(text)
661
- if not text or alpha <= 0.004:
662
- return
663
- size = int(size)
664
- px = v.x(x)
665
- py = v.y(y)
666
- font = _font(th, size, weight)
667
- fill = _rgba(color, 255.0 * clamp(alpha))
668
- d = ImageDraw.Draw(img, "RGBA")
669
- tr = float(tracking or 0.0)
670
- if tr > 0.3 and len(text) > 1:
671
- total = _text_w(text, size, weight, th) + tr * (len(text) - 1)
672
- if align == "center":
673
- px -= int(total * 0.5)
674
- elif align == "right":
675
- px -= int(total)
676
- for ch in text:
677
- d.text((px, py), ch, font=font, fill=fill)
678
- px += _text_w(ch, size, weight, th) + tr
679
- return
680
- w = _text_w(text, size, weight, th)
681
- if align == "center":
682
- px -= int(w * 0.5)
683
- elif align == "right":
684
- px -= int(w)
685
- d.text((px, py), text, font=font, fill=fill)
686
-
687
-
688
- def _entrance_x(th, p, align):
689
- dx = float(th.get("entrance_dx") or 0)
690
- if align == "right":
691
- return -dx * (1.0 - p)
692
- if align == "center":
693
- return 0.0
694
- return dx * (1.0 - p)
695
-
696
-
697
- def _entrance_y(th, p):
698
- return float(th.get("entrance_dy") or 0) * (1.0 - p)
699
-
700
-
701
- # ============================================================================
702
- # 二、HUD(三档:chip-left / corner-line / none)
703
- # ============================================================================
704
-
705
- def _hud(img, th, chapter, progress, title):
706
- style = str(th.get("hud") or "chip-left")
707
- if style == "none":
708
- return img
709
- accent = th["accent"]
710
- rule = th["rule"]
711
- ink = th["ink"]
712
- prog = clamp(progress)
713
- d = ImageDraw.Draw(img, "RGBA")
714
- if style == "chip-left":
715
- d.rectangle([0, 0, W, 3], fill=_rgba(rule, 14))
716
- d.rectangle([0, 0, int(W * prog), 3], fill=_rgba(accent, 255))
717
- cs = int(th["scale"]["chip"][0])
718
- tw = _text_w(chapter, cs, "reg", th)
719
- pad_l, pad_r = 16, 18
720
- h = int(cs * 1.95)
721
- x0, y0 = 64, 42
722
- x1 = x0 + pad_l + tw + pad_r
723
- fillc = _mix(th["surface"], th["bg"], 0.55)
724
- d.rounded_rectangle([x0, y0, x1, y0 + h], radius=h // 2,
725
- fill=_rgba(fillc, 168), outline=_rgba(rule, 36))
726
- d.ellipse([x0 + 16, y0 + h // 2 - 5, x0 + 26, y0 + h // 2 + 5],
727
- fill=_rgba(accent, 255))
728
- d.text((x0 + 34, y0 + (h - cs) // 2 - 3), chapter,
729
- font=_font(th, cs, "reg"), fill=_rgba(ink, 228))
730
- bs = int(th["scale"]["brand"][0])
731
- tw2 = _text_w(title, bs, "reg", th)
732
- d.text((W - 64 - tw2, 56), title, font=_font(th, bs, "reg"),
733
- fill=_rgba(_mix(ink, th["bg"], 0.62), 220))
734
- return img
735
- # corner-line:只有角标与细线,没有胶囊底
736
- d.rectangle([0, 0, W, 3], fill=_rgba(rule, 10))
737
- d.rectangle([0, 0, int(W * prog), 2], fill=_rgba(accent, 214))
738
- d.line([64, 44, 64, 96], fill=_rgba(accent, 190), width=2)
739
- d.line([64, 44, 116, 44], fill=_rgba(accent, 190), width=2)
740
- cs = int(th["scale"]["chip"][0])
741
- d.text((84, 54), chapter, font=_font(th, cs, "reg"), fill=_rgba(ink, 220))
742
- bs = int(th["scale"]["brand"][0])
743
- tw = _text_w(title, bs, "reg", th)
744
- d.text((W - 64 - tw, 56), title, font=_font(th, bs, "reg"), fill=_rgba(ink, 120))
745
- d.line([W - 64, 44, W - 64 - 96, 44], fill=_rgba(accent, 120), width=2)
746
- d.line([W - 64, 44, W - 64, 96], fill=_rgba(accent, 120), width=2)
747
- return img
748
-
749
-
750
- # ============================================================================
751
- # 三、三段场景(hook / body / outro)
752
- # ============================================================================
753
-
754
- def _progress(tg, script):
755
- return clamp(float(tg) / max(0.1, float(script.get("_total") or 1.0)))
756
-
757
-
758
- def _deco_rings(img, th, v, t, cx, cy, strength):
759
- """开场放射线 + 光点(位置过视差;颜色/透明度按主题)。"""
760
- d = ImageDraw.Draw(img, "RGBA")
761
- rule = th["rule"]
762
- p = smooth(rng(t, 0.15, 2.6))
763
- opacity = clamp(0.30 if not ST.is_dark(th) else 0.27) * strength
764
- for i in range(18):
765
- a = (i / 18.0) * 2 * math.pi + 0.2
766
- rr = 60 + 420 * p * (0.55 + 0.45 * K.hash2(i, 11))
767
- x2 = cx + math.cos(a) * rr
768
- y2 = cy + math.sin(a) * rr * 0.62
769
- d.line([v.x(cx), v.y(cy), v.x(x2), v.y(y2)],
770
- fill=_rgba(rule, 255 * opacity * p), width=1)
771
- if K.hash2(i, 9) > 0.45:
772
- img = _soft_dot(img, v.x(x2), v.y(y2), 3 + 5 * K.hash2(i, 5),
773
- th["accent2"], 0.50 * p * strength)
774
- img = _soft_dot(img, v.x(cx), v.y(cy), 24 + 5 * math.sin(t * 2.2),
775
- th["accent"], 0.85 * rng(t, 0.1, 0.7) * strength)
776
- return img
777
-
778
-
779
- def _draw_hook(scene, t, dur, tg, script, frame, plan, th):
780
- s_bg = _push_scale(th, t, dur)
781
- v = _view(th, s_bg)
782
- img = _bg_plate(th, s_bg)
783
- lay = ST.LAYOUTS[th["layout"]]
784
- ax, align = _title_anchor(lay)
785
- if lay.get("center"):
786
- cx, cy, strength = W * 0.5, 430.0, 0.55
787
- elif align == "right":
788
- cx, cy, strength = 620.0, 470.0, 1.0
789
- else:
790
- cx, cy, strength = 1300.0, 470.0, 1.0
791
- img = _deco_rings(img, th, v, t, cx, cy, strength)
792
-
793
- ease = _ease(th.get("ease") or "out3")
794
- tracking = float(th.get("tracking") or 0.0)
795
- big = scene.get("big") or script.get("title") or ""
796
- size = int(th["scale"]["display"][0])
797
- # 封面标题用整行宽度:版式里的 title_w 是正文栏宽(820),给 96px 大字会把标题折出孤字。
798
- max_w = min(1560.0, float(W - 300))
799
- lines = _wrap(big, size, max_w, "bold", th)
800
- while len(lines) > 3 and size > 42:
801
- size = int(size * 0.88)
802
- lines = _wrap(big, size, max_w, "bold", th)
803
- lh = int(size * float(th.get("line_gap") or 1.3))
804
- ksize = int(th["scale"]["kicker"][0])
805
- kicker = scene.get("kicker") or "00 · 开场"
806
-
807
- p0 = ease(rng(t, 0.05, 0.85))
808
- _draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
809
- lay["kicker_y"] + _entrance_y(th, p0), ksize, "bold",
810
- th["accent"], align, p0, tracking * 0.5)
811
-
812
- y = float(lay["title_y"])
813
- for i, ln in enumerate(lines):
814
- p = ease(rng(t, 0.12 + 0.13 * i, 0.88 + 0.13 * i))
815
- _draw_text(img, th, v, ln, ax + _entrance_x(th, p, align),
816
- y + i * lh + _entrance_y(th, p), size, "bold",
817
- th["ink"], align, p, tracking)
818
-
819
- sub = scene.get("lead") or scene.get("sub") or ""
820
- sub_bottom = y + len(lines) * lh
821
- if sub:
822
- ssize = int(th["scale"]["sub"][0])
823
- sy = sub_bottom + int(size * 0.24)
824
- p2 = ease(rng(t, 0.30, 1.40))
825
- for j, ln in enumerate(_wrap(sub, ssize, float(lay["sub_w"]), "light", th)[:3]):
826
- _draw_text(img, th, v, ln, ax + _entrance_x(th, p2, align),
827
- sy + j * int(ssize * float(th.get("line_gap") or 1.3)),
828
- ssize, "light", th["muted"], align, p2)
829
- sub_bottom = sy + int(ssize * float(th.get("line_gap") or 1.3))
830
-
831
- # 持续动态元素:标题下一条缓慢呼吸的强调线(让画面永不静止)
832
- amp = 0.45 + 0.55 * (0.5 + 0.5 * math.sin(0.85 * t + 0.5))
833
- line_w = int(min(lay["title_w"], 760) * 0.55 * amp)
834
- line_y = sub_bottom + int(size * 0.42)
835
- if line_y < 856:
836
- lx = ax if align == "left" else (ax - line_w if align == "right" else ax - line_w // 2)
837
- ImageDraw.Draw(img, "RGBA").line(
838
- [v.x(lx), v.y(line_y), v.x(lx + line_w), v.y(line_y)],
839
- fill=_rgba(th["accent"], 190), width=3)
840
-
841
- return _hud(img, th, scene.get("chapter") or "01 · 开场", _progress(tg, script),
842
- script.get("title", ""))
843
-
844
-
845
- def _draw_items(img, th, v, boxes, items, t, stagger):
846
- ease = _ease(th.get("ease") or "out3")
847
- card = th["card"]
848
- rule = th["rule"]
849
- ink = th["ink"]
850
- body = max(20, int(th["scale"]["body"][0]) - 1)
851
- brand = int(th["scale"]["brand"][0])
852
- pad = max(12, int(card["pad"] * 0.7))
853
- for i, it in enumerate(items):
854
- if i >= len(boxes):
855
- break
856
- x, y, w, h = boxes[i]
857
- p = ease(rng(t, 0.48 + stagger * i, 1.18 + stagger * i))
858
- if p <= 0.0:
859
- continue
860
- a = clamp(p)
861
- dy = _entrance_y(th, p)
862
- alpha = a
863
- d = ImageDraw.Draw(img, "RGBA")
864
- if card["fill_a"] > 0 or card["outline_a"] > 0:
865
- x0, y0 = v.x(x), v.y(y + dy)
866
- x1, y1 = v.x(x + w), v.y(y + dy + h)
867
- radius = int(max(0, card["radius"]) * v.s)
868
- d.rounded_rectangle([x0, y0, x1, y1], radius=radius,
869
- fill=_rgba(rule, card["fill_a"] * alpha),
870
- outline=_rgba(rule, card["outline_a"] * alpha))
871
- if isinstance(it, dict):
872
- txt = str(it.get("text") or "")
873
- ok = bool(it.get("ok", True))
874
- else:
875
- txt = str(it)
876
- ok = True
877
- col = th["good"] if ok else th["bad"]
878
- dot_x = v.x(x + pad + 16)
879
- dot_y = v.y(y + dy + h * 0.5)
880
- dot_r = 9.0 + 1.6 * math.sin(t * 2.1 + i * 0.8)
881
- img = _soft_dot(img, dot_x, dot_y, dot_r * v.s, col, 0.95 * alpha)
882
- d = ImageDraw.Draw(img, "RGBA")
883
- tx = x + pad + 16 + 18 + 10
884
- max_w = (x + w - pad) - tx - 46
885
- tsize = _fit_size(txt, max_w, body, "reg", th, 16)
886
- d.text((v.x(tx), v.y(y + dy + (h - tsize * 1.2) * 0.5)), txt,
887
- font=_font(th, tsize, "reg"), fill=_rgba(ink, 236 * alpha))
888
- if w >= 420:
889
- d.text((v.x(x + w - pad), v.y(y + dy + (h - brand * 1.25) * 0.5)),
890
- "%02d" % (i + 1), font=_font(th, brand, "reg"),
891
- fill=_rgba(th["dim"], 120 * alpha), anchor="ra")
892
- return img
893
-
894
-
895
- def _draw_body(scene, t, dur, tg, script, frame, plan, th):
896
- s_bg = _push_scale(th, t, dur)
897
- v = _view(th, s_bg)
898
- img = _bg_plate(th, s_bg)
899
- lay = ST.LAYOUTS[th["layout"]]
900
- ax, align = _title_anchor(lay)
901
- ease = _ease(th.get("ease") or "out3")
902
- tracking = float(th.get("tracking") or 0.0)
903
- gap_ratio = float(th.get("line_gap") or 1.3)
904
-
905
- kicker = scene.get("kicker") or ""
906
- ksize = int(th["scale"]["kicker"][0])
907
- p0 = ease(rng(t, 0.0, 0.75))
908
- if kicker:
909
- _draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
910
- lay["kicker_y"] + _entrance_y(th, p0), ksize, "bold",
911
- th["accent"], align, p0, tracking * 0.5)
912
-
913
- big = scene.get("big") or (scene.get("text") or "")[:18]
914
- bsize = int(th["scale"]["heading"][0])
915
- max_w = float(lay["title_w"])
916
- lines = _wrap(big, bsize, max_w, "bold", th)
917
- while len(lines) > 3 and bsize > 40:
918
- bsize = int(bsize * 0.9)
919
- lines = _wrap(big, bsize, max_w, "bold", th)
920
- lh = int(bsize * gap_ratio)
921
- p1 = ease(rng(t, 0.06, 0.80))
922
- y0 = float(lay["title_y"]) + _entrance_y(th, p1)
923
- for i, ln in enumerate(lines):
924
- _draw_text(img, th, v, ln, ax + _entrance_x(th, p1, align),
925
- y0 + i * lh, bsize, "bold", th["ink"], align, p1, tracking)
926
-
927
- sub_bottom = y0 + len(lines) * lh
928
- sub = scene.get("sub") or ""
929
- if sub:
930
- ssize = int(th["scale"]["body"][0])
931
- sy = max(float(lay["sub_y"]) if not lay.get("center") else sub_bottom + int(bsize * 0.30),
932
- sub_bottom + int(bsize * 0.30))
933
- p2 = ease(rng(t, 0.20, 0.95))
934
- for j, ln in enumerate(_wrap(sub, ssize, float(lay["sub_w"]), "reg", th)[:2]):
935
- _draw_text(img, th, v, ln, ax + _entrance_x(th, p2, align),
936
- sy + j * int(ssize * gap_ratio), ssize, "reg",
937
- th["muted"], align, p2)
938
- sub_bottom = sy + int(ssize * gap_ratio)
939
-
940
- # 持续动态 1:标题旁的强调线随呼吸伸缩(0.85 rad/s,永不静止)
941
- meter_w = int(min(max_w, 780) * 0.46 * (0.55 + 0.45 * (0.5 + 0.5 * math.sin(0.85 * t + 0.6))))
942
- meter_y = sub_bottom + int(bsize * 0.22)
943
- if meter_y < 850:
944
- mx = ax if align == "left" else (ax - meter_w if align == "right" else ax - meter_w // 2)
945
- ImageDraw.Draw(img, "RGBA").line(
946
- [v.x(mx), v.y(meter_y), v.x(mx + meter_w), v.y(meter_y)],
947
- fill=_rgba(th["accent"], 170), width=3)
948
-
949
- items = list(scene.get("items") or [])
950
- if items:
951
- stagger = float((plan.get("pacing") or {}).get("item_stagger") or 0.32)
952
- # 居中版式里标题/副标题横跨整幅,面板顶必须让开真实底边;
953
- # 左右版式的卡片在另一列,从 layout 的 panel_y 起步即可,不必被标题高度牵着走。
954
- if lay.get("center"):
955
- panel_top = sub_bottom + int(bsize * 0.55)
956
- else:
957
- panel_top = float(lay["panel_y"])
958
- boxes = _panel_boxes(lay, th, len(items), panel_top, v)
959
- # 持续动态 2:卡片区里一条缓慢上下扫的高光(sin 振荡,不做取模跳变)
960
- if boxes:
961
- px0 = min(b[0] for b in boxes)
962
- px1 = max(b[0] + b[2] for b in boxes)
963
- py0 = min(b[1] for b in boxes)
964
- py1 = max(b[1] + b[3] for b in boxes)
965
- k = 0.5 + 0.5 * math.sin(t * 0.55)
966
- sy = py0 + (py1 - py0) * k
967
- ImageDraw.Draw(img, "RGBA").line(
968
- [v.x(px0), v.y(sy), v.x(px1), v.y(sy)],
969
- fill=_rgba(th["accent"], 26), width=2)
970
- img = _draw_items(img, th, v, boxes, items, t, stagger)
971
-
972
- return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
973
- script.get("title", ""))
974
-
975
-
976
- def _deco_orbit(img, th, v, t, cx, cy, p):
977
- """收尾旋转光点环(旋转速度恒定 -> 永远有运动)。"""
978
- for i in range(26):
979
- a = (i / 26.0) * 2.0 * math.pi + t * 0.15
980
- rr = 460.0 * (1.0 - p) + 60.0 + 16.0 * K.hash2(i, 4)
981
- img = _soft_dot(img, v.x(cx + math.cos(a) * rr),
982
- v.y(cy + math.sin(a) * rr * 0.5), 3.0,
983
- th["accent2"], 0.5 * (1.0 - p * 0.7))
984
- return img
985
-
986
-
987
- def _draw_outro(scene, t, dur, tg, script, frame, plan, th):
988
- s_bg = _push_scale(th, t, dur)
989
- v = _view(th, s_bg)
990
- img = _bg_plate(th, s_bg)
991
- lay = ST.LAYOUTS[th["layout"]]
992
- ease = _ease(th.get("ease") or "out3")
993
- tracking = float(th.get("tracking") or 0.0)
994
- gap_ratio = float(th.get("line_gap") or 1.3)
995
-
996
- p = ease(rng(t, 0.0, 1.0))
997
- img = _deco_orbit(img, th, v, t, W * 0.5, 520.0, p)
998
-
999
- big = scene.get("big") or script.get("end_text") or "完"
1000
- size = int(th["scale"]["display"][0])
1001
- lines = _wrap(big, size, min(float(lay["title_w"]), 1520.0), "bold", th)
1002
- while len(lines) > 3 and size > 42:
1003
- size = int(size * 0.88)
1004
- lines = _wrap(big, size, min(float(lay["title_w"]), 1520.0), "bold", th)
1005
- lh = int(size * gap_ratio)
1006
- pa = ease(rng(t, 0.30, 1.30))
1007
- alpha = 0.02 + 0.98 * pa
1008
- dy = _entrance_y(th, pa)
1009
- top = 380.0 - (len(lines) - 1) * lh * 0.5
1010
- for i, ln in enumerate(lines):
1011
- _draw_text(img, th, v, ln, W * 0.5, top + i * lh + dy,
1012
- size, "bold", th["ink"], "center", alpha, tracking)
1013
- bottom = top + len(lines) * lh + dy
1014
-
1015
- sub = scene.get("sub") or ""
1016
- if sub:
1017
- ssize = int(th["scale"]["sub"][0])
1018
- p2 = ease(rng(t, 0.55, 1.55))
1019
- for j, ln in enumerate(_wrap(sub, ssize, 1400, "light", th)[:2]):
1020
- _draw_text(img, th, v, ln, W * 0.5,
1021
- bottom + int(size * 0.22) + j * int(ssize * gap_ratio),
1022
- ssize, "light", th["muted"], "center", p2)
1023
- bottom = bottom + int(size * 0.22) + int(ssize * gap_ratio)
1024
-
1025
- # 持续动态:标题下缓慢生长的强调线 + 两端呼吸
1026
- grow = ease(rng(t, 0.45, 1.45))
1027
- line_w = int(340 * grow * (0.82 + 0.18 * math.sin(0.9 * t)))
1028
- ly = min(bottom + int(size * 0.30), 840)
1029
- ImageDraw.Draw(img, "RGBA").line(
1030
- [v.x(W * 0.5 - line_w * 0.5), v.y(ly), v.x(W * 0.5 + line_w * 0.5), v.y(ly)],
1031
- fill=_rgba(th["accent"], 200), width=3)
1032
-
1033
- return _hud(img, th, scene.get("chapter") or "99 · 收尾", 1.0, script.get("title", ""))
1034
-
1035
-
1036
- _DRAWERS = {
1037
- "hook": _draw_hook,
1038
- "body": _draw_body,
1039
- "outro": _draw_outro,
1040
- }
1041
-
1042
-
1043
- # ============================================================================
1044
- # 四、对外契约
1045
- # ============================================================================
1046
-
1047
- def draw_frame(scene, t_local, dur, t_global, script, frame):
1048
- """按帧画一张 1920x1080 RGB 图。legacy 走 v0.4 原函数(逐字节基线)。"""
1049
- plan = _plan(script)
1050
- kind = scene.get("kind") or "body"
1051
- if plan.get("legacy"):
1052
- fn = _LEGACY_DRAWERS.get(kind) or _legacy_scene_body
1053
- return fn(scene, t_local, dur, t_global, script, frame)
1054
- th = _scene_theme(plan, scene)
1055
- fn = _DRAWERS.get(kind) or _draw_body
1056
- return fn(scene, t_local, dur, t_global, script, frame, plan, th)
1057
-
1058
-
1059
- # ---------------- 提示点(v0.3.0 起可选;v0.7 按 kind 与内容生成) ----------------
1060
- def _sha1_int(*parts):
1061
- """把若干文本片段拼起来取 sha1 前 8 位(提示点"内容派生"的稳定来源)。"""
1062
- raw = "|".join(str(p) for p in parts).encode("utf-8")
1063
- return int(hashlib.sha1(raw).hexdigest()[:8], 16)
1064
-
1065
-
1066
- def _item_pitch(text, base=1760.0):
1067
- """条目文本 -> ui_blip 音高(sha1 派生,不用序号)。
1068
-
1069
- v0.7 契约 §3.4:body 条目的 pitch 必须由**条目文本**派生,不再按序号
1070
- (旧实现 1760+110*i 每片都一样)——同一句文案换到别的片子里仍是同一个音高,
1071
- 不同文案落到 -5~+7 半音的不同音级;万一两条碰撞,_dedupe_segment 会再升半音。
1072
- """
1073
- off = _sha1_int("pitch", text) % 13 - 5
1074
- return round(float(base) * 2.0 ** (off / 12.0), 2)
1075
-
1076
-
1077
- def _cue_gain_decay(gain, repeat):
1078
- """同段内同类型 cue 第 repeat 次重复(0 起)时的增益:每次再低 2 dB(-2/-4/-6/…)。
1079
-
1080
- v0.7 契约 §3.4:不许再出现"每段都一模一样的三连击"——同类型重复必须**严格递减**
1081
- (第 2 次 -2、第 3 次 -4、第 4 次 -6……),且 pitch 必须不同。
1082
- """
1083
- return round(float(gain) - 2.0 * int(repeat), 2)
1084
-
1085
-
1086
- def _dedupe_segment(seg):
1087
- """同一段内:同类型重复的 gain 递减、pitch 去重(保证"pitch 必须不同")。
1088
-
1089
- 正文条目多时 sha1 可能把两条吸附到同一个半音;碰撞就把后一个再升半音——
1090
- 仍然由文本派生,只是保证同段里不出现两个一模一样的高音(听着像卡带)。
1091
- """
1092
- seen = {} # type -> {"n": 已出现次数, "pitch": 已用音高集}
1093
- for c in seg:
1094
- st = seen.setdefault(c["type"], {"n": 0, "pitch": set()})
1095
- if st["n"]:
1096
- c["gain"] = _cue_gain_decay(c.get("gain", 0.0), st["n"])
1097
- st["n"] += 1
1098
- p = c.get("pitch")
1099
- if p is not None:
1100
- p = float(p)
1101
- guard = 0
1102
- while round(p, 2) in st["pitch"] and guard < 12:
1103
- p *= 2.0 ** (1.0 / 12.0)
1104
- guard += 1
1105
- c["pitch"] = round(p, 2)
1106
- st["pitch"].add(round(p, 2))
1107
- return seg
1108
-
1109
-
1110
- def cues(tl, script):
1111
- """画面提示点钩子:render.py --stage cues 会调它(没有这个函数就不调)。
1112
-
1113
- 好处是"声音跟着画面的像素走":这里算的是卡片落位、数字滚完的时刻,
1114
- 画面一改,声音自动跟着改,不用回来手工对时间。
1115
-
1116
- v0.5:条目落位步长与场景绘制共用 plan["pacing"]["item_stagger"],
1117
- 两边读同一个数,否则音效会错位(这是 v0.4 写死 0.32 留下的坑)。
1118
-
1119
- v0.7(references/audio-variation.md §3.4):提示点改为按**场景 kind 与内容**生成,
1120
- 不再是"每段 ui_blip 三连 + 结尾 answer_chime"的同质化固定套路(跨片音效雷同的
1121
- 来源之一):
1122
- hook : 段首 impact(size 按字数 0.9~1.3)+ 落定 riser
1123
- body : 条目落位 ui_blip(pitch 由条目文本 sha1 派生)+ items>=3 补 answer_chime
1124
- + 段尾 whoosh(转场到下一段)
1125
- quote : reverse_swell + 落定轻 impact
1126
- outro : answer_chime + 片尾 sub_drop
1127
- 同段内同类型 cue 重复时 gain 逐次再降 2 dB(-2/-4/-6)、pitch 必须不同。
1128
- 返回 schema 不变:[{"at": 时刻, "type": 配方名, ...参数}];
1129
- at 可以写 "abs:12.5" / 12.5 / "l03+0.35"(相对该段 start)/"l03"
1130
- type 见 references/sfx-design.md 的配方表,或 sfxkit.list_recipes()
1131
- """
1132
- plan = _plan(script)
1133
- stagger = 0.32
1134
- if not plan.get("legacy"):
1135
- stagger = float((plan.get("pacing") or {}).get("item_stagger") or 0.32)
1136
- scenes = list(tl.get("scenes") or [])
1137
- out = []
1138
- for i, s in enumerate(scenes):
1139
- sid = str(s.get("id") or ("l%02d" % (i + 1)))
1140
- kind = str(s.get("kind") or "body")
1141
- end = float(s.get("end") or 0.0)
1142
- dur = float(s.get("dur") or max(0.0, end - float(s.get("start") or 0.0)))
1143
- has_next = (i + 1) < len(scenes)
1144
- seg = []
1145
-
1146
- def _at(off):
1147
- """本段相对时刻 -> cue 的 at 写法(id+偏移,保持返回 schema 不变)。"""
1148
- return "%s+%.2f" % (sid, max(0.0, float(off)))
1149
-
1150
- if kind == "hook":
1151
- # 开场重击按文案字数给"体量":字数越多 size 越大(0.9~1.3),避免每片同一记闷响
1152
- text = str(s.get("text") or s.get("big") or "")
1153
- n_char = len("".join(text.split()))
1154
- size = round(0.9 + 0.4 * min(1.0, n_char / 40.0), 2)
1155
- seg.append({"at": _at(0.10), "type": "impact", "size": size, "gain": -2})
1156
- seg.append({"at": _at(min(1.2, max(0.35, dur * 0.28))),
1157
- "type": "riser", "dur": 1.2, "gain": -6})
1158
- elif kind == "quote":
1159
- # 引语段:先来一层反向渐强铺底,落定时轻一点的一击
1160
- seg.append({"at": _at(0.05), "type": "reverse_swell", "dur": 1.2, "gain": -5})
1161
- seg.append({"at": _at(min(1.4, max(0.5, dur * 0.30))),
1162
- "type": "impact", "size": 0.6, "gain": -8})
1163
- elif kind == "outro":
1164
- # 收尾:答对钟声 + 片尾 sub_drop(尾巴正好落在段尾,不留半截低频)
1165
- seg.append({"at": _at(0.35), "type": "answer_chime", "gain": -4})
1166
- seg.append({"at": _at(max(0.30, dur - 1.60)),
1167
- "type": "sub_drop", "dur": 1.6, "gain": -5})
1168
- else:
1169
- # body(以及 emphasis/calm 等未单独设计的 kind:按正文处理,向后兼容)
1170
- items = list(s.get("items") or [])
1171
- for j, it in enumerate(items):
1172
- # 条目逐条落位:第一项在 0.48s 出现,之后每 stagger 秒一个(与 _draw_items 同一拍)
1173
- at = 0.48 + stagger * j
1174
- if at > dur - 0.12: # 极短段:放不下的条目不再排提示点(避免响到下一段)
1175
- break
1176
- txt = it.get("text") if isinstance(it, dict) else it
1177
- seg.append({"at": _at(at), "type": "ui_blip",
1178
- "pitch": _item_pitch(txt), "gain": -6})
1179
- blips = [c for c in seg if c["type"] == "ui_blip"]
1180
- if len(items) >= 3 and blips and dur > 0.6:
1181
- # 最后一个条目落定后的下一拍补一声"猜中了",给列表收尾
1182
- last_at = float(blips[-1]["at"].split("+", 1)[1])
1183
- t_chime = min(last_at + stagger, max(last_at + 0.20, dur - 0.30))
1184
- seg.append({"at": _at(t_chime), "type": "answer_chime", "gain": -6})
1185
- if has_next and dur > 0.6:
1186
- # 段尾 whoosh:从本段扫进下一段(C 路会在段界附近给转场类音效让位)
1187
- seg.append({"at": _at(max(0.0, dur - 0.45)),
1188
- "type": "whoosh", "dur": 0.9, "gain": -6})
1189
- out.extend(_dedupe_segment(seg))
1190
- return out
1
+ # -*- coding: utf-8 -*-
2
+ """scenes.py — 技能内置的场景渲染器(v1.0.2:默认渲染路径 + 自定义场景的范例)。
3
+
4
+ ⚠ 这**不是**给用户拷走改的模板:v1.0.2 起 render.py 不写 --scenes 时会自动回落到本文件,
5
+ 用户工程目录里只需要一个 film.json。想改画面时,在剧本同目录放一个自己的 scenes.py
6
+ (或 --scenes 显式指定),它会被优先发现;本文件就是那份自写场景的**参考实现**:
7
+ 10 类构图 / 8 种母题 / 6 种转场 / 35 字段主题 / 版式轮换 / 字幕参数全部从计划里读。
8
+
9
+ 唯一约定:提供 draw_frame(scene, t_local, dur, t_global, script, frame) -> PIL.Image
10
+ scene : timeline.json 里的一段(含 id/text/sub/start/end/dur 与 grammar 注入的
11
+ scene_type / scene_variant / scene_decor / scene_layout / scene_reason)
12
+ t_local : 本段内已经过秒数
13
+ dur : 本段总秒数
14
+ t_global: 全片秒数
15
+ script : 剧本 dict(含 _style 计划、_total 总时长)
16
+ frame : 全片帧号(确定性:同一帧号必须画出同一张图)
17
+
18
+ 画风从哪来
19
+ ----------
20
+ render.py 把 designkit.design_to_plan() 的计划塞进 script["_style"](主题 / 视觉语言 /
21
+ 版式轮换 / 配乐 / 声线 / 节奏 / 字幕样式)。本文件从这份计划里取配色、材质、字体族、
22
+ 版式、缓动、HUD、镜头缓推——**没有任何内置风格表**(v1.0.1 已全部删除):
23
+ 同一份代码画出来是什么样,完全由现场采样并确认过的 design.json 决定。
24
+
25
+ 铁律(决定画质与可复现性):
26
+ 1. 不许用 random / time.time();要随机用 K.hash2 / K.hash3 / vnoise(seed)
27
+ 2. 每帧从零画,不依赖上一帧的变量
28
+ 3. 静态背景预渲染一次并缓存(见 _bg()),不要每帧重算渐变 / 纹理 / 暗角
29
+ 4. 文字排版一律用本文件的 _wrap / _text_w(带缓存):直接调 wrap_cn 会让单帧慢 2-3 倍
30
+ 5. 所有正文落在 K.SAFE 内(y <= 974),字幕由 render.py 统一画在 y >= 872
31
+ 6. 每段至少一个动态元素,静止不要超过 6 秒;镜头缓推 3-15%
32
+ 7. 缺字形会画成方块:用 K.glyph_missing_chars 自查,别写 ✓ × ↵ 这类符号(改用矢量线条)
33
+ 8. 性能:1080p 单帧预算 ~15ms。重活(渐变/暗角/纹理/精灵)全部预烘焙进 _CACHE;
34
+ 不许每帧全屏高斯模糊 / 全屏双线性 resize(实测 30ms/帧,直接吃掉预算)
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import hashlib
39
+ import math
40
+ import os
41
+ import sys
42
+ import warnings
43
+
44
+ from PIL import Image, ImageDraw, ImageFilter
45
+ import numpy as np
46
+
47
+ # stylekit 的单向依赖:stylekit 不 import 本文件(也不 import filmkit),不会成环。
48
+ # 用户会把 scenes.py 与 scripts/*.py 拷到同一目录,那种情况下直接 import 就行;
49
+ # 本仓库里是 assets/scenes.py 与 scripts/stylekit.py 两个目录,所以兜底加 ../scripts。
50
+ try:
51
+ import stylekit as ST
52
+ except ImportError:
53
+ _SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "scripts")
54
+ _SCRIPTS = os.path.normpath(_SCRIPTS)
55
+ if _SCRIPTS not in sys.path:
56
+ sys.path.insert(0, _SCRIPTS)
57
+ import stylekit as ST
58
+
59
+ import filmkit as K
60
+ # 只 import 模板**当下真的用到**的名字(多余导入会被 pyflakes 记一笔,也容易让人以为哪都能用)。
61
+ # 写自己的场景时,缓动/噪声直接走模块:K.lerp / K.ease_in / K.ease_in_out / K.vnoise / K.hash2 …
62
+ from filmkit import W, H, clamp, smooth, ease_out, rng
63
+
64
+ # ============================================================================
65
+ # 零、v0.4 兼容层(legacy 逐字节基线,别动这里的任何一行)
66
+ # ============================================================================
67
+ #
68
+ # legacy = 老项目(film.json 没有 style 字段)。这组函数是 v0.4 的原文,
69
+ # 一个字符都不许改:Lead 的基线三帧 sha256 直接对拍这里的输出。
70
+ # 新主题化代码只在 _legacy_* 之外生长,互不影响。
71
+
72
+ # ---------------- 主题(legacy 专用;新代码走 stylekit.STYLES / THEMES) ----------------
73
+ THEME = dict(
74
+ bg_top=(7, 14, 26), bg_bot=(17, 33, 54),
75
+ ink=(240, 245, 252), muted=(150, 174, 200), dim=(96, 118, 144),
76
+ accent=(243, 132, 60), accent2=(86, 170, 232),
77
+ good=(108, 178, 110), bad=(226, 96, 92),
78
+ )
79
+
80
+ _CACHE = {}
81
+
82
+
83
+ def _vgrad(w, h, top, bot):
84
+ """竖直渐变背景 + 极轻网点(确定性),缓存复用。"""
85
+ key = ("bg", w, h, top, bot)
86
+ if key in _CACHE:
87
+ return _CACHE[key]
88
+ a = np.linspace(0, 1, h, dtype=np.float32)[:, None]
89
+ arr = np.array(top, dtype=np.float32)[None, :] * (1 - a[..., None]) \
90
+ + np.array(bot, dtype=np.float32)[None, :] * a[..., None]
91
+ arr = np.repeat(arr, w, axis=1)
92
+ yy, xx = np.mgrid[0:h, 0:w]
93
+ arr = np.clip(arr + (((np.sin(xx * 0.31) * np.sin(yy * 0.31)) > 0.955)
94
+ .astype(np.float32) * 5.0)[..., None], 0, 255)
95
+ im = Image.fromarray(arr.astype(np.uint8), "RGB")
96
+ _CACHE[key] = im
97
+ return im
98
+
99
+
100
+ def _vignette_mask(w, h, strength=0.40):
101
+ key = ("vig", w, h, strength)
102
+ if key in _CACHE:
103
+ return _CACHE[key]
104
+ yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
105
+ r = np.sqrt(((xx - w / 2.0) / (w / 2.0)) ** 2 + ((yy - h / 2.0) / (h / 2.0)) ** 2) / 1.4142
106
+ m = np.clip((r - 0.40) / 0.60, 0, 1) ** 1.6 * strength
107
+ mask = Image.fromarray((m * 255).astype(np.uint8), "L")
108
+ _CACHE[key] = mask
109
+ return mask
110
+
111
+
112
+ def _soft_dot(img, x, y, r, color, alpha=1.0):
113
+ """柔边圆点精灵(预渲染一次,之后只 paste)。"""
114
+ if r < 0.6 or alpha <= 0.004:
115
+ return img
116
+ key = ("dot", int(round(r * 2)), color)
117
+ if key not in _CACHE:
118
+ s = int(r * 2 + 6)
119
+ im = Image.new("RGBA", (s, s), (0, 0, 0, 0))
120
+ ImageDraw.Draw(im).ellipse([3, 3, s - 3, s - 3], fill=color + (255,))
121
+ _CACHE[key] = im.filter(ImageFilter.GaussianBlur(max(1.0, r * 0.22)))
122
+ spr = _CACHE[key]
123
+ if alpha < 0.985:
124
+ spr = spr.copy()
125
+ spr.putalpha(spr.split()[3].point(lambda v: int(v * alpha)))
126
+ img.paste(spr, (int(x - spr.width / 2), int(y - spr.height / 2)), spr)
127
+ return img
128
+
129
+
130
+ def _legacy_bg():
131
+ """预烘焙背景:渐变 + 暗角一次算好,每帧只 copy(实测省 ~4.5ms/帧)。"""
132
+ if "baked" not in _CACHE:
133
+ base = _vgrad(W, H, THEME["bg_top"], THEME["bg_bot"])
134
+ _CACHE["baked"] = Image.composite(Image.new("RGB", (W, H), (0, 0, 0)),
135
+ base, _vignette_mask(W, H, 0.40))
136
+ return _CACHE["baked"]
137
+
138
+
139
+ def _legacy_hud(img, chapter, progress, title):
140
+ d = ImageDraw.Draw(img, "RGBA")
141
+ d.rectangle([0, 0, int(W * clamp(progress)), 3], fill=THEME["accent"])
142
+ d.rectangle([0, 0, W, 3], fill=(255, 255, 255, 11))
143
+ f = K.font(27, "reg")
144
+ tw = K.text_w_cached(chapter, 27, "reg")
145
+ d.rounded_rectangle([64, 42, 64 + tw + 46, 96], radius=27,
146
+ fill=(4, 10, 20, 150), outline=(255, 255, 255, 40))
147
+ d.ellipse([64 + 16, 62, 64 + 26, 72], fill=THEME["accent"])
148
+ d.text((64 + 34, 56), chapter, font=f, fill=(228, 236, 246))
149
+ f2 = K.font(23, "reg")
150
+ tw2 = K.text_w_cached(title, 23, "reg")
151
+ d.text((W - 64 - tw2, 58), title, font=f2, fill=(255, 255, 255, 96))
152
+ return img
153
+
154
+
155
+ def _legacy_cover(img, t, dur, kicker, big, sub, progress, title):
156
+ d = ImageDraw.Draw(img, "RGBA")
157
+ p = ease_out(rng(t, 0.0, 0.9))
158
+ p2 = ease_out(rng(t, 0.25, 1.3))
159
+ d.text((150, 300 - 26 * (1 - p)), kicker, font=K.font(30, "bold"),
160
+ fill=THEME["accent"] + (255,))
161
+ y = 352
162
+ for ln in K.wrap_cn_cached(big, 84, 1560, "bold"):
163
+ d.text((150, y), ln, font=K.font(84, "bold"), fill=THEME["ink"] + (255,))
164
+ y += 104
165
+ if sub:
166
+ for j, ln in enumerate(K.wrap_cn_cached(sub, 38, 1400, "light")):
167
+ d.text((150, y + 26 + j * 56), ln, font=K.font(38, "light"),
168
+ fill=THEME["muted"] + (int(242 * p2),))
169
+ return _legacy_hud(img, "01 · 开场", progress, title)
170
+
171
+
172
+ def _legacy_scene_hook(scene, t, dur, tg, script, frame):
173
+ img = _legacy_bg().copy()
174
+ d = ImageDraw.Draw(img, "RGBA")
175
+ cx, cy = W // 2, H // 2 - 40
176
+ p = smooth(rng(t, 0.15, 2.6))
177
+ for i in range(18):
178
+ a = (i / 18.0) * 2 * math.pi + 0.2
179
+ rr = 60 + 460 * p * (0.55 + 0.45 * K.hash2(i, 11))
180
+ x2, y2 = cx + math.cos(a) * rr, cy + math.sin(a) * rr * 0.62
181
+ d.line([cx, cy, x2, y2], fill=(255, 255, 255, int(70 * p)), width=1)
182
+ if K.hash2(i, 9) > 0.45:
183
+ img = _soft_dot(img, x2, y2, 3 + 5 * K.hash2(i, 5), THEME["accent2"], 0.55 * p)
184
+ img = _soft_dot(img, cx, cy, 26 + 6 * math.sin(t * 2.2), THEME["accent"], 0.9 * rng(t, 0.1, 0.7))
185
+ return _legacy_cover(img, t, dur, "00 · 开场", script.get("title", ""),
186
+ scene.get("lead") or "零素材,全代码生成", tg / max(0.1, script.get("_total", 1.0)),
187
+ script.get("title", ""))
188
+
189
+
190
+ def _legacy_scene_body(scene, t, dur, tg, script, frame):
191
+ img = _legacy_bg().copy()
192
+ d = ImageDraw.Draw(img, "RGBA")
193
+ p = ease_out(rng(t, 0.0, 0.8))
194
+ d.text((150, 250 + int(14 * (1 - p))), scene.get("kicker", ""),
195
+ font=K.font(28, "bold"), fill=THEME["accent"] + (int(255 * p),))
196
+ for i, ln in enumerate(K.wrap_cn_cached(scene.get("big") or scene["text"][:18], 64, 820, "bold")):
197
+ d.text((150, 300 + i * 78), ln, font=K.font(64, "bold"), fill=THEME["ink"])
198
+ if scene.get("sub"):
199
+ for i, ln in enumerate(K.wrap_cn_cached(scene["sub"], 34, 820)):
200
+ d.text((150, 420 + i * 48), ln, font=K.font(34, "reg"), fill=THEME["muted"])
201
+
202
+ for i, it in enumerate(scene.get("items") or []):
203
+ p_i = ease_out(rng(t, 0.45 + 0.32 * i, 1.15 + 0.32 * i))
204
+ if p_i <= 0:
205
+ continue
206
+ x, y = 1030 + int(46 * (1 - p_i)), 240 + i * 118
207
+ d.rounded_rectangle([x, y, x + 740, y + 96], radius=14,
208
+ fill=(255, 255, 255, int(16 * p_i)),
209
+ outline=(255, 255, 255, int(28 * p_i)))
210
+ col = THEME["good"] if it.get("ok", True) else THEME["bad"]
211
+ img = _soft_dot(img, x + 34, y + 48, 11, col, p_i)
212
+ d = ImageDraw.Draw(img, "RGBA")
213
+ d.text((x + 60, y + 28), it["text"], font=K.font(32, "reg"),
214
+ fill=THEME["ink"] + (int(255 * p_i),))
215
+ return _legacy_hud(img, scene.get("chapter") or "02 · 正文",
216
+ tg / max(0.1, script.get("_total", 1.0)), script.get("title", ""))
217
+
218
+
219
+ def _legacy_scene_outro(scene, t, dur, tg, script, frame):
220
+ img = _legacy_bg().copy()
221
+ d = ImageDraw.Draw(img, "RGBA")
222
+ p = ease_out(rng(t, 0.0, 1.0))
223
+ cx, cy = W // 2, H // 2 - 30
224
+ for i in range(26):
225
+ a = (i / 26.0) * 2 * math.pi + t * 0.15
226
+ rr = 500 * (1 - p) + 40 + 18 * K.hash2(i, 4)
227
+ img = _soft_dot(img, cx + math.cos(a) * rr, cy + math.sin(a) * rr * 0.5,
228
+ 3, THEME["accent2"], 0.5 * (1 - p * 0.7))
229
+ d = ImageDraw.Draw(img, "RGBA")
230
+ lines = K.wrap_cn_cached(scene.get("big") or script.get("end_text") or "完", 96, 1500, "bold")
231
+ alpha = int(255 * ease_out(rng(t, 0.3, 1.5)))
232
+ for i, ln in enumerate(lines):
233
+ d.text(((W - K.text_w_cached(ln, 96, "bold")) / 2, 380 + i * 118), ln,
234
+ font=K.font(96, "bold"), fill=THEME["ink"] + (alpha,))
235
+ return _legacy_hud(img, "99 · 收尾", 1.0, script.get("title", ""))
236
+
237
+
238
+ _LEGACY_DRAWERS = {
239
+ "hook": _legacy_scene_hook,
240
+ "body": _legacy_scene_body,
241
+ "outro": _legacy_scene_outro,
242
+ }
243
+
244
+
245
+ # ============================================================================
246
+ # 一、风格计划 / 主题(v0.5)
247
+ # ============================================================================
248
+
249
+ _PLAN_CACHE = {}
250
+ _THEME_WARNED = set()
251
+ _DEFAULT_PUSH = 0.04
252
+
253
+
254
+ def _warn_once(key, msg):
255
+ if key in _THEME_WARNED:
256
+ return
257
+ _THEME_WARNED.add(key)
258
+ warnings.warn(msg, RuntimeWarning, stacklevel=3)
259
+
260
+
261
+ def _plan(script):
262
+ """取整片的风格计划:render.py 会把解析好的计划放进 script["_style"]。
263
+
264
+ 没有就自己解一次(纯函数,同参数同结果)。每帧都会调用,所以只留一条缓存;
265
+ 键里带 id(script) 之外的稳定字段,避免脚本对象被回收后 id 复用串档。
266
+ """
267
+ p = script.get("_style")
268
+ if isinstance(p, dict):
269
+ return p
270
+ key = (id(script), script.get("title"), len(script.get("lines") or []),
271
+ script.get("style"), bool(script.get("pacing")))
272
+ got = _PLAN_CACHE.get(key)
273
+ if got is None:
274
+ got = ST.resolve_script_style(script)
275
+ _PLAN_CACHE.clear()
276
+ _PLAN_CACHE[key] = got
277
+ return got
278
+
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
+
300
+ def _scene_theme(plan, scene):
301
+ """本段用哪套主题:全片主题 + 可选的 scene["theme"] 覆盖(只许同明暗族)。"""
302
+ base = plan.get("theme")
303
+ if isinstance(base, dict):
304
+ th = _freeze_theme(base)
305
+ else:
306
+ th = dict(ST.THEMES[plan["theme_id"]])
307
+ sid = scene.get("theme")
308
+ if not sid or str(sid) == str(th.get("id")):
309
+ return th
310
+ alt = ST.THEMES.get(str(sid))
311
+ if alt is None:
312
+ _warn_once(("unknown", str(sid)),
313
+ "场景 %r 指定了未知主题 %r,已忽略(可用:%s)"
314
+ % (scene.get("id"), sid, " / ".join(ST.STYLE_IDS)))
315
+ return th
316
+ if not ST.family_compatible(th, alt):
317
+ # 跨明暗族 = 两部片,不是两段:段界会整屏换底,QA 的闪烁门禁必炸。
318
+ _warn_once(("family", str(th.get("id")), str(sid)),
319
+ "场景 %r 想从主题 %s 换到 %s,两者明暗族不同(同片换族会触发闪烁门禁),已忽略"
320
+ % (scene.get("id"), th.get("id"), sid))
321
+ return th
322
+ return _freeze_theme(alt)
323
+
324
+
325
+ # ---------------- 包内小工具 ----------------
326
+
327
+ def _mix(a, b, p):
328
+ """两色线性插值(p=0 -> a,p=1 -> b)。"""
329
+ q = clamp(p)
330
+ return (int(round(a[0] + (b[0] - a[0]) * q)),
331
+ int(round(a[1] + (b[1] - a[1]) * q)),
332
+ int(round(a[2] + (b[2] - a[2]) * q)))
333
+
334
+
335
+ def _rgba(c, alpha=255):
336
+ """RGB + 0..255 透明度 -> 4 元组(PIL 的 RGBA 画笔要它)。"""
337
+ return (int(c[0]), int(c[1]), int(c[2]),
338
+ int(max(0, min(255, round(alpha)))))
339
+
340
+
341
+ def _ease(name):
342
+ """风格包里的缓动名 -> 函数(四个都满足 f(0)=0 / f(1)=1)。"""
343
+ got = _EASES.get(name)
344
+ if got is not None:
345
+ return got
346
+ if name == "out5":
347
+ got = lambda p: 1.0 - (1.0 - clamp(p)) ** 5
348
+ elif name == "inOut3":
349
+ got = K.ease_in_out
350
+ elif name == "outBack":
351
+ got = K.ease_out_back
352
+ else:
353
+ got = K.ease_out
354
+ _EASES[name] = got
355
+ return got
356
+
357
+
358
+ _EASES = {}
359
+
360
+
361
+ # ---------------- 字体:family 是 v0.5 新加的参数(旧 filmkit 没有,自动回退) ----------------
362
+
363
+ _FONT_CACHE = {}
364
+ _FAMILY_OK = None
365
+
366
+
367
+ def _family_ok():
368
+ """filmkit.font 是否支持 family=...(Lead 在 v0.5 加;老版本没有就回退旧签名)。"""
369
+ global _FAMILY_OK
370
+ if _FAMILY_OK is None:
371
+ try:
372
+ K.font(16, "reg", "sans")
373
+ _FAMILY_OK = True
374
+ except TypeError:
375
+ _FAMILY_OK = False
376
+ except Exception: # noqa: BLE001 - 字体族实现有 bug 时退回旧签名
377
+ _FAMILY_OK = False
378
+ return _FAMILY_OK
379
+
380
+
381
+ def _font(th, size, weight="reg"):
382
+ size = max(6, int(size))
383
+ fam = str(th.get("family") or "sans")
384
+ key = (size, weight, fam)
385
+ f = _FONT_CACHE.get(key)
386
+ if f is None:
387
+ if _family_ok():
388
+ try:
389
+ f = K.font(size, weight, fam)
390
+ except Exception: # noqa: BLE001 - 某族字体缺失时退回到默认字体
391
+ f = K.font(size, weight)
392
+ else:
393
+ f = K.font(size, weight)
394
+ _FONT_CACHE[key] = f
395
+ return f
396
+
397
+
398
+ _TW_CACHE = {}
399
+ _WRAP_CACHE = {}
400
+
401
+
402
+ def _text_w(text, size, weight, th):
403
+ """带缓存的行宽(按主题字体族;老 filmkit 回退到 K.text_w_cached)。"""
404
+ if not text:
405
+ return 0.0
406
+ size = max(6, int(size))
407
+ fam = str(th.get("family") or "sans")
408
+ key = (text, size, weight, fam)
409
+ got = _TW_CACHE.get(key)
410
+ if got is None:
411
+ if len(_TW_CACHE) > 40000:
412
+ _TW_CACHE.clear()
413
+ if _family_ok():
414
+ got = K.text_w(text, _font(th, size, weight))
415
+ else:
416
+ got = K.text_w_cached(text, size, weight)
417
+ _TW_CACHE[key] = got
418
+ return got
419
+
420
+
421
+ def _wrap(text, size, max_w, weight, th):
422
+ """带缓存的换行(同上)。"""
423
+ if not text:
424
+ return []
425
+ size = max(6, int(size))
426
+ max_w = max(80, int(max_w))
427
+ fam = str(th.get("family") or "sans")
428
+ key = (text, size, max_w, weight, fam)
429
+ got = _WRAP_CACHE.get(key)
430
+ if got is None:
431
+ if len(_WRAP_CACHE) > 20000:
432
+ _WRAP_CACHE.clear()
433
+ if _family_ok():
434
+ got = K.wrap_cn(text, _font(th, size, weight), max_w)
435
+ else:
436
+ got = K.wrap_cn_cached(text, size, max_w, weight)
437
+ _WRAP_CACHE[key] = got
438
+ return got
439
+
440
+
441
+ def _fit_size(text, max_w, size, weight, th, floor=16):
442
+ """按卡片可用宽度缩字号(条目文字长短不一,不能撑破卡片)。"""
443
+ s = int(size)
444
+ while s > floor and _text_w(text, s, weight, th) > max_w:
445
+ s -= 2
446
+ return s
447
+
448
+
449
+ # ---------------- 镜头缓推 + 前景视差 ----------------
450
+
451
+ class _View(object):
452
+ """设计坐标 -> 帧坐标。
453
+
454
+ 为什么不是整帧重采样:实测 1080p 双线性整帧变换 29ms/帧(预算 15ms),
455
+ 而整帧按 push 放大又会把 150px 边距的内容推出 SAFE。所以分两层:
456
+ 背景层做**真缩放**(EXTENT + NEAREST,1.7~3.5ms,见 _bg_plate);
457
+ 前景层做同向**视差**(0.35x,位置与卡片尺寸随镜头收放,字形不重采样保清晰)。
458
+ """
459
+ __slots__ = ("s", "cx", "cy")
460
+
461
+ def __init__(self, s):
462
+ self.s = float(s)
463
+ self.cx = W * 0.5
464
+ self.cy = H * 0.5
465
+
466
+ def x(self, x):
467
+ return int(round(self.cx + (float(x) - self.cx) * self.s))
468
+
469
+ def y(self, y):
470
+ return int(round(self.cy + (float(y) - self.cy) * self.s))
471
+
472
+ def r(self, r):
473
+ return max(0.0, float(r) * self.s)
474
+
475
+
476
+ def _push_scale(th, t_local, dur):
477
+ """镜头缓推:t=0 -> 1.0,t=dur -> 1+push,单调平滑(端点必须收敛,不许抖)。"""
478
+ u = clamp(t_local / max(0.05, float(dur)))
479
+ return 1.0 + float(th.get("push") or _DEFAULT_PUSH) * smooth(u)
480
+
481
+
482
+ def _view(th, s_bg):
483
+ return _View(1.0 + (s_bg - 1.0) * 0.35)
484
+
485
+
486
+ # ---------------- 背景:按主题烘焙一次(渐变 + 材质 + 颗粒 + 暗角) ----------------
487
+
488
+ def _grid_xy():
489
+ yy = np.arange(H, dtype=np.float32)[:, None]
490
+ xx = np.arange(W, dtype=np.float32)[None, :]
491
+ return yy, xx
492
+
493
+
494
+ def _paper_noise(th):
495
+ """纸纹:纤维条纹(vnoise)+ 多尺度斑块(hash2 小图上采样)。一次烘焙,不进逐帧路径。"""
496
+ freq = float(th.get("grain_freq") or 0.27)
497
+ xs = np.arange(W, dtype=np.float32) * freq * 0.35
498
+ fiber = np.array([K.vnoise(float(v), 3) for v in xs], dtype=np.float32) - 0.5
499
+ fine = np.array([K.vnoise(float(v) * 2.3, 9) for v in xs], dtype=np.float32) - 0.5
500
+ rows, cols = 27, 48
501
+ blobs = np.array([[K.hash2(r * 31 + 7, c * 17 + 3) for c in range(cols)]
502
+ for r in range(rows)], dtype=np.float32) - 0.5
503
+ blob_img = Image.fromarray(((blobs + 0.5) * 255.0).astype(np.uint8), "L")
504
+ blobs = np.asarray(blob_img.resize((W, H), Image.BICUBIC), dtype=np.float32) / 255.0 - 0.5
505
+ return blobs * 0.55 + fiber[None, :] * 0.30 + fine[None, :] * 0.15
506
+
507
+
508
+ def _texture_layer(th, yy, xx):
509
+ """背景材质层(灰度偏移;深色主题提亮、浅色主题压暗)。"""
510
+ kind = str(th.get("texture") or "none")
511
+ alpha = float(th.get("texture_alpha") or 0.0)
512
+ if kind == "none" or alpha <= 0.0:
513
+ return None
514
+ sign = 1.0 if ST.is_dark(th) else -1.0
515
+ amp = sign * 255.0 * alpha * 0.44
516
+ if kind == "dots":
517
+ f = float(th.get("grain_freq") or 0.31)
518
+ layer = ((np.sin(xx * f) * np.sin(yy * f)) > 0.955).astype(np.float32)
519
+ elif kind == "grid":
520
+ step = 88.0
521
+ layer = (((xx % step) < 1.0) | ((yy % step) < 1.0)).astype(np.float32)
522
+ elif kind == "scanlines":
523
+ layer = ((yy % 4.0) < 1.0).astype(np.float32)
524
+ elif kind == "paper":
525
+ layer = _paper_noise(th)
526
+ else:
527
+ return None
528
+ return layer * amp
529
+
530
+
531
+ def _grain_layer(th, yy, xx):
532
+ """确定性颗粒(坐标 hash,等价 vnoise 的 2D 版本;不用 random)。"""
533
+ g = float(th.get("grain") or 0.0)
534
+ if g <= 0.05:
535
+ return None
536
+ xi = np.arange(W, dtype=np.int64)[None, :]
537
+ yi = np.arange(H, dtype=np.int64)[:, None]
538
+ v = (xi * 374761393 + yi * 668265263) & 0xFFFFFFFF
539
+ v = (v ^ (v >> 13)) & 0xFFFFFFFF
540
+ v = (v * 1274126177) & 0xFFFFFFFF
541
+ v = (v ^ (v >> 16)) & 0xFFFFFFFF
542
+ noise = (v & 0xFFFF).astype(np.float32) / 65535.0 - 0.5
543
+ flat = np.empty((H, W), dtype=np.float32)
544
+ np.add(noise * (g * 1.2), 0.0, out=flat)
545
+ return flat
546
+
547
+
548
+ def _vignette_layer(th, yy, xx):
549
+ """暗角:浅色主题压向 vignette_tint(米灰),绝不压向纯黑。"""
550
+ strength = float(th.get("vignette") or 0.0)
551
+ if strength <= 0.0:
552
+ return None
553
+ r = np.sqrt(((xx - W * 0.5) / (W * 0.5)) ** 2 + ((yy - H * 0.5) / (H * 0.5)) ** 2) / 1.4142
554
+ m = np.clip((r - 0.40) / 0.60, 0.0, 1.0) ** 1.6 * strength
555
+ return m
556
+
557
+
558
+ def _hashable(v):
559
+ """把 list 递归转成 tuple:主题从 JSON 读回来时元组会变成 list,而 list 不能当 dict 键。
560
+
561
+ 为什么需要它:--jobs >1 时 render.py 的 worker 从剧本快照读 script,主题色的
562
+ (r,g,b) 元组变成 [r,g,b],于是下面 _bg 的缓存键抛 unhashable type: "list"。
563
+ render.py 已按剧本重解风格,但这层防御留着——scenes.py 是用户会拷走改的模板。
564
+ """
565
+ if isinstance(v, (list, tuple)):
566
+ return tuple(_hashable(x) for x in v)
567
+ if isinstance(v, dict):
568
+ return tuple(sorted((k, _hashable(x)) for k, x in v.items()))
569
+ return v
570
+
571
+
572
+ def _bg(th):
573
+ """预烘焙背景:主题色渐变 + 材质 + 颗粒 + 暗角,一次算好,逐帧只 copy / 变换。"""
574
+ key = _hashable(("baked", th.get("id"), th["bg"], th["bg2"], th["texture"], th["grain"],
575
+ th["vignette"], th["vignette_tint"], th["texture_alpha"],
576
+ th["grain_freq"]))
577
+ got = _CACHE.get(key)
578
+ if got is not None:
579
+ return got
580
+ yy, xx = _grid_xy()
581
+ a = np.linspace(0.0, 1.0, H, dtype=np.float32)[:, None, None]
582
+ top = np.array(th["bg"], dtype=np.float32)[None, None, :]
583
+ bot = np.array(th["bg2"], dtype=np.float32)[None, None, :]
584
+ arr = np.repeat(top * (1.0 - a) + bot * a, W, axis=1)
585
+ tex = _texture_layer(th, yy, xx)
586
+ if tex is not None:
587
+ arr = arr + tex[:, :, None]
588
+ grain = _grain_layer(th, yy, xx)
589
+ if grain is not None:
590
+ arr = arr + grain[:, :, None]
591
+ np.clip(arr, 0.0, 255.0, out=arr)
592
+ vig = _vignette_layer(th, yy, xx)
593
+ if vig is not None:
594
+ tint = np.array(th["vignette_tint"], dtype=np.float32)[None, None, :]
595
+ arr = arr * (1.0 - vig)[:, :, None] + tint * vig[:, :, None]
596
+ np.clip(arr, 0.0, 255.0, out=arr)
597
+ im = Image.fromarray(arr.astype(np.uint8), "RGB")
598
+ _CACHE[key] = im
599
+ return im
600
+
601
+
602
+ def _bg_plate(th, s):
603
+ """逐帧背景:按镜头缩放取景(NEAREST 重采样,实测 1.7~3.5ms/帧)。"""
604
+ base = _bg(th)
605
+ if s <= 1.0008:
606
+ return base.copy()
607
+ bw, bh = W / s, H / s
608
+ x0 = (W - bw) * 0.5
609
+ y0 = (H - bh) * 0.5
610
+ return base.transform((W, H), Image.EXTENT, (x0, y0, x0 + bw, y0 + bh), Image.NEAREST)
611
+
612
+
613
+ # ---------------- 版式几何(全部从 ST.LAYOUTS 取) ----------------
614
+
615
+ def _title_anchor(lay):
616
+ """标题锚点 + 对齐方式:left-stack 左对齐 / split-right 右对齐 / center-stack 居中。"""
617
+ if lay.get("center"):
618
+ return W * 0.5, "center"
619
+ if lay.get("title_x") is None:
620
+ return float(W - 150), "right"
621
+ return float(lay["title_x"]), "left"
622
+
623
+
624
+ def _panel_x(lay):
625
+ if lay.get("panel_x") is None:
626
+ return (W - float(lay["panel_w"])) * 0.5
627
+ return float(lay["panel_x"])
628
+
629
+
630
+ def _panel_boxes(lay, th, n, y_floor, v):
631
+ """条目卡片布局:选列数 + 压缩卡片高度,保证①不压标题②不越字幕带(856)。
632
+
633
+ panel_gap 在 stylekit 里是**行距(pitch)**不是间隙:v0.4 用 240 + i*118 排卡片,
634
+ 所以 pitch 至少得比卡片高度多 6px,否则卡片会叠在一起。条目多时先加列,再压高度,
635
+ 最后才截断——静默重叠或画到字幕带下面比少画两条更糟。
636
+ """
637
+ if n <= 0:
638
+ return []
639
+ px = _panel_x(lay)
640
+ pw = float(lay["panel_w"])
641
+ base_pitch = max(float(lay["panel_gap"]), float(lay["panel_h"]) + 6.0)
642
+ ch_base = float(lay["panel_h"])
643
+ y0 = max(float(lay["panel_y"]), float(y_floor))
644
+ ylim = v.cy + (856.0 - v.cy) / max(0.2, v.s)
645
+ avail = max(80.0, ylim - y0)
646
+ if lay.get("center"):
647
+ cand = (1, 2) if n <= 2 else ((2, 3) if n <= 6 else (3, 2))
648
+ else:
649
+ cand = (1, 2) if n <= 5 else ((2, 1) if n <= 10 else (2,))
650
+ chosen = None
651
+ for cols in cand:
652
+ rows = int(math.ceil(n / float(cols)))
653
+ for ch in (ch_base, ch_base * 0.82, 54.0):
654
+ pitch = min(base_pitch, (avail - ch) / max(1, rows - 1)) if rows > 1 else base_pitch
655
+ if pitch < ch + 6.0:
656
+ continue
657
+ if (rows - 1) * pitch + ch <= avail + 0.5:
658
+ chosen = (cols, rows, ch, pitch)
659
+ break
660
+ if chosen:
661
+ break
662
+ if chosen is None:
663
+ cols = cand[-1]
664
+ ch, pitch = 54.0, 60.0
665
+ rows = int(max(1, (avail + 6.0) // pitch))
666
+ chosen = (cols, rows, ch, pitch)
667
+ cols, rows, ch, pitch = chosen
668
+ cap = rows * cols
669
+ if n > cap:
670
+ _warn_once(("panel", lay.get("layout"), n),
671
+ "本段有 %d 个条目,版式只放得下 %d 个(其余已省略);"
672
+ "请拆成多段或减少 items" % (n, cap))
673
+ gx = 40.0 if cols > 1 else 0.0
674
+ cw = (pw - gx * (cols - 1)) / cols
675
+ boxes = []
676
+ for i in range(min(n, cap)):
677
+ r, c = divmod(i, cols)
678
+ boxes.append((px + c * (cw + gx), y0 + r * pitch, cw, ch))
679
+ return boxes
680
+
681
+
682
+ # ---------------- 文字绘制(位置过视差,字号不重采样) ----------------
683
+
684
+ def _draw_text(img, th, v, text, x, y, size, weight, color, align="left",
685
+ alpha=1.0, tracking=0.0):
686
+ text = "" if text is None else str(text)
687
+ if not text or alpha <= 0.004:
688
+ return
689
+ size = int(size)
690
+ px = v.x(x)
691
+ py = v.y(y)
692
+ font = _font(th, size, weight)
693
+ fill = _rgba(color, 255.0 * clamp(alpha))
694
+ d = ImageDraw.Draw(img, "RGBA")
695
+ tr = float(tracking or 0.0)
696
+ if tr > 0.3 and len(text) > 1:
697
+ total = _text_w(text, size, weight, th) + tr * (len(text) - 1)
698
+ if align == "center":
699
+ px -= int(total * 0.5)
700
+ elif align == "right":
701
+ px -= int(total)
702
+ for ch in text:
703
+ d.text((px, py), ch, font=font, fill=fill)
704
+ px += _text_w(ch, size, weight, th) + tr
705
+ return
706
+ w = _text_w(text, size, weight, th)
707
+ if align == "center":
708
+ px -= int(w * 0.5)
709
+ elif align == "right":
710
+ px -= int(w)
711
+ d.text((px, py), text, font=font, fill=fill)
712
+
713
+
714
+ def _entrance_x(th, p, align):
715
+ dx = float(th.get("entrance_dx") or 0)
716
+ if align == "right":
717
+ return -dx * (1.0 - p)
718
+ if align == "center":
719
+ return 0.0
720
+ return dx * (1.0 - p)
721
+
722
+
723
+ def _entrance_y(th, p):
724
+ return float(th.get("entrance_dy") or 0) * (1.0 - p)
725
+
726
+
727
+ # ============================================================================
728
+ # 二、HUD(三档:chip-left / corner-line / none)
729
+ # ============================================================================
730
+
731
+ def _hud(img, th, chapter, progress, title):
732
+ style = str(th.get("hud") or "chip-left")
733
+ if style == "none":
734
+ return img
735
+ accent = th["accent"]
736
+ rule = th["rule"]
737
+ ink = th["ink"]
738
+ prog = clamp(progress)
739
+ d = ImageDraw.Draw(img, "RGBA")
740
+ if style == "chip-left":
741
+ d.rectangle([0, 0, W, 3], fill=_rgba(rule, 14))
742
+ d.rectangle([0, 0, int(W * prog), 3], fill=_rgba(accent, 255))
743
+ cs = int(th["scale"]["chip"][0])
744
+ tw = _text_w(chapter, cs, "reg", th)
745
+ pad_l, pad_r = 16, 18
746
+ h = int(cs * 1.95)
747
+ x0, y0 = 64, 42
748
+ x1 = x0 + pad_l + tw + pad_r
749
+ fillc = _mix(th["surface"], th["bg"], 0.55)
750
+ d.rounded_rectangle([x0, y0, x1, y0 + h], radius=h // 2,
751
+ fill=_rgba(fillc, 168), outline=_rgba(rule, 36))
752
+ d.ellipse([x0 + 16, y0 + h // 2 - 5, x0 + 26, y0 + h // 2 + 5],
753
+ fill=_rgba(accent, 255))
754
+ d.text((x0 + 34, y0 + (h - cs) // 2 - 3), chapter,
755
+ font=_font(th, cs, "reg"), fill=_rgba(ink, 228))
756
+ bs = int(th["scale"]["brand"][0])
757
+ tw2 = _text_w(title, bs, "reg", th)
758
+ d.text((W - 64 - tw2, 56), title, font=_font(th, bs, "reg"),
759
+ fill=_rgba(_mix(ink, th["bg"], 0.62), 220))
760
+ return img
761
+ # corner-line:只有角标与细线,没有胶囊底
762
+ d.rectangle([0, 0, W, 3], fill=_rgba(rule, 10))
763
+ d.rectangle([0, 0, int(W * prog), 2], fill=_rgba(accent, 214))
764
+ d.line([64, 44, 64, 96], fill=_rgba(accent, 190), width=2)
765
+ d.line([64, 44, 116, 44], fill=_rgba(accent, 190), width=2)
766
+ cs = int(th["scale"]["chip"][0])
767
+ d.text((84, 54), chapter, font=_font(th, cs, "reg"), fill=_rgba(ink, 220))
768
+ bs = int(th["scale"]["brand"][0])
769
+ tw = _text_w(title, bs, "reg", th)
770
+ d.text((W - 64 - tw, 56), title, font=_font(th, bs, "reg"), fill=_rgba(ink, 120))
771
+ d.line([W - 64, 44, W - 64 - 96, 44], fill=_rgba(accent, 120), width=2)
772
+ d.line([W - 64, 44, W - 64, 96], fill=_rgba(accent, 120), width=2)
773
+ return img
774
+
775
+
776
+ # ============================================================================
777
+ # 三、场景语法(v0.9:10 类构图 × 8 套视觉语言)
778
+ # ============================================================================
779
+ #
780
+ # 为什么重写这一节
781
+ # ---------------
782
+ # v0.5 的 _draw_hook / _draw_body / _draw_outro 是"一套骨架 + 换色":背景板 → 眉标 →
783
+ # 大字 → 副标题 → 强调线 → HUD,三种 kind 只差字号与位置。于是 8 套风格换完只剩配色不同。
784
+ # v0.9 把"画什么"(scene type,由 grammar.py 按内容选)与"怎么画"(视觉语言,由 stylekit
785
+ # 的 VISUAL_LANGUAGES 给)拆开:
786
+ # · 10 类构图各自有独立的视觉重心与留白(hero 偏左上 / statement 正中 / compare 强制对称 …)
787
+ # · 每套风格有自己的装饰母题(括角 / 手绘内框 / letterbox / 信号条 …)与转场签名
788
+ # · 片内还有 variant 轮换,避免"每屏同一个骨架"
789
+ #
790
+ # 性能纪律(这一节最容易把 25ms 预算吃光)
791
+ # · 装饰母题全部**预烘焙**成一张 RGBA 图层,逐帧只 paste(~0.3ms)
792
+ # · 文字用 _draw_text(字号不重采样,只过视差坐标)
793
+ # · 禁每帧全屏 GaussianBlur(30–60ms)/ 全屏双线性 resize(29ms)/ np.array 往返(8–11ms)
794
+ # · 段界 veil 只在段首 ≤0.45s 内生效,用 base plate 直接 paste/blend,不新增模糊
795
+
796
+ # ---------------- 视觉语言 / 场景字段 ----------------
797
+
798
+ _DEFAULT_LANG = {
799
+ "motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.04, "breathe_hz": 0.85},
800
+ "motif": {"kind": "minimal", "density": 0.0, "detail": {}},
801
+ "transition": {"kind": "cut", "dur": 0.0},
802
+ "typography": {"tracking": 0.0, "line_gap": 1.30, "align": "left",
803
+ "number": "dim", "ratio": 1.50},
804
+ "layout_cycle": ("left-stack",),
805
+ }
806
+
807
+ _DECOR_LEVELS = (0.55, 0.75, 1.0)
808
+
809
+
810
+ def _lang(plan, th):
811
+ """本片的视觉语言包。plan["language"] 由 stylekit 解析时塞进来;没有就按主题 id 查。"""
812
+ got = (plan or {}).get("language")
813
+ if isinstance(got, dict):
814
+ return got
815
+ try:
816
+ return ST.visual_language(str((th or {}).get("id") or "tech-deep"))
817
+ except Exception: # noqa: BLE001 - 老 stylekit 没有这一层
818
+ return _DEFAULT_LANG
819
+
820
+
821
+ def _scene_type(scene):
822
+ """本段用哪个绘制器:优先 timeline 里 grammar 写好的 scene_type,其次按 kind 映射。"""
823
+ t = str(scene.get("scene_type") or "").strip()
824
+ if t:
825
+ return t
826
+ kind = str(scene.get("kind") or "body")
827
+ return {"hook": "hero", "outro": "outro", "quote": "quote",
828
+ "emphasis": "statement", "calm": "statement"}.get(kind, "list")
829
+
830
+
831
+ def _decor_scale(scene, lang):
832
+ """装饰强度 = 片内密度档(0/1/2 -> 0.55/0.75/1.0)× 该风格母题密度。"""
833
+ try:
834
+ lvl = int(scene.get("scene_decor") or 0)
835
+ except (TypeError, ValueError, OverflowError):
836
+ lvl = 0
837
+ lvl = max(0, min(2, lvl))
838
+ base = _DECOR_LEVELS[lvl]
839
+ dens = float((lang.get("motif") or {}).get("density") or 1.0)
840
+ return base * dens
841
+
842
+
843
+ def _progress(tg, script):
844
+ """全片进度(0..1),HUD 进度条用。_total 由 render.py 注入(st_frames/st_stills)。"""
845
+ return clamp(float(tg) / max(0.1, float(script.get("_total") or 1.0)))
846
+
847
+
848
+ def _phase(scene):
849
+ """段相位(弧度)。NaN/Inf 一律当 0 —— 它们会让 int(round()) 直接抛
850
+ ValueError: cannot convert float NaN to integer,而那是在渲染第 N 帧才炸。"""
851
+ try:
852
+ v = float(scene.get("scene_phase") or 0.0)
853
+ except (TypeError, ValueError):
854
+ return 0.0
855
+ if v != v or v in (float("inf"), float("-inf")): # NaN / ±Inf
856
+ return 0.0
857
+ return v
858
+
859
+
860
+ def _variant(scene):
861
+ try:
862
+ return int(scene.get("scene_variant") or 0) % 3
863
+ except (TypeError, ValueError, OverflowError):
864
+ return 0
865
+
866
+
867
+ def _scene_layout(scene, th):
868
+ """本段版式:grammar 按 chapter 写好的 scene_layout 优先,其次主题默认。"""
869
+ name = str(scene.get("scene_layout") or th.get("layout") or "left-stack")
870
+ return ST.LAYOUTS.get(name) or ST.LAYOUTS["left-stack"]
871
+
872
+
873
+ def _typo(lang):
874
+ return lang.get("typography") or _DEFAULT_LANG["typography"]
875
+
876
+
877
+ def _bias(lang):
878
+ """该风格的整体重心偏移 (dx, dy, inset),单位 px。
879
+
880
+ 为什么需要它:剪影判据里 Cx/Cy 是"前景质心",而"同样左对齐、同样字号"的两套浅色主题
881
+ (paper / lo-fi)即使装饰不同,质心也几乎重合——实测这对 ΔCx 只有 2.3pp。
882
+ 把"这套风格的画面重心往哪偏"做成数据,是最便宜、也最像"导演习惯"的差异化手段。
883
+ """
884
+ b = (lang or {}).get("bias") or {}
885
+ try:
886
+ return (float(b.get("x") or 0.0), float(b.get("y") or 0.0), float(b.get("inset") or 0.0))
887
+ except (TypeError, ValueError):
888
+ return (0.0, 0.0, 0.0)
889
+
890
+
891
+ def _tracking(th, lang):
892
+ return float(th.get("tracking") or 0.0) + float(_typo(lang).get("tracking") or 0.0)
893
+
894
+
895
+ def _gap(th, lang):
896
+ return float(th.get("line_gap") or _typo(lang).get("line_gap") or 1.3)
897
+
898
+
899
+ # ---------------- 装饰母题(一次烘焙,逐帧只 paste) ----------------
900
+
901
+ def _motif_key(th, lang):
902
+ """母题图层/底版的缓存键(主题 + 母题 + 密度 + 参与绘制的颜色)。
903
+
904
+ 单独抽出来是为了让 _motif_layer(RGBA 图层)与 _plate(合成后的底版)用同一把钥匙:
905
+ 两者必须一起失效,否则改了主题色会拿到"新图层 + 旧底版"的错配画面。
906
+ """
907
+ mf = lang.get("motif") or _DEFAULT_LANG["motif"]
908
+ return _hashable(("motif", th.get("id"), mf.get("kind"),
909
+ tuple(sorted((str(k), _hashable(v)) for k, v in (mf.get("detail") or {}).items())),
910
+ round(float(mf.get("density") or 0.0), 3),
911
+ th["accent"], th["rule"], th["bg"], th["bg2"]))
912
+
913
+
914
+ def _motif_layer(th, lang):
915
+ """把该风格独有的装饰图形烘焙成一张全屏 RGBA 图层(不含动态)。
916
+
917
+ 为什么烘焙:这些图形是静态的(括角、页眉线、letterbox、信号条),
918
+ 逐帧重画一次就是 3–8ms。注意:v0.9.0 这里写的是"paste 只要 0.3ms",
919
+ 实测全屏 RGBA paste 是 **5.6ms**(Pillow 13 / 1080p),所以 v0.9.1 起
920
+ 装饰直接烘焙进底版(见 _plate),逐帧只 copy 底版,不再单独 paste。
921
+ """
922
+ mf = lang.get("motif") or _DEFAULT_LANG["motif"]
923
+ key = _motif_key(th, lang)
924
+ got = _CACHE.get(key)
925
+ if got is not None:
926
+ return got
927
+ kind = str(mf.get("kind") or "minimal")
928
+ det = mf.get("detail") or {}
929
+ dens = float(mf.get("density") or 0.0)
930
+ lay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
931
+ d = ImageDraw.Draw(lay, "RGBA")
932
+ accent = th["accent"]
933
+ rule = th["rule"]
934
+ ink = th["ink"]
935
+ dark = ST.is_dark(th)
936
+
937
+ def a(v):
938
+ return int(max(0, min(255, round(v))))
939
+
940
+ if kind == "brackets":
941
+ # 四角 L 形括角 + 右侧数据刻度尺:科技风的"仪器感"
942
+ c = int(det.get("corner") or 24)
943
+ t = 2
944
+ for (x, y, dx, dy) in ((48, 40, 1, 1), (W - 48, 40, -1, 1),
945
+ (48, H - 48, 1, -1), (W - 48, H - 48, -1, -1)):
946
+ d.line([(x, y), (x + dx * c, y)], fill=_rgba(accent, a(150 * dens)), width=t)
947
+ d.line([(x, y), (x, y + dy * c)], fill=_rgba(accent, a(150 * dens)), width=t)
948
+ step = int(det.get("rule_step") or 120)
949
+ for i, yy in enumerate(range(150, 860, step)):
950
+ long = (i % 3 == 0)
951
+ d.line([(W - 34, yy), (W - 34 + (18 if long else 10), yy)],
952
+ fill=_rgba(rule, a(70 * dens)), width=1)
953
+ if det.get("scan"):
954
+ d.rectangle([0, 120, W, 124], fill=_rgba(rule, a(18 * dens)))
955
+ elif kind == "hairlines":
956
+ # 杂志:双横线页眉 + 栏间细线 + 右上页码位
957
+ d.rectangle([64, 112, W - 64, 114], fill=_rgba(rule, a(120 * dens)))
958
+ d.rectangle([64, 118, W - 64, 119], fill=_rgba(rule, a(60 * dens)))
959
+ if det.get("column_rule"):
960
+ d.rectangle([W // 2, 160, W // 2 + 1, 820], fill=_rgba(rule, a(26 * dens)))
961
+ d.rectangle([64, 838, W - 64, 839], fill=_rgba(rule, a(70 * dens)))
962
+ elif kind == "handdrawn":
963
+ # 纸感:不规则内框(vnoise 抖动)+ 四角胶带 + 方印
964
+ wob = float(det.get("wobble") or 3.0)
965
+ # 底边收在 1016:字幕带从 872 开始,外框若画到 1024 会被字幕底板压住一截,
966
+ # 看起来像"框没画完"。
967
+ x0, y0, x1, y1 = 56, 52, W - 56, 1016
968
+ n = 10
969
+ pts = []
970
+ for i in range(n): # 上边
971
+ pts.append((x0 + (x1 - x0) * i / (n - 1.0), y0 + wob * (K.vnoise(i * 0.7, 5) - 0.5)))
972
+ for i in range(n): # 右边
973
+ pts.append((x1 + wob * (K.vnoise(i * 0.9, 7) - 0.5), y0 + (y1 - y0) * i / (n - 1.0)))
974
+ for i in range(n): # 下边
975
+ pts.append((x1 - (x1 - x0) * i / (n - 1.0), y1 + wob * (K.vnoise(i * 0.6, 9) - 0.5)))
976
+ for i in range(n): # 左边
977
+ pts.append((x0 + wob * (K.vnoise(i * 0.8, 11) - 0.5), y1 - (y1 - y0) * i / (n - 1.0)))
978
+ d.line(pts + [pts[0]], fill=_rgba(rule, a(120 * dens)), width=2, joint="curve")
979
+ if det.get("tape"):
980
+ for (tx, ty, ww, hh) in ((44, 40, 96, 26), (W - 140, 40, 96, 26)):
981
+ d.polygon([(tx, ty + 4), (tx + ww, ty), (tx + ww, ty + hh), (tx, ty + hh + 4)],
982
+ fill=_rgba(_mix(ink, th["bg"], 0.72), a(70 * dens)))
983
+ if det.get("seal"):
984
+ sx, sy = W - 150, 128
985
+ d.rectangle([sx, sy, sx + 58, sy + 58], outline=_rgba(th["accent2"], a(150 * dens)),
986
+ width=3)
987
+ elif kind == "slabs":
988
+ # 撞色:粗色条横贯 + 斜切块 + 圆形贴纸
989
+ bh = int(det.get("bar_h") or 24)
990
+ d.rectangle([0, 118, W, 118 + bh], fill=_rgba(accent, a(200 * dens)))
991
+ sl = int(det.get("slant") or 18)
992
+ d.polygon([(0, 118 + bh), (W, 118 + bh), (W, 118 + bh + sl), (0, 118 + bh + sl + 8)],
993
+ fill=_rgba(th["accent2"], a(120 * dens)))
994
+ d.rectangle([0, 846, W, 852], fill=_rgba(accent, a(140 * dens)))
995
+ if det.get("sticker"):
996
+ for (cx, cy, r) in ((96, 196, 34), (W - 96, 196, 22)):
997
+ d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=_rgba(accent, a(190 * dens)),
998
+ width=8)
999
+ elif kind == "minimal":
1000
+ # 极简:只有一条 2px 横线(其余全是留白,这就是它的"装饰语言")
1001
+ d.rectangle([W // 2 - 600, 700, W // 2 + 600, 702], fill=_rgba(rule, a(200 * dens)))
1002
+ d.rectangle([W // 2 - 120, 742, W // 2 + 120, 743], fill=_rgba(rule, a(90 * dens)))
1003
+ elif kind == "letterbox":
1004
+ # 电影:上下遮幅(画幅外一律黑),内缘一条 1px 亮线
1005
+ top = int(det.get("top") or 40)
1006
+ bot = int(det.get("bottom") or 44)
1007
+ d.rectangle([0, 0, W, top], fill=(0, 0, 0, 255))
1008
+ d.rectangle([0, H - bot, W, H], fill=(0, 0, 0, 255))
1009
+ d.rectangle([0, top, W, top + 1], fill=_rgba(rule, a(40 * dens)))
1010
+ d.rectangle([0, H - bot - 1, W, H - bot], fill=_rgba(rule, a(40 * dens)))
1011
+ if det.get("flares"):
1012
+ img = Image.fromarray(np.zeros((2, 2, 4), dtype=np.uint8), "RGBA")
1013
+ elif kind == "signal":
1014
+ # lo-fi:底部 12 格信号条 + 时间戳位
1015
+ bars = int(det.get("bars") or 12)
1016
+ bw, gap = 10, 6
1017
+ total = bars * bw + (bars - 1) * gap
1018
+ x0 = W - 64 - total
1019
+ y0 = 806
1020
+ for i in range(bars):
1021
+ on = i < bars - 2
1022
+ d.rectangle([x0 + i * (bw + gap), y0, x0 + i * (bw + gap) + bw, y0 + 14],
1023
+ fill=_rgba(accent if on else rule, a((170 if on else 40) * dens)))
1024
+ elif kind == "rays":
1025
+ # 史诗:上下双装饰线 + 18 条放射线 + 内细框
1026
+ d.rectangle([0, 104, W, 106], fill=_rgba(accent, a(150 * dens)))
1027
+ d.rectangle([0, 110, W, 111], fill=_rgba(accent, a(60 * dens)))
1028
+ d.rectangle([0, H - 112, W, H - 110], fill=_rgba(accent, a(150 * dens)))
1029
+ d.rectangle([0, H - 106, W, H - 105], fill=_rgba(accent, a(60 * dens)))
1030
+ cx, cy = W * 0.5, 470.0
1031
+ for i in range(int(det.get("rays") or 18)):
1032
+ ang = (i / 18.0) * 2.0 * math.pi
1033
+ r0, r1 = 250.0, 430.0 + 40.0 * K.hash2(i, 3)
1034
+ d.line([(cx + math.cos(ang) * r0, cy + math.sin(ang) * r0 * 0.62),
1035
+ (cx + math.cos(ang) * r1, cy + math.sin(ang) * r1 * 0.62)],
1036
+ fill=_rgba(accent, a(26 * dens)), width=1)
1037
+ if det.get("frame"):
1038
+ d.rectangle([52, 96, W - 52, 1016], outline=_rgba(accent, a(50 * dens)), width=1)
1039
+ _CACHE[key] = lay
1040
+ return lay
1041
+
1042
+
1043
+ _PLATE_ORDER = []
1044
+
1045
+
1046
+ def _plate(th, lang, scene, s_bg):
1047
+ """背景板 + 装饰母题合成后的整帧底版(v0.9.1)。
1048
+
1049
+ 这是单帧预算里最大的一处优化:装饰是静态的,逐帧单独 paste 一张全屏 RGBA 要 5.6ms,
1050
+ 合成进底版后逐帧只剩 copy(1.3ms)或 EXTENT 缩放(1.7–2.0ms)。
1051
+ 顺带一个好处:镜头缓推现在会把装饰一起推——装饰本来就该跟着镜头走,
1052
+ 以前"背景推、装饰不动"在强 push 的风格(cinema 0.12 / epic 0.14)里其实是穿帮。
1053
+
1054
+ 缓存:一个片子只用一套主题 + 一套母题,密度档最多 3 档 -> 最多 3 张底版(约 18MB)。
1055
+ 用 LRU 限 4 张,防多片/多主题同进程时无限增长。
1056
+ """
1057
+ base = _bg(th)
1058
+ if _decor_scale(scene, lang) <= 0.02:
1059
+ if s_bg <= 1.0008:
1060
+ return base.copy()
1061
+ return _bg_plate(th, s_bg)
1062
+ key = _hashable(("plate", _motif_key(th, lang),
1063
+ int(max(0, min(2, int(scene.get("scene_decor") or 0))))))
1064
+ got = _CACHE.get(key)
1065
+ if got is None:
1066
+ lay = _motif_layer(th, lang)
1067
+ got = base.copy()
1068
+ got.paste(lay, (0, 0), lay)
1069
+ _CACHE[key] = got
1070
+ _PLATE_ORDER.append(key)
1071
+ while len(_PLATE_ORDER) > 4: # LRU:只留最近 4 张
1072
+ old = _PLATE_ORDER.pop(0)
1073
+ if old != key:
1074
+ _CACHE.pop(old, None)
1075
+ if s_bg <= 1.0008:
1076
+ return got.copy()
1077
+ bw, bh = W / s_bg, H / s_bg
1078
+ x0 = (W - bw) * 0.5
1079
+ y0 = (H - bh) * 0.5
1080
+ return got.transform((W, H), Image.EXTENT, (x0, y0, x0 + bw, y0 + bh), Image.NEAREST)
1081
+
1082
+
1083
+ def _apply_motif(img, th, lang, scene):
1084
+ """逐帧贴装饰层(0.3ms)。密度为 0 时直接跳过。"""
1085
+ if _decor_scale(scene, lang) <= 0.02:
1086
+ return img
1087
+ lay = _motif_layer(th, lang)
1088
+ img.paste(lay, (0, 0), lay)
1089
+ return img
1090
+
1091
+
1092
+ # ---------------- 段界转场(veil:只在段首 ≤0.45s 内生效) ----------------
1093
+
1094
+ def _apply_veil(img, th, lang, scene, t_local, s_bg):
1095
+ """段首从背景"长出来"的过场。不做跨场景 cross-dissolve(draw_frame 拿不到上一段帧),
1096
+ 只做"从本段背景板渐显 / 擦入",观感是 dip 而不是 dissolve。
1097
+
1098
+ 为什么必须做:v0.9 起相邻两段可能是完全不同的构图(数字卡 → 左右对比),
1099
+ 整屏瞬切会精确触发 qa_frames 的单帧闪烁门禁(8x8 分块灰度差 ≥6.0),
1100
+ 观众看着也像"换了一部片"。
1101
+ """
1102
+ tr = lang.get("transition") or _DEFAULT_LANG["transition"]
1103
+ kind = str(tr.get("kind") or "cut")
1104
+ dur = float(tr.get("dur") or 0.0)
1105
+ if kind == "cut" or dur <= 0.01 or t_local >= dur:
1106
+ return img
1107
+ p = clamp(t_local / dur)
1108
+ base = _plate(th, lang, scene, s_bg)
1109
+ if kind in ("dip", "wash"):
1110
+ # dip/wash 的差别是"淡向哪一层":wash 淡向主题底色(浅色主题不出现黑场),
1111
+ # dip 也淡向 bg2 —— 刻意不淡向纯黑,否则暗色主题会撞黑帧门禁(平均亮度 <0.045)。
1112
+ return Image.blend(base, img, float(p))
1113
+ # 擦入类:用背景板盖住"还没露出来"的部分(paste 比 mask 便宜)
1114
+ if kind == "wipe": # 自左向右
1115
+ cut = int(round(W * float(p)))
1116
+ if cut < W:
1117
+ img.paste(base.crop((cut, 0, W, H)), (cut, 0))
1118
+ return img
1119
+ if kind == "paper": # 自右向左(纸卷)
1120
+ cut = int(round(W * (1.0 - float(p))))
1121
+ if cut > 0:
1122
+ img.paste(base.crop((0, 0, cut, H)), (0, 0))
1123
+ return img
1124
+ if kind == "iris": # 中心向两侧展开
1125
+ half = int(round(W * 0.5 * float(p)))
1126
+ if half < W // 2:
1127
+ img.paste(base.crop((0, 0, W // 2 - half, H)), (0, 0))
1128
+ img.paste(base.crop((W // 2 + half, 0, W, H)), (W // 2 + half, 0))
1129
+ return img
1130
+ return img
1131
+
1132
+
1133
+ # ---------------- 通用:数字(等宽)+ 编号写法 ----------------
1134
+
1135
+ _NUM_WEIGHT = "mono"
1136
+
1137
+
1138
+ def _draw_num(img, th, v, text, x, y, size, color, align="center", alpha=1.0):
1139
+ """数字用等宽字体:滚动/跳变时字宽不抖(consola 只有 ASCII,中文标签另用 reg)。"""
1140
+ _draw_text(img, th, v, text, x, y, size, _NUM_WEIGHT, color, align, alpha)
1141
+
1142
+
1143
+ def _chapter_index(chapter):
1144
+ """从 "03 · 画质关键" 里取 3;取不到返回 None(章节卡要用巨型编号)。"""
1145
+ s = str(chapter or "")
1146
+ for i in range(len(s) - 1):
1147
+ if s[i].isdigit() and s[i + 1].isdigit():
1148
+ return int(s[i:i + 2])
1149
+ for ch in s:
1150
+ if ch.isdigit():
1151
+ return int(ch)
1152
+ return None
1153
+
1154
+
1155
+ _CN_NUM = "零一二三四五六七八九十"
1156
+
1157
+
1158
+ def _number_text(style, n, fallback=""):
1159
+ """按视觉语言的编号写法生成编号文本(dim/serif/cn/block/roman/timecode/dot/gilt)。"""
1160
+ if n is None:
1161
+ return fallback
1162
+ if style in ("dim", "serif", "gilt"):
1163
+ return "%02d" % n
1164
+ if style == "cn":
1165
+ return _CN_NUM[n] + "、" if 0 < n < 11 else "%02d" % n
1166
+ if style == "block":
1167
+ return "%02d" % n
1168
+ if style == "roman":
1169
+ rom = ("I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X")
1170
+ return rom[n - 1] if 0 < n <= 10 else "%02d" % n
1171
+ if style == "timecode":
1172
+ return "00:%02d" % (n * 4 % 60)
1173
+ if style == "dot":
1174
+ return "· %02d" % n
1175
+ return "%02d" % n
1176
+
1177
+
1178
+ # ---------------- 1) hero 封面 ----------------
1179
+
1180
+ def _draw_hero(scene, t, dur, tg, script, frame, plan, th):
1181
+ lang = _lang(plan, th)
1182
+ s_bg = _push_scale(th, t, dur)
1183
+ v = _view(th, s_bg)
1184
+ img = _plate(th, lang, scene, s_bg)
1185
+ lay = _scene_layout(scene, th)
1186
+ var = _variant(scene)
1187
+ ease = _ease((lang.get("motion") or {}).get("ease") or th.get("ease") or "out3")
1188
+ trk = _tracking(th, lang)
1189
+ gap = _gap(th, lang)
1190
+ ph = _phase(scene)
1191
+
1192
+ # 重心:variant 决定标题落在左/中/右 —— 这是"换版式"最便宜也最明显的一种;
1193
+ # 再叠上该风格的 bias(比如 paper 整体压左下、lo-fi 整体偏右上)。
1194
+ bdx, bdy, bins = _bias(lang)
1195
+ if lay.get("center") or var == 2:
1196
+ ax, align = W * 0.5 + bdx, "center"
1197
+ elif var == 1:
1198
+ ax, align = float(W - 150) + bdx, "right"
1199
+ else:
1200
+ ax, align = 150.0 + bdx + bins, "left"
1201
+
1202
+
1203
+ # 持续动态:背景里缓慢扩散的同心弧(每套风格都在动,但颜色/透明度随主题)
1204
+ p_ring = smooth(rng(t, 0.15, 2.4))
1205
+ d = ImageDraw.Draw(img, "RGBA")
1206
+ cx, cy = (ax, 470.0) if align != "center" else (W * 0.5, 430.0)
1207
+ for i in range(3):
1208
+ rr = (120.0 + 260.0 * i) * (0.4 + 0.6 * p_ring)
1209
+ d.arc([v.x(cx - rr), v.y(cy - rr * 0.62), v.x(cx + rr), v.y(cy + rr * 0.62)],
1210
+ start=int(200 + i * 40), end=int(340 + i * 40),
1211
+ fill=_rgba(th["accent"], int(46 * p_ring)), width=2)
1212
+
1213
+ size = int(th["scale"]["display"][0])
1214
+ big = scene.get("big") or script.get("title") or ""
1215
+ max_w = min(1560.0, float(W - 300))
1216
+ lines = _wrap(big, size, max_w, "bold", th)
1217
+ while len(lines) > 3 and size > 42:
1218
+ size = int(size * 0.88)
1219
+ lines = _wrap(big, size, max_w, "bold", th)
1220
+ lh = int(size * gap)
1221
+ ksize = int(th["scale"]["kicker"][0])
1222
+ kicker = scene.get("kicker") or "00 · 开场"
1223
+ y = float(lay["title_y"]) + bdy
1224
+
1225
+ # kicker 的纵向位置必须**跟着标题字号走**,不能直接用布局表里的固定 y。
1226
+ # 为什么:布局表给的是 left-stack 的 title_y=300 / kicker_y=250,只差 50px;
1227
+ # 而 hero 的标题字号是 96px(display 档),字形往上顶穿 kicker —— 实测封面上
1228
+ # "00 · 开场"直接压在"价值与代价来自同一个选择"中间。
1229
+ # 按字号算间距后,换字体/换字号档(visual 语言包会改 ratio)都不会再压。
1230
+ _ky = min(float(lay["kicker_y"]), y - size * 1.05 - ksize * 0.35)
1231
+ p0 = ease(rng(t, 0.05, 0.85))
1232
+ _draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
1233
+ _ky + _entrance_y(th, p0), ksize, "bold",
1234
+ th["accent"], align, p0, trk * 0.5)
1235
+
1236
+ for i, ln in enumerate(lines):
1237
+ p = ease(rng(t, 0.12 + 0.13 * i, 0.88 + 0.13 * i))
1238
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p, align),
1239
+ y + i * lh + _entrance_y(th, p), size, "bold",
1240
+ th["ink"], align, p, trk)
1241
+
1242
+ sub = scene.get("lead") or scene.get("sub") or ""
1243
+ # 副标题与大字重复时不要画第二遍:模板片里 big 与 sub 都是"零素材,全代码生成",
1244
+ # 照抄会让画面出现两行一样的字(v0.9 实测第一版就是这样)。
1245
+ if sub and (str(sub).strip() == str(big).strip()
1246
+ or (len(str(sub)) >= 6 and str(sub).strip() in str(big).strip())):
1247
+ sub = ""
1248
+ sub_bottom = y + len(lines) * lh
1249
+ if sub:
1250
+ ssize = int(th["scale"]["sub"][0])
1251
+ sy = sub_bottom + int(size * 0.24)
1252
+ p2 = ease(rng(t, 0.30, 1.40))
1253
+ for j, ln in enumerate(_wrap(sub, ssize, float(lay["sub_w"]), "light", th)[:3]):
1254
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p2, align),
1255
+ sy + j * int(ssize * gap), ssize, "light", th["muted"], align, p2)
1256
+ sub_bottom = sy + int(ssize * gap)
1257
+
1258
+ # 持续动态:标题下呼吸强调线(相位随段偏移,避免每段同频"复印")
1259
+ hz = float((lang.get("motion") or {}).get("breathe_hz") or 0.85)
1260
+ amp = 0.45 + 0.55 * (0.5 + 0.5 * math.sin(hz * t + ph))
1261
+ line_w = int(min(float(lay["title_w"]), 760) * 0.55 * amp)
1262
+ line_y = sub_bottom + int(size * 0.42)
1263
+ if line_y < 856:
1264
+ lx = ax if align == "left" else (ax - line_w if align == "right" else ax - line_w // 2)
1265
+ ImageDraw.Draw(img, "RGBA").line(
1266
+ [v.x(lx), v.y(line_y), v.x(lx + line_w), v.y(line_y)],
1267
+ fill=_rgba(th["accent"], 190), width=3)
1268
+
1269
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1270
+ return _hud(img, th, scene.get("chapter") or "01 · 开场", _progress(tg, script),
1271
+ script.get("title", ""))
1272
+
1273
+
1274
+ # ---------------- 2) statement 金句(居中大字,无卡片) ----------------
1275
+
1276
+ def _draw_statement(scene, t, dur, tg, script, frame, plan, th):
1277
+ lang = _lang(plan, th)
1278
+ s_bg = _push_scale(th, t, dur)
1279
+ v = _view(th, s_bg)
1280
+ img = _plate(th, lang, scene, s_bg)
1281
+ var = _variant(scene)
1282
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1283
+ trk = _tracking(th, lang)
1284
+ gap = _gap(th, lang)
1285
+ ph = _phase(scene)
1286
+
1287
+ big = str(scene.get("big") or scene.get("text") or "")[:24]
1288
+ size = int(th["scale"]["display"][0] * 1.10)
1289
+ max_w = 1400.0 if var != 1 else 1180.0
1290
+ lines = _wrap(big, size, max_w, "bold", th)
1291
+ while len(lines) > 2 and size > 52:
1292
+ size = int(size * 0.90)
1293
+ lines = _wrap(big, size, max_w, "bold", th)
1294
+ lh = int(size * gap)
1295
+ top = 470.0 - (len(lines) - 1) * lh * 0.5
1296
+ if var == 1: # variant 1:整体上移,留出落款区
1297
+ top -= 40.0
1298
+
1299
+ # 逐字显现:4 字一组(单字一次 text 太贵;整行一次又没"写出来"的感觉)
1300
+ reveal = clamp(t / max(0.35, min(1.6, dur * 0.45)))
1301
+ total_chars = sum(len(x) for x in lines)
1302
+ shown = int(total_chars * ease(reveal))
1303
+ walked = 0
1304
+ for i, ln in enumerate(lines):
1305
+ if walked >= shown:
1306
+ break
1307
+ take = max(0, min(len(ln), shown - walked))
1308
+ walked += len(ln)
1309
+ seg = ln[:take]
1310
+ if not seg:
1311
+ continue
1312
+ w = _text_w(seg, size, "bold", th)
1313
+ x0 = (W - w) * 0.5
1314
+ _draw_text(img, th, v, seg, x0, top + i * lh, size, "bold", th["ink"],
1315
+ "left", 1.0, trk)
1316
+
1317
+ # 持续动态:字距呼吸 + 下划线从左生长(保证"3–6 秒内有可见变化")
1318
+ hz = float((lang.get("motion") or {}).get("breathe_hz") or 0.85)
1319
+ bre = 0.5 + 0.5 * math.sin(hz * 0.9 * t + ph)
1320
+ ul_w = int(280 * (0.7 + 0.3 * bre))
1321
+ ul_y = top + len(lines) * lh + int(size * 0.22)
1322
+ if ul_y < 856:
1323
+ d = ImageDraw.Draw(img, "RGBA")
1324
+ x0 = W * 0.5 - ul_w * 0.5
1325
+ d.line([v.x(x0), v.y(ul_y), v.x(x0 + ul_w), v.y(ul_y)],
1326
+ fill=_rgba(th["accent"], 170), width=3)
1327
+
1328
+ sub = scene.get("sub") or ""
1329
+ if sub and (str(sub).strip() == str(big).strip()
1330
+ or (len(str(sub)) >= 6 and str(sub).strip() in str(big).strip())):
1331
+ sub = "" # 与大字重复的落款不画(理由同 hero)
1332
+ if sub and var != 2:
1333
+ ssize = int(th["scale"]["sub"][0])
1334
+ p2 = ease(rng(t, 0.55, 1.55))
1335
+ for j, ln in enumerate(_wrap(sub, ssize, 1100, "light", th)[:2]):
1336
+ _draw_text(img, th, v, ln, W * 0.5,
1337
+ top + len(lines) * lh + int(size * 0.55) + j * int(ssize * gap),
1338
+ ssize, "light", th["muted"], "center", p2)
1339
+
1340
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1341
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1342
+ script.get("title", ""))
1343
+
1344
+
1345
+ # ---------------- 3) list 要点(左标题 + 右条目 / 全宽序号列) ----------------
1346
+
1347
+ def _draw_list(scene, t, dur, tg, script, frame, plan, th):
1348
+ lang = _lang(plan, th)
1349
+ s_bg = _push_scale(th, t, dur)
1350
+ v = _view(th, s_bg)
1351
+ img = _plate(th, lang, scene, s_bg)
1352
+ lay = _scene_layout(scene, th)
1353
+ var = _variant(scene)
1354
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1355
+ trk = _tracking(th, lang)
1356
+ gap = _gap(th, lang)
1357
+ ph = _phase(scene)
1358
+
1359
+ items = list(scene.get("items") or [])
1360
+ stagger = float((plan.get("pacing") or {}).get("item_stagger") or 0.32)
1361
+ nstyle = str(_typo(lang).get("number") or "dim")
1362
+
1363
+ # variant 0/2:左标题 + 右条目列;variant 1:全宽(标题在上,序号列在左)
1364
+ full = (var == 1) or lay.get("center")
1365
+ bdx, bdy, bins = _bias(lang)
1366
+ if full:
1367
+ ax, align = 150.0 + bdx + bins, "left"
1368
+ title_w = 1620.0
1369
+ panel_x, panel_w = 150.0 + bdx + bins, 1620.0
1370
+ panel_y = 400.0 + bdy
1371
+ else:
1372
+ ax, align = _title_anchor(lay)
1373
+ ax += bdx + (bins if align == "left" else 0.0)
1374
+ title_w = float(lay["title_w"])
1375
+ panel_x, panel_w = _panel_x(lay) + bdx, float(lay["panel_w"])
1376
+ panel_y = max(float(lay["panel_y"]) + bdy, 210.0)
1377
+
1378
+ kicker = scene.get("kicker") or ""
1379
+ p0 = ease(rng(t, 0.0, 0.75))
1380
+ if kicker:
1381
+ _draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
1382
+ float(lay["kicker_y"]) + _entrance_y(th, p0),
1383
+ int(th["scale"]["kicker"][0]), "bold", th["accent"], align, p0, trk * 0.5)
1384
+
1385
+ big = scene.get("big") or (scene.get("text") or "")[:18]
1386
+ bsize = int(th["scale"]["heading"][0])
1387
+ lines = _wrap(big, bsize, title_w, "bold", th)
1388
+ while len(lines) > 3 and bsize > 40:
1389
+ bsize = int(bsize * 0.9)
1390
+ lines = _wrap(big, bsize, title_w, "bold", th)
1391
+ lh = int(bsize * gap)
1392
+ p1 = ease(rng(t, 0.06, 0.80))
1393
+ y0 = float(lay["title_y"]) + bdy + _entrance_y(th, p1)
1394
+ for i, ln in enumerate(lines):
1395
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p1, align),
1396
+ y0 + i * lh, bsize, "bold", th["ink"], align, p1, trk)
1397
+ sub_bottom = y0 + len(lines) * lh
1398
+
1399
+ sub = scene.get("sub") or ""
1400
+ if sub:
1401
+ ssize = int(th["scale"]["body"][0])
1402
+ sy = max(sub_bottom + int(bsize * 0.30),
1403
+ float(lay["sub_y"]) if not lay.get("center") else 0.0)
1404
+ p2 = ease(rng(t, 0.20, 0.95))
1405
+ for j, ln in enumerate(_wrap(sub, ssize, title_w, "reg", th)[:2]):
1406
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p2, align),
1407
+ sy + j * int(ssize * gap), ssize, "reg", th["muted"], align, p2)
1408
+ sub_bottom = sy + int(ssize * gap)
1409
+
1410
+ if not items:
1411
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1412
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1413
+ script.get("title", ""))
1414
+
1415
+ # 条目几何:全宽模式一行一条;双栏模式按行距排
1416
+ top = max(panel_y, sub_bottom + int(bsize * 0.45)) if full else panel_y
1417
+ top = max(top, 200.0)
1418
+ body = max(22, int(th["scale"]["body"][0]) - 1)
1419
+ # 行高:优先"每条 96–118px",条目多时往下压,硬下限 56px(再低中文就糊成一片)。
1420
+ # 为什么不是固定行高:模板片 5 条要点在 9.3s 的段里,固定 118px 会溢出 856 硬边界,
1421
+ # 于是第 5 条被静默截断(v0.9 第一版实测),观众看到"五步"只列出四步。
1422
+ avail = 856.0 - top
1423
+ row_h = avail / max(1, len(items)) if items else 118.0
1424
+ row_h = min(118.0, max(56.0, row_h - 6.0))
1425
+ shown = int(avail // row_h)
1426
+ if shown < len(items):
1427
+ _warn_once(("list", scene.get("id"), len(items)),
1428
+ "本段 %d 个条目在版式里只放得下 %d 个(其余已省略);请拆段或减条目"
1429
+ % (len(items), max(0, shown)))
1430
+ for i, it in enumerate(items[:max(0, shown)]):
1431
+ p = ease(rng(t, 0.42 + stagger * i, 1.12 + stagger * i))
1432
+ if p <= 0.0:
1433
+ continue
1434
+ txt = str(it.get("text") if isinstance(it, dict) else it)
1435
+ ok = bool(it.get("ok", True)) if isinstance(it, dict) else True
1436
+ yy = top + i * row_h
1437
+ alpha = clamp(p)
1438
+ # 焦点行:每 2.2s 换一条高亮(左侧竖条 + 行底微亮),保证画面不静止
1439
+ focus = int((t / 2.2) + ph) % max(1, min(len(items), 6))
1440
+ is_focus = (i == focus)
1441
+ d = ImageDraw.Draw(img, "RGBA")
1442
+ if is_focus:
1443
+ d.rectangle([v.x(panel_x - 26), v.y(yy + 8), v.x(panel_x - 20), v.y(yy + row_h - 18)],
1444
+ fill=_rgba(th["accent"], int(190 * alpha)))
1445
+ d.rectangle([v.x(panel_x - 14), v.y(yy + row_h - 14), v.x(panel_x + panel_w),
1446
+ v.y(yy + row_h - 12)],
1447
+ fill=_rgba(th["rule"], int(26 * alpha)))
1448
+ col = th["good"] if ok else th["bad"]
1449
+ img = _soft_dot(img, v.x(panel_x + 12), v.y(yy + row_h * 0.5),
1450
+ 7.0 + 1.4 * math.sin(2.0 * t + i + ph), col, 0.95 * alpha)
1451
+ d = ImageDraw.Draw(img, "RGBA")
1452
+ num = _number_text(nstyle, i + 1)
1453
+ nsize = int(body * 0.78)
1454
+ nx = panel_x + 34
1455
+ # 编号字体:纯数字/符号走等宽(滚动不抖),带中文的写法(一、二、三)退回 reg
1456
+ # —— consola 没有中文字形,用 mono 画"一、"会得到两个方框。
1457
+ nweight = "reg" if any("\u4e00" <= ch <= "\u9fff" for ch in num) else _NUM_WEIGHT
1458
+ _draw_text(img, th, v, num, nx, yy + (row_h - nsize * 1.25) * 0.5,
1459
+ nsize, nweight, th["dim"], "left", alpha * 0.9)
1460
+ tx = nx + max(70.0, _text_w(num, nsize, nweight, th) + 26.0)
1461
+ max_w = panel_x + panel_w - tx
1462
+ tsize = _fit_size(txt, max_w, body, "reg", th, 18)
1463
+ _draw_text(img, th, v, txt, tx, yy + (row_h - tsize * 1.2) * 0.5,
1464
+ tsize, "reg", th["ink"], "left", alpha)
1465
+
1466
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1467
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1468
+ script.get("title", ""))
1469
+
1470
+
1471
+ # ---------------- 4) stat 数字卡(巨数字 count-up) ----------------
1472
+
1473
+ def _draw_stat(scene, t, dur, tg, script, frame, plan, th):
1474
+ lang = _lang(plan, th)
1475
+ s_bg = _push_scale(th, t, dur)
1476
+ v = _view(th, s_bg)
1477
+ img = _plate(th, lang, scene, s_bg)
1478
+ var = _variant(scene)
1479
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1480
+ trk = _tracking(th, lang)
1481
+ ph = _phase(scene)
1482
+
1483
+ stats = scene.get("_stats")
1484
+ if not stats:
1485
+ try:
1486
+ import grammar as _G
1487
+ except Exception: # noqa: BLE001
1488
+ _G = None
1489
+ stats = (_G.derive_stats(scene) if _G is not None else []) or []
1490
+ if not stats:
1491
+ return _draw_statement(scene, t, dur, tg, script, frame, plan, th)
1492
+
1493
+ main = stats[0]
1494
+ raw = str(main.get("value") if main.get("value") is not None else "")
1495
+ unit = str(main.get("unit") or "")
1496
+ label = str(main.get("label") or "")
1497
+ notes = [s for s in stats[1:3] if str(s.get("label") or "")]
1498
+
1499
+ # count-up:0 -> target(整数位,小数位保留原样);跳变 ≤2 位/帧,避免闪烁门禁
1500
+ target = None
1501
+ try:
1502
+ target = float(raw)
1503
+ except (TypeError, ValueError):
1504
+ target = None
1505
+ p = ease(clamp(t / max(0.5, min(1.2, dur * 0.42))))
1506
+ if target is None:
1507
+ shown_txt = raw
1508
+ elif "." in raw:
1509
+ dec = len(raw.split(".")[1])
1510
+ shown_txt = ("%." + str(dec) + "f") % (target * p)
1511
+ else:
1512
+ shown_txt = "%d" % int(round(target * p))
1513
+
1514
+ size = int(240 * (1.0 if var != 1 else 0.86))
1515
+ max_w = 1500.0
1516
+ while size > 90 and _text_w(shown_txt + unit, size, _NUM_WEIGHT, th) > max_w:
1517
+ size = int(size * 0.9)
1518
+ cx = W * 0.5 if var != 1 else W * 0.5 - 160.0
1519
+ cy = 400.0
1520
+ total_w = _text_w(shown_txt, size, _NUM_WEIGHT, th) + _text_w(unit, int(size * 0.42), "reg", th)
1521
+ x0 = cx - total_w * 0.5
1522
+ _draw_num(img, th, v, shown_txt, x0, cy - size * 0.62, size, th["accent"], "left",
1523
+ 0.25 + 0.75 * p)
1524
+ if unit:
1525
+ pu = ease(rng(t, 0.35, 0.95))
1526
+ _draw_text(img, th, v, unit, x0 + _text_w(shown_txt, size, _NUM_WEIGHT, th) + 10,
1527
+ cy + size * 0.22, int(size * 0.42), "reg", th["muted"], "left", pu)
1528
+
1529
+ if label:
1530
+ pl = ease(rng(t, 0.45, 1.10))
1531
+ for j, ln in enumerate(_wrap(label, int(th["scale"]["sub"][0]), 1200, "light", th)[:2]):
1532
+ _draw_text(img, th, v, ln, W * 0.5,
1533
+ 660.0 + j * int(th["scale"]["sub"][0] * 1.3),
1534
+ int(th["scale"]["sub"][0]), "light", th["ink"], "center", pl)
1535
+
1536
+ # 持续动态:底部 12 格刻度逐格点亮(0.55s/格,循环)
1537
+ bars = 12
1538
+ bw, gapb = 46, 14
1539
+ tot = bars * bw + (bars - 1) * gapb
1540
+ bx = W * 0.5 - tot * 0.5
1541
+ lit = int((t / 0.55 + ph) % bars)
1542
+ d = ImageDraw.Draw(img, "RGBA")
1543
+ for i in range(bars):
1544
+ on = i <= lit
1545
+ d.rectangle([v.x(bx + i * (bw + gapb)), v.y(760), v.x(bx + i * (bw + gapb) + bw),
1546
+ v.y(768)], fill=_rgba(th["accent"] if on else th["rule"],
1547
+ int((150 if on else 34))))
1548
+ # 次要统计:**数值 + 单位 + 标签**都要画出来。
1549
+ # v1.0 修:早先这里只画了 s["label"],于是 stats[] 里的第 2、3 项(例如
1550
+ # "338207 次 / 工具调用"、"22.62 B / Token 重放")在画面上只剩两个光秃秃的词,
1551
+ # 并且行距 30px < 字号,两行直接叠在一起 —— 观众看到的是"工具调用/Token 重放"两行字
1552
+ # 糊在一块、数字完全消失。数字卡的价值就在数字,丢数字等于这段白做。
1553
+ nsize = int(th["scale"]["body"][0] - 6)
1554
+ step = int(nsize * 1.75) # 行距必须大于字号,否则相邻两行会压在一起
1555
+ for j, s in enumerate(notes):
1556
+ pn = ease(rng(t, 0.6 + 0.15 * j, 1.3 + 0.15 * j))
1557
+ val = str(s.get("value") if s.get("value") is not None else "").strip()
1558
+ unit = str(s.get("unit") or "").strip()
1559
+ lbl = str(s.get("label") or "").strip()
1560
+ head = val + ((" " + unit) if unit else "")
1561
+ txt = (head + " " + lbl) if head else lbl
1562
+ if not txt:
1563
+ continue
1564
+ _draw_text(img, th, v, txt, W * 0.5, 796.0 + j * step, nsize,
1565
+ "reg", th["muted"], "center", pn)
1566
+
1567
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1568
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1569
+ script.get("title", ""))
1570
+
1571
+
1572
+ # ---------------- 5) compare 左右对比(中缝 + 双栏) ----------------
1573
+
1574
+ def _draw_compare(scene, t, dur, tg, script, frame, plan, th):
1575
+ lang = _lang(plan, th)
1576
+ s_bg = _push_scale(th, t, dur)
1577
+ v = _view(th, s_bg)
1578
+ img = _plate(th, lang, scene, s_bg)
1579
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1580
+ trk = _tracking(th, lang)
1581
+ gap = _gap(th, lang)
1582
+ ph = _phase(scene)
1583
+
1584
+ sides = scene.get("_sides")
1585
+ if not sides:
1586
+ try:
1587
+ import grammar as _G
1588
+ except Exception: # noqa: BLE001
1589
+ _G = None
1590
+ sides = (_G.derive_sides(scene) if _G is not None else None)
1591
+ if not sides:
1592
+ return _draw_statement(scene, t, dur, tg, script, frame, plan, th)
1593
+
1594
+ big = str(scene.get("big") or "")
1595
+ if big:
1596
+ pb = ease(rng(t, 0.0, 0.7))
1597
+ for j, ln in enumerate(_wrap(big, int(th["scale"]["heading"][0] - 6), 1500, "bold", th)[:1]):
1598
+ _draw_text(img, th, v, ln, W * 0.5, 150.0 + _entrance_y(th, pb),
1599
+ int(th["scale"]["heading"][0] - 6), "bold", th["ink"], "center", pb, trk)
1600
+
1601
+ # 中缝:从中心向上下生长(0.35s),是"对比"这件事最直接的视觉符号
1602
+ pg = ease(rng(t, 0.05, 0.40))
1603
+ mid_x = W * 0.5
1604
+ y_top, y_bot = 240.0, 820.0
1605
+ cy = (y_top + y_bot) * 0.5
1606
+ half = (y_bot - y_top) * 0.5 * pg
1607
+ d = ImageDraw.Draw(img, "RGBA")
1608
+ d.line([v.x(mid_x), v.y(cy - half), v.x(mid_x), v.y(cy + half)],
1609
+ fill=_rgba(th["rule"], 90), width=2)
1610
+ # 持续动态:中缝上缓慢下扫的高光
1611
+ scan = cy - half + 2.0 * half * (0.5 + 0.5 * math.sin(0.6 * t + ph))
1612
+ if abs(scan - cy) <= half:
1613
+ d.line([v.x(mid_x - 90), v.y(scan), v.x(mid_x + 90), v.y(scan)],
1614
+ fill=_rgba(th["accent"], 40), width=2)
1615
+
1616
+ # 两栏内容垂直居中(上界 360,下界 800):只有一两条时不会全挤在顶部留一大片空白。
1617
+ # 栏头:优先 sides 里作者写的 head;没有就**不要**写"左/右"这种零信息标签
1618
+ # (v0.9 第一版实测:观众看到"左 / 右"会以为画面坏了),改画一个 VS 徽标在中缝上方。
1619
+ cols = [("left", 150.0, 700.0, -1), ("right", W - 850.0, 700.0, 1)]
1620
+ max_rows = max(len((sides.get("left") or {}).get("items") or []),
1621
+ len((sides.get("right") or {}).get("items") or []), 1)
1622
+ rows_h = min(3, max_rows) * 130
1623
+ y_start = max(380.0, 620.0 - rows_h * 0.5)
1624
+ heads = [str((sides.get(s) or {}).get("head") or "") for s, _, _, _ in cols]
1625
+ has_head = any(heads)
1626
+ for side, x0, ww, sgn in cols:
1627
+ blk = sides.get(side) or {}
1628
+ head = str(blk.get("head") or "")
1629
+ its = [str(x) for x in (blk.get("items") or [])]
1630
+ ph2 = ease(rng(t, 0.10, 0.70))
1631
+ if head:
1632
+ hx = x0 + 40 * (1 - ph2) * sgn
1633
+ _draw_text(img, th, v, head, hx, y_start - 92.0, int(th["scale"]["body"][0]),
1634
+ "bold", th["accent"], "left", ph2)
1635
+ ImageDraw.Draw(img, "RGBA").line(
1636
+ [v.x(x0), v.y(y_start - 48.0), v.x(x0 + ww * ph2), v.y(y_start - 48.0)],
1637
+ fill=_rgba(th["accent"], 130), width=2)
1638
+ for i, txt in enumerate(its[:3]):
1639
+ pi = ease(rng(t, 0.42 + 0.22 * i, 1.05 + 0.22 * i))
1640
+ if pi <= 0:
1641
+ continue
1642
+ yy = y_start + i * 130
1643
+ # 反相高亮:左右两栏轮流亮(周期 3.2s),让"对照"这件事一直在动
1644
+ hi = ((math.sin(2 * math.pi * t / 3.2 + ph + (0 if side == "left" else math.pi)) + 1) / 2)
1645
+ alpha = 0.55 + 0.45 * hi
1646
+ tsize = _fit_size(txt, ww - 40, int(th["scale"]["body"][0] - 2), "reg", th, 18)
1647
+ _draw_text(img, th, v, txt, x0 + 40 * (1 - pi) * sgn, yy, tsize, "reg",
1648
+ th["ink"], "left", pi * alpha)
1649
+ img = _soft_dot(img, v.x(x0 - 8), v.y(yy + tsize * 0.6), 6.0,
1650
+ th["accent"], 0.7 * pi)
1651
+
1652
+ if not has_head:
1653
+ # 中缝 VS 徽标:对比这件事的"视觉锚点",也让画面重心落在中轴上
1654
+ pv = ease(rng(t, 0.2, 0.8))
1655
+ vs_size = int(th["scale"]["body"][0] - 6)
1656
+ vw = _text_w("VS", vs_size, "bold", th)
1657
+ cyv = 250.0
1658
+ ImageDraw.Draw(img, "RGBA").rectangle(
1659
+ [v.x(mid_x - vw * 0.5 - 22), v.y(cyv - 8), v.x(mid_x + vw * 0.5 + 22), v.y(cyv + 44)],
1660
+ fill=_rgba(th["surface"], int(220 * pv)), outline=_rgba(th["accent"], int(180 * pv)))
1661
+ _draw_text(img, th, v, "VS", mid_x - vw * 0.5, cyv, vs_size, "bold",
1662
+ th["accent"], "left", pv)
1663
+
1664
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1665
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1666
+ script.get("title", ""))
1667
+
1668
+
1669
+ # ---------------- 6) process 流程轴 ----------------
1670
+
1671
+ def _draw_process(scene, t, dur, tg, script, frame, plan, th):
1672
+ lang = _lang(plan, th)
1673
+ s_bg = _push_scale(th, t, dur)
1674
+ v = _view(th, s_bg)
1675
+ img = _plate(th, lang, scene, s_bg)
1676
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1677
+ trk = _tracking(th, lang)
1678
+ ph = _phase(scene)
1679
+
1680
+ steps = scene.get("_steps")
1681
+ if not steps:
1682
+ try:
1683
+ import grammar as _G
1684
+ except Exception: # noqa: BLE001
1685
+ _G = None
1686
+ steps = (_G.derive_steps(scene) if _G is not None else []) or []
1687
+ if len(steps) < 2:
1688
+ return _draw_list(scene, t, dur, tg, script, frame, plan, th)
1689
+
1690
+ big = str(scene.get("big") or "")
1691
+ pb = ease(rng(t, 0.0, 0.7))
1692
+ if big:
1693
+ for j, ln in enumerate(_wrap(big, int(th["scale"]["heading"][0] - 6), 1500, "bold", th)[:1]):
1694
+ _draw_text(img, th, v, ln, 150.0, 210.0 + _entrance_y(th, pb),
1695
+ int(th["scale"]["heading"][0] - 6), "bold", th["ink"], "left", pb, trk)
1696
+
1697
+ n = min(5, len(steps))
1698
+ x0, x1, ay = 240.0, float(W - 240), 560.0
1699
+ gapx = (x1 - x0) / max(1, n - 1)
1700
+ pg = ease(rng(t, 0.10, 0.90))
1701
+ d = ImageDraw.Draw(img, "RGBA")
1702
+ d.line([v.x(x0), v.y(ay), v.x(x0 + (x1 - x0) * pg), v.y(ay)],
1703
+ fill=_rgba(th["rule"], 70), width=2)
1704
+ for i in range(n):
1705
+ st = steps[i]
1706
+ pn = ease(rng(t, 0.35 + 0.18 * i, 1.05 + 0.18 * i))
1707
+ if pn <= 0:
1708
+ continue
1709
+ cx = x0 + gapx * i
1710
+ r = 14.0 * pn
1711
+ d.ellipse([v.x(cx - r), v.y(ay - r), v.x(cx + r), v.y(ay + r)],
1712
+ outline=_rgba(th["accent"], int(230 * pn)), width=3)
1713
+ d.ellipse([v.x(cx - 4), v.y(ay - 4), v.x(cx + 4), v.y(ay + 4)],
1714
+ fill=_rgba(th["accent"], int(200 * pn)))
1715
+ lbl = str(st.get("label") or "")
1716
+ lsize = int(th["scale"]["body"][0] - 4)
1717
+ lw = _text_w(lbl, lsize, "bold", th)
1718
+ _draw_text(img, th, v, lbl, cx - lw * 0.5, 470.0, lsize, "bold",
1719
+ th["ink"], "left", pn)
1720
+ note = str(st.get("note") or "")
1721
+ if note:
1722
+ for j, ln in enumerate(_wrap(note, int(lsize * 0.82), 300, "reg", th)[:2]):
1723
+ nw = _text_w(ln, int(lsize * 0.82), "reg", th)
1724
+ _draw_text(img, th, v, ln, cx - nw * 0.5, 610.0 + j * int(lsize * 1.05),
1725
+ int(lsize * 0.82), "reg", th["muted"], "left", pn * 0.95)
1726
+ num = _number_text(str(_typo(lang).get("number") or "dim"), i + 1)
1727
+ nw = "reg" if any("\u4e00" <= ch <= "\u9fff" for ch in num) else _NUM_WEIGHT
1728
+ _draw_text(img, th, v, num, cx - 26, ay - 66, int(lsize * 0.8), nw,
1729
+ th["dim"], "left", pn * 0.9)
1730
+
1731
+ # 持续动态:进度光点沿轴匀速(周期 = 段长),永远有运动
1732
+ if dur > 0.1:
1733
+ u = clamp((t / dur))
1734
+ px = x0 + (x1 - x0) * u
1735
+ img = _soft_dot(img, v.x(px), v.y(ay), 9.0, th["accent2"], 0.85)
1736
+
1737
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1738
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1739
+ script.get("title", ""))
1740
+
1741
+
1742
+ # ---------------- 7) quote 引语 ----------------
1743
+
1744
+ def _draw_quote(scene, t, dur, tg, script, frame, plan, th):
1745
+ lang = _lang(plan, th)
1746
+ s_bg = _push_scale(th, t, dur)
1747
+ v = _view(th, s_bg)
1748
+ img = _plate(th, lang, scene, s_bg)
1749
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1750
+ trk = _tracking(th, lang)
1751
+ gap = _gap(th, lang)
1752
+ ph = _phase(scene)
1753
+
1754
+ try:
1755
+ import grammar as _G
1756
+ except Exception: # noqa: BLE001
1757
+ _G = None
1758
+ q, by = (scene.get("quote"), scene.get("by"))
1759
+ if not q and _G is not None:
1760
+ q, by = _G.derive_quote(scene)
1761
+ q = str(q or scene.get("text") or "")
1762
+ by = str(by or "")
1763
+
1764
+ # 巨型引号:**用字形**,不用矢量线。
1765
+ # v0.9 第一版画的是两条短线拼成的「《」,实测看起来像播放器的"快退"图标,
1766
+ # 观众会以为点错了什么。雅黑粗体的 U+201C 是有字形的(preflight 查过不缺口),
1767
+ # 直接放大 150px 就是最标准的引语符号。
1768
+ pq = ease(rng(t, 0.0, 0.45))
1769
+ qx, qy = 150.0, 150.0
1770
+ _draw_text(img, th, v, "\u201c", qx, qy, 150, "bold", th["accent"], "left", 0.55 * pq)
1771
+
1772
+ qsize = 46
1773
+ max_w = 1240.0
1774
+ lines = _wrap(q, qsize, max_w, "light", th)
1775
+ while len(lines) > 4 and qsize > 30:
1776
+ qsize -= 2
1777
+ lines = _wrap(q, qsize, max_w, "light", th)
1778
+ lh = int(qsize * float(gap) * 1.16)
1779
+ top = 300.0
1780
+ reveal = clamp(t / max(0.4, min(2.2, dur * 0.55)))
1781
+ total = sum(len(x) for x in lines)
1782
+ shown = int(total * ease(reveal))
1783
+ walked = 0
1784
+ for i, ln in enumerate(lines):
1785
+ if walked >= shown:
1786
+ break
1787
+ take = max(0, min(len(ln), shown - walked))
1788
+ walked += len(ln)
1789
+ seg = ln[:take]
1790
+ if seg:
1791
+ _draw_text(img, th, v, seg, 340.0, top + i * lh, qsize, "light",
1792
+ th["ink"], "left", 1.0, trk * 0.4)
1793
+
1794
+ if by:
1795
+ pb = ease(rng(t, 0.9, 1.9))
1796
+ bw = _text_w("—— " + by, int(th["scale"]["body"][0]), "reg", th)
1797
+ _draw_text(img, th, v, "—— " + by, W - 300 - bw, top + len(lines) * lh + 46,
1798
+ int(th["scale"]["body"][0]), "reg", th["muted"], "left", pb)
1799
+
1800
+ # 持续动态:引号随呼吸缓慢上浮 6px(正弦,不做方波跳变)+ 末行光标块
1801
+ bre = 0.5 + 0.5 * math.sin(0.5 * t + ph)
1802
+ _draw_text(img, th, v, "\u201c", qx, qy - 6.0 * bre, 150, "bold",
1803
+ th["accent"], "left", 0.55 * (0.55 + 0.45 * bre))
1804
+ if int(t * 1.1) % 2 == 0:
1805
+ ly = top + len(lines) * lh - qsize
1806
+ lw = _text_w(lines[-1] if lines else "", qsize, "light", th)
1807
+ ImageDraw.Draw(img, "RGBA").rectangle(
1808
+ [v.x(340 + lw + 6), v.y(ly), v.x(340 + lw + 14), v.y(ly + qsize)],
1809
+ fill=_rgba(th["accent"], 150))
1810
+
1811
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1812
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1813
+ script.get("title", ""))
1814
+
1815
+
1816
+ # ---------------- 8) chapter 章节卡 ----------------
1817
+
1818
+ def _draw_chapter(scene, t, dur, tg, script, frame, plan, th):
1819
+ lang = _lang(plan, th)
1820
+ s_bg = _push_scale(th, t, dur)
1821
+ v = _view(th, s_bg)
1822
+ img = _plate(th, lang, scene, s_bg)
1823
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1824
+ trk = _tracking(th, lang)
1825
+ ph = _phase(scene)
1826
+
1827
+ chapter = str(scene.get("chapter") or "")
1828
+ n = _chapter_index(chapter)
1829
+ nstyle = str(_typo(lang).get("number") or "dim")
1830
+ num = _number_text(nstyle, n, fallback=chapter[:2])
1831
+
1832
+ # 巨型编号:用矩形"裁剪揭示"(先画字,再用两块背景板盖住未揭示部分),比 mask 便宜
1833
+ nsize = 240
1834
+ nx, ny = 96.0, 130.0
1835
+ _draw_num(img, th, v, num, nx, ny, nsize, th["accent"], "left", 0.30)
1836
+ p = ease(clamp(t / 0.7))
1837
+ cut = int(nx + _text_w(num, nsize, _NUM_WEIGHT, th) * p)
1838
+ if cut < nx + _text_w(num, nsize, _NUM_WEIGHT, th):
1839
+ base = _plate(th, lang, scene, s_bg)
1840
+ img.paste(base.crop((int(cut), int(ny - 10), W, int(ny + nsize * 1.3))),
1841
+ (int(cut), int(ny - 10)))
1842
+
1843
+ name = str(scene.get("big") or "").strip() or chapter
1844
+ pn = ease(rng(t, 0.25, 1.0))
1845
+ for j, ln in enumerate(_wrap(name, 68, 1300, "bold", th)[:2]):
1846
+ _draw_text(img, th, v, ln, 150.0, 470.0 + j * 86 + _entrance_y(th, pn),
1847
+ 68, "bold", th["ink"], "left", pn, trk)
1848
+
1849
+ kicker = str(scene.get("kicker") or "")
1850
+ if kicker:
1851
+ pk = ease(rng(t, 0.15, 0.8))
1852
+ _draw_text(img, th, v, kicker, 150.0, 410.0, int(th["scale"]["kicker"][0]),
1853
+ "bold", th["accent"], "left", pk, trk * 0.5)
1854
+
1855
+ # 横线由中点向两侧生长 + 持续动态(端点微呼吸)
1856
+ pl = ease(rng(t, 0.35, 1.1))
1857
+ bre = 0.9 + 0.1 * math.sin(1.0 * t + ph)
1858
+ half = 800.0 * pl * bre
1859
+ d = ImageDraw.Draw(img, "RGBA")
1860
+ d.line([v.x(W * 0.5 - half), v.y(700), v.x(W * 0.5 + half), v.y(700)],
1861
+ fill=_rgba(th["rule"], 120), width=2)
1862
+
1863
+ # 持续动态:编号上方一条细光带横向扫(2.8s 周期)。
1864
+ # 为什么不做"整条竖光带":竖着扫过编号会盖住笔画,看起来像渲染错误(实测第一版就是这样),
1865
+ # 而且一根 640px 高的半透明条会把剪影里的前景占比拉高,让章节卡和别的类型混淆。
1866
+ # 细光带只在编号上缘 6px 高,既有运动又不动主体。
1867
+ sx = 96.0 + (W - 400.0) * ((t / 2.8 + ph / 6.28) % 1.0)
1868
+ ImageDraw.Draw(img, "RGBA").rectangle(
1869
+ [v.x(sx), v.y(118), v.x(sx + 180), v.y(124)], fill=_rgba(th["accent"], 60))
1870
+
1871
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1872
+ return _hud(img, th, chapter or "02 · 正文", _progress(tg, script),
1873
+ script.get("title", ""))
1874
+
1875
+
1876
+ # ---------------- 9) chart 数据条 ----------------
1877
+
1878
+ def _draw_chart(scene, t, dur, tg, script, frame, plan, th):
1879
+ lang = _lang(plan, th)
1880
+ s_bg = _push_scale(th, t, dur)
1881
+ v = _view(th, s_bg)
1882
+ img = _plate(th, lang, scene, s_bg)
1883
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1884
+ trk = _tracking(th, lang)
1885
+ ph = _phase(scene)
1886
+
1887
+ series = scene.get("_series")
1888
+ if not series:
1889
+ try:
1890
+ import grammar as _G
1891
+ except Exception: # noqa: BLE001
1892
+ _G = None
1893
+ series = (_G.derive_series(scene) if _G is not None else []) or []
1894
+ if len(series) < 2:
1895
+ return _draw_stat(scene, t, dur, tg, script, frame, plan, th)
1896
+
1897
+ big = str(scene.get("big") or "")
1898
+ if big:
1899
+ pb = ease(rng(t, 0.0, 0.7))
1900
+ _draw_text(img, th, v, big, 150.0, 170.0 + _entrance_y(th, pb),
1901
+ int(th["scale"]["heading"][0] - 20), "bold", th["ink"], "left", pb, trk)
1902
+
1903
+ vals = []
1904
+ for s in series[:6]:
1905
+ try:
1906
+ vals.append(float(s.get("value")))
1907
+ except (TypeError, ValueError):
1908
+ vals.append(0.0)
1909
+ vmax = max(vals) if vals else 1.0
1910
+ if vmax <= 0:
1911
+ vmax = 1.0
1912
+ n = len(vals)
1913
+ bar_x0, bar_x1 = 640.0, 1600.0
1914
+ pitch = min(96.0, (760.0 - 250.0) / max(1, n))
1915
+ top = 260.0
1916
+ # 网格(一次画完,1px 低 alpha)
1917
+ d = ImageDraw.Draw(img, "RGBA")
1918
+ pg = ease(rng(t, 0.0, 0.35))
1919
+ for k in range(5):
1920
+ gx = bar_x0 + (bar_x1 - bar_x0) * k / 4.0
1921
+ d.line([v.x(gx), v.y(240), v.x(gx), v.y(240 + pitch * n * pg)],
1922
+ fill=_rgba(th["rule"], 22), width=1)
1923
+
1924
+ for i, s in enumerate(series[:n]):
1925
+ p = ease(rng(t, 0.20 + 0.15 * i, 0.95 + 0.15 * i))
1926
+ if p <= 0:
1927
+ continue
1928
+ yy = top + i * pitch
1929
+ frac = vals[i] / vmax
1930
+ wpx = (bar_x1 - bar_x0) * frac * p
1931
+ d = ImageDraw.Draw(img, "RGBA")
1932
+ d.rectangle([v.x(bar_x0), v.y(yy), v.x(bar_x0 + wpx), v.y(yy + 42)],
1933
+ fill=_rgba(th["accent"] if i == 0 else th["accent2"], 190))
1934
+ lbl = str(s.get("label") or "")
1935
+ lsize = int(th["scale"]["body"][0] - 6)
1936
+ lw = _text_w(lbl, lsize, "reg", th)
1937
+ _draw_text(img, th, v, lbl, bar_x0 - 24 - lw, yy + 6, lsize, "reg",
1938
+ th["ink"], "left", p)
1939
+ num = ("%g" % vals[i]) + str(s.get("unit") or "")
1940
+ _draw_num(img, th, v, num, bar_x0 + wpx + 16, yy + 4, int(lsize * 1.05),
1941
+ th["ink"], "left", p)
1942
+
1943
+ # 持续动态:最大条端点呼吸 + 一条网格高亮扫描
1944
+ if vals:
1945
+ i0 = vals.index(max(vals))
1946
+ yy = top + i0 * pitch
1947
+ r = 4.0 + 3.0 * (0.5 + 0.5 * math.sin(1.8 * t + ph))
1948
+ img = _soft_dot(img, v.x(bar_x1), v.y(yy + 21), r, th["accent"], 0.8)
1949
+ scan_y = 240.0 + (pitch * n) * ((t / 2.6 + ph / 6.28) % 1.0)
1950
+ ImageDraw.Draw(img, "RGBA").line(
1951
+ [v.x(bar_x0), v.y(scan_y), v.x(bar_x1), v.y(scan_y)],
1952
+ fill=_rgba(th["accent"], 26), width=2)
1953
+
1954
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1955
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1956
+ script.get("title", ""))
1957
+
1958
+
1959
+ # ---------------- 10) outro 收尾 ----------------
1960
+
1961
+ def _draw_outro(scene, t, dur, tg, script, frame, plan, th):
1962
+ lang = _lang(plan, th)
1963
+ s_bg = _push_scale(th, t, dur)
1964
+ v = _view(th, s_bg)
1965
+ img = _plate(th, lang, scene, s_bg)
1966
+ lay = _scene_layout(scene, th)
1967
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1968
+ trk = _tracking(th, lang)
1969
+ gap = _gap(th, lang)
1970
+ ph = _phase(scene)
1971
+
1972
+ # 光点环:数量随视觉语言母题走(epic 18 / cinema 12 / mono 0),且比 v0.5 更少
1973
+ mf = str((lang.get("motif") or {}).get("kind") or "minimal")
1974
+ ring_n = {"rays": 18, "letterbox": 12, "brackets": 10, "signal": 8,
1975
+ "handdrawn": 6, "slabs": 12, "hairlines": 6, "minimal": 0}.get(mf, 8)
1976
+ p = ease(rng(t, 0.0, 1.0))
1977
+ for i in range(ring_n):
1978
+ a = (i / float(max(1, ring_n))) * 2.0 * math.pi + 0.15 * t + ph
1979
+ rr = 460.0 * (1.0 - p) + 70.0 + 16.0 * K.hash2(i, 4)
1980
+ img = _soft_dot(img, v.x(W * 0.5 + math.cos(a) * rr),
1981
+ v.y(520.0 + math.sin(a) * rr * 0.5), 3.0,
1982
+ th["accent2"], 0.5 * (1.0 - p * 0.7))
1983
+
1984
+ big = scene.get("big") or script.get("end_text") or "完"
1985
+ size = int(th["scale"]["display"][0])
1986
+ lines = _wrap(big, size, min(float(lay["title_w"]), 1520.0), "bold", th)
1987
+ while len(lines) > 3 and size > 42:
1988
+ size = int(size * 0.88)
1989
+ lines = _wrap(big, size, min(float(lay["title_w"]), 1520.0), "bold", th)
1990
+ lh = int(size * gap)
1991
+ pa = ease(rng(t, 0.30, 1.30))
1992
+ top = 380.0 - (len(lines) - 1) * lh * 0.5
1993
+ for i, ln in enumerate(lines):
1994
+ _draw_text(img, th, v, ln, W * 0.5, top + i * lh + _entrance_y(th, pa),
1995
+ size, "bold", th["ink"], "center", 0.02 + 0.98 * pa, trk)
1996
+ bottom = top + len(lines) * lh + _entrance_y(th, pa)
1997
+
1998
+ sub = scene.get("sub") or ""
1999
+ # 与大字重复的副标题不画(同 hero:模板片 sub 与 big 是同一句)
2000
+ if sub and (str(sub).strip() == str(big).strip()
2001
+ or (len(str(sub)) >= 6 and str(sub).strip() in str(big).strip())):
2002
+ sub = ""
2003
+ if sub:
2004
+ ssize = int(th["scale"]["sub"][0])
2005
+ p2 = ease(rng(t, 0.55, 1.55))
2006
+ for j, ln in enumerate(_wrap(sub, ssize, 1400, "light", th)[:2]):
2007
+ _draw_text(img, th, v, ln, W * 0.5,
2008
+ bottom + int(size * 0.22) + j * int(ssize * gap),
2009
+ ssize, "light", th["muted"], "center", p2)
2010
+ bottom = bottom + int(size * 0.22) + int(ssize * gap)
2011
+
2012
+ grow = ease(rng(t, 0.45, 1.45))
2013
+ bre = 0.82 + 0.18 * math.sin(0.9 * t + ph)
2014
+ line_w = int(340 * grow * bre)
2015
+ ly = min(bottom + int(size * 0.30), 840)
2016
+ ImageDraw.Draw(img, "RGBA").line(
2017
+ [v.x(W * 0.5 - line_w * 0.5), v.y(ly), v.x(W * 0.5 + line_w * 0.5), v.y(ly)],
2018
+ fill=_rgba(th["accent"], 200), width=3)
2019
+
2020
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
2021
+ return _hud(img, th, scene.get("chapter") or "99 · 收尾", 1.0, script.get("title", ""))
2022
+
2023
+
2024
+ _DRAWERS = {
2025
+ "hero": _draw_hero,
2026
+ "statement": _draw_statement,
2027
+ "list": _draw_list,
2028
+ "stat": _draw_stat,
2029
+ "compare": _draw_compare,
2030
+ "process": _draw_process,
2031
+ "quote": _draw_quote,
2032
+ "chapter": _draw_chapter,
2033
+ "chart": _draw_chart,
2034
+ "outro": _draw_outro,
2035
+ }
2036
+
2037
+ # 老 kind -> 场景类型(没有 scene_type 的旧 timeline / 手工构造的 scene 走这里)
2038
+ _KIND_TO_TYPE = {
2039
+ "hook": "hero", "body": "list", "quote": "quote", "outro": "outro",
2040
+ "emphasis": "statement", "calm": "statement",
2041
+ }
2042
+
2043
+
2044
+ # ============================================================================
2045
+ # 四、对外契约
2046
+ # ============================================================================
2047
+
2048
+ def draw_frame(scene, t_local, dur, t_global, script, frame):
2049
+ """按帧画一张 1920x1080 RGB 图。
2050
+
2051
+ v1.0 / D3 起只有一条路:按 scene_type(grammar.py 选好、render.py 写进 timeline)
2052
+ 派发到 10 类构图之一;没有 scene_type 的旧 timeline 按 kind 映射到兼容类型。
2053
+
2054
+ 原来的第 1 条分支(legacy —— film.json 不写 style 就走 v0.4 三个老绘制器)**已删除**:
2055
+ stylekit 不再产出 legacy 计划(不写设计会直接报错),那条分支永远走不到。
2056
+ 留着死分支比删掉更危险——它会让"legacy 还能用"的错觉一直存在。
2057
+
2058
+ 未命中的类型回落到 list("有条目就画条目"是最不会丢信息的兜底),
2059
+ 并 warn 一次——静默画错构图比报错更糟。
2060
+ """
2061
+ plan = _plan(script)
2062
+ kind = scene.get("kind") or "body"
2063
+ th = _scene_theme(plan, scene)
2064
+ stype = str(scene.get("scene_type") or "").strip()
2065
+ if not stype:
2066
+ stype = _KIND_TO_TYPE.get(kind, "list")
2067
+ fn = _DRAWERS.get(stype)
2068
+ if fn is None:
2069
+ _warn_once(("scene_type", stype),
2070
+ "场景 %r 的 scene_type=%r 不认识,已回落到 list 构图(可用:%s)"
2071
+ % (scene.get("id"), stype, " / ".join(_DRAWERS.keys())))
2072
+ fn = _draw_list
2073
+ return fn(scene, t_local, dur, t_global, script, frame, plan, th)
2074
+
2075
+
2076
+ # ---------------- 提示点(v0.3.0 起可选;v0.7 按 kind 与内容生成) ----------------
2077
+ def _sha1_int(*parts):
2078
+ """把若干文本片段拼起来取 sha1 前 8 位(提示点"内容派生"的稳定来源)。"""
2079
+ raw = "|".join(str(p) for p in parts).encode("utf-8")
2080
+ return int(hashlib.sha1(raw).hexdigest()[:8], 16)
2081
+
2082
+
2083
+ def _item_pitch(text, base=1760.0):
2084
+ """条目文本 -> ui_blip 音高(sha1 派生,不用序号)。
2085
+
2086
+ v0.7 契约 §3.4:body 条目的 pitch 必须由**条目文本**派生,不再按序号
2087
+ (旧实现 1760+110*i 每片都一样)——同一句文案换到别的片子里仍是同一个音高,
2088
+ 不同文案落到 -5~+7 半音的不同音级;万一两条碰撞,_dedupe_segment 会再升半音。
2089
+ """
2090
+ off = _sha1_int("pitch", text) % 13 - 5
2091
+ return round(float(base) * 2.0 ** (off / 12.0), 2)
2092
+
2093
+
2094
+ def _cue_gain_decay(gain, repeat):
2095
+ """同段内同类型 cue 第 repeat 次重复(0 起)时的增益:每次再低 2 dB(-2/-4/-6/…)。
2096
+
2097
+ v0.7 契约 §3.4:不许再出现"每段都一模一样的三连击"——同类型重复必须**严格递减**
2098
+ (第 2 次 -2、第 3 次 -4、第 4 次 -6……),且 pitch 必须不同。
2099
+ """
2100
+ return round(float(gain) - 2.0 * int(repeat), 2)
2101
+
2102
+
2103
+ def _dedupe_segment(seg):
2104
+ """同一段内:同类型重复的 gain 递减、pitch 去重(保证"pitch 必须不同")。
2105
+
2106
+ 正文条目多时 sha1 可能把两条吸附到同一个半音;碰撞就把后一个再升半音——
2107
+ 仍然由文本派生,只是保证同段里不出现两个一模一样的高音(听着像卡带)。
2108
+ """
2109
+ seen = {} # type -> {"n": 已出现次数, "pitch": 已用音高集}
2110
+ for c in seg:
2111
+ st = seen.setdefault(c["type"], {"n": 0, "pitch": set()})
2112
+ if st["n"]:
2113
+ c["gain"] = _cue_gain_decay(c.get("gain", 0.0), st["n"])
2114
+ st["n"] += 1
2115
+ p = c.get("pitch")
2116
+ if p is not None:
2117
+ p = float(p)
2118
+ guard = 0
2119
+ while round(p, 2) in st["pitch"] and guard < 12:
2120
+ p *= 2.0 ** (1.0 / 12.0)
2121
+ guard += 1
2122
+ c["pitch"] = round(p, 2)
2123
+ st["pitch"].add(round(p, 2))
2124
+ return seg
2125
+
2126
+
2127
+ def cues(tl, script):
2128
+ """画面提示点钩子:render.py --stage cues 会调它(没有这个函数就不调)。
2129
+
2130
+ 好处是"声音跟着画面的像素走":这里算的是卡片落位、数字滚完的时刻,
2131
+ 画面一改,声音自动跟着改,不用回来手工对时间。
2132
+
2133
+ v0.5:条目落位步长与场景绘制共用 plan["pacing"]["item_stagger"],
2134
+ 两边读同一个数,否则音效会错位(这是 v0.4 写死 0.32 留下的坑)。
2135
+
2136
+ v0.7(references/audio-variation.md §3.4):提示点改为按**场景 kind 与内容**生成,
2137
+ 不再是"每段 ui_blip 三连 + 结尾 answer_chime"的同质化固定套路(跨片音效雷同的
2138
+ 来源之一):
2139
+ hook : 段首 impact(size 按字数 0.9~1.3)+ 落定 riser
2140
+ body : 条目落位 ui_blip(pitch 由条目文本 sha1 派生)+ items>=3 补 answer_chime
2141
+ + 段尾 whoosh(转场到下一段)
2142
+ quote : reverse_swell + 落定轻 impact
2143
+ outro : answer_chime + 片尾 sub_drop
2144
+ 同段内同类型 cue 重复时 gain 逐次再降 2 dB(-2/-4/-6)、pitch 必须不同。
2145
+ 返回 schema 不变:[{"at": 时刻, "type": 配方名, ...参数}];
2146
+ at 可以写 "abs:12.5" / 12.5 / "l03+0.35"(相对该段 start)/"l03"
2147
+ type 见 references/sfx-design.md 的配方表,或 sfxkit.list_recipes()
2148
+ """
2149
+ plan = _plan(script)
2150
+ stagger = 0.32
2151
+ if not plan.get("legacy"):
2152
+ stagger = float((plan.get("pacing") or {}).get("item_stagger") or 0.32)
2153
+ scenes = list(tl.get("scenes") or [])
2154
+ out = []
2155
+ for i, s in enumerate(scenes):
2156
+ sid = str(s.get("id") or ("l%02d" % (i + 1)))
2157
+ kind = str(s.get("kind") or "body")
2158
+ end = float(s.get("end") or 0.0)
2159
+ dur = float(s.get("dur") or max(0.0, end - float(s.get("start") or 0.0)))
2160
+ has_next = (i + 1) < len(scenes)
2161
+ seg = []
2162
+
2163
+ def _at(off):
2164
+ """本段相对时刻 -> cue 的 at 写法(id+偏移,保持返回 schema 不变)。"""
2165
+ return "%s+%.2f" % (sid, max(0.0, float(off)))
2166
+
2167
+ if kind == "hook":
2168
+ # 开场重击按文案字数给"体量":字数越多 size 越大(0.9~1.3),避免每片同一记闷响
2169
+ text = str(s.get("text") or s.get("big") or "")
2170
+ n_char = len("".join(text.split()))
2171
+ size = round(0.9 + 0.4 * min(1.0, n_char / 40.0), 2)
2172
+ seg.append({"at": _at(0.10), "type": "impact", "size": size, "gain": -2})
2173
+ seg.append({"at": _at(min(1.2, max(0.35, dur * 0.28))),
2174
+ "type": "riser", "dur": 1.2, "gain": -6})
2175
+ elif kind == "quote":
2176
+ # 引语段:先来一层反向渐强铺底,落定时轻一点的一击
2177
+ seg.append({"at": _at(0.05), "type": "reverse_swell", "dur": 1.2, "gain": -5})
2178
+ seg.append({"at": _at(min(1.4, max(0.5, dur * 0.30))),
2179
+ "type": "impact", "size": 0.6, "gain": -8})
2180
+ elif kind == "outro":
2181
+ # 收尾:答对钟声 + 片尾 sub_drop(尾巴正好落在段尾,不留半截低频)
2182
+ seg.append({"at": _at(0.35), "type": "answer_chime", "gain": -4})
2183
+ seg.append({"at": _at(max(0.30, dur - 1.60)),
2184
+ "type": "sub_drop", "dur": 1.6, "gain": -5})
2185
+ else:
2186
+ # body(以及 emphasis/calm 等未单独设计的 kind:按正文处理,向后兼容)
2187
+ items = list(s.get("items") or [])
2188
+ for j, it in enumerate(items):
2189
+ # 条目逐条落位:第一项在 0.48s 出现,之后每 stagger 秒一个(与 _draw_items 同一拍)
2190
+ at = 0.48 + stagger * j
2191
+ if at > dur - 0.12: # 极短段:放不下的条目不再排提示点(避免响到下一段)
2192
+ break
2193
+ txt = it.get("text") if isinstance(it, dict) else it
2194
+ seg.append({"at": _at(at), "type": "ui_blip",
2195
+ "pitch": _item_pitch(txt), "gain": -6})
2196
+ blips = [c for c in seg if c["type"] == "ui_blip"]
2197
+ if len(items) >= 3 and blips and dur > 0.6:
2198
+ # 最后一个条目落定后的下一拍补一声"猜中了",给列表收尾
2199
+ last_at = float(blips[-1]["at"].split("+", 1)[1])
2200
+ t_chime = min(last_at + stagger, max(last_at + 0.20, dur - 0.30))
2201
+ seg.append({"at": _at(t_chime), "type": "answer_chime", "gain": -6})
2202
+ if has_next and dur > 0.6:
2203
+ # 段尾 whoosh:从本段扫进下一段(C 路会在段界附近给转场类音效让位)
2204
+ seg.append({"at": _at(max(0.0, dur - 0.45)),
2205
+ "type": "whoosh", "dur": 0.9, "gain": -6})
2206
+ out.extend(_dedupe_segment(seg))
2207
+ return out