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

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 (45) hide show
  1. package/CHANGELOG.md +1109 -0
  2. package/README.md +76 -10
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/docs/styles/sheet-8-styles.png +0 -0
  5. package/docs/styles/sheet-before-after.png +0 -0
  6. package/index.js +4 -1
  7. package/package.json +7 -3
  8. package/schemas/film.schema.json +329 -2
  9. package/skills/video-studio/SKILL.md +270 -13
  10. package/skills/video-studio/assets/golden_frames.json +65 -0
  11. package/skills/video-studio/assets/scenes.py +2162 -1190
  12. package/skills/video-studio/assets/web/engine.js +63 -2
  13. package/skills/video-studio/assets/web/film.js +224 -101
  14. package/skills/video-studio/assets/web/index.html +4 -2
  15. package/skills/video-studio/assets/web/qa_text.js +36 -8
  16. package/skills/video-studio/references/audio-variation.md +4 -3
  17. package/skills/video-studio/references/audio.md +2 -2
  18. package/skills/video-studio/references/change-impact.md +1 -1
  19. package/skills/video-studio/references/quality-gates.md +25 -3
  20. package/skills/video-studio/references/scene-grammar.md +306 -0
  21. package/skills/video-studio/references/style-packs.md +108 -3
  22. package/skills/video-studio/references/v08-quality.md +256 -0
  23. package/skills/video-studio/references/web-engine.md +19 -2
  24. package/skills/video-studio/references/word-timeline.md +147 -0
  25. package/skills/video-studio/requirements.txt +14 -0
  26. package/skills/video-studio/scripts/filmcheck.py +218 -0
  27. package/skills/video-studio/scripts/filmkit.py +521 -38
  28. package/skills/video-studio/scripts/gates.py +88 -4
  29. package/skills/video-studio/scripts/grammar.py +559 -0
  30. package/skills/video-studio/scripts/preflight.py +774 -0
  31. package/skills/video-studio/scripts/qa_frames.py +49 -8
  32. package/skills/video-studio/scripts/qaasr.py +1563 -0
  33. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  34. package/skills/video-studio/scripts/render.py +764 -32
  35. package/skills/video-studio/scripts/scorekit.py +16 -30
  36. package/skills/video-studio/scripts/selfcheck.py +483 -0
  37. package/skills/video-studio/scripts/sfxkit.py +130 -14
  38. package/skills/video-studio/scripts/stagewatch.py +19 -6
  39. package/skills/video-studio/scripts/stylekit.py +242 -12
  40. package/skills/video-studio/scripts/voicekit.py +15 -7
  41. package/skills/video-studio/scripts/webengine.py +1119 -72
  42. package/tools/ci_check.py +173 -31
  43. package/tools/estimate.py +130 -19
  44. package/tools/golden_frames.py +664 -0
  45. package/tools/silhouette_qa.py +910 -0
@@ -1,1190 +1,2162 @@
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 — 场景绘制模板(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
+ # 三、场景语法(v0.9:10 类构图 × 8 套视觉语言)
752
+ # ============================================================================
753
+ #
754
+ # 为什么重写这一节
755
+ # ---------------
756
+ # v0.5 的 _draw_hook / _draw_body / _draw_outro 是"一套骨架 + 换色":背景板 → 眉标 →
757
+ # 大字 → 副标题 → 强调线 → HUD,三种 kind 只差字号与位置。于是 8 套风格换完只剩配色不同。
758
+ # v0.9 把"画什么"(scene type,由 grammar.py 按内容选)与"怎么画"(视觉语言,由 stylekit
759
+ # 的 VISUAL_LANGUAGES 给)拆开:
760
+ # · 10 类构图各自有独立的视觉重心与留白(hero 偏左上 / statement 正中 / compare 强制对称 …)
761
+ # · 每套风格有自己的装饰母题(括角 / 手绘内框 / letterbox / 信号条 …)与转场签名
762
+ # · 片内还有 variant 轮换,避免"每屏同一个骨架"
763
+ #
764
+ # 性能纪律(这一节最容易把 25ms 预算吃光)
765
+ # · 装饰母题全部**预烘焙**成一张 RGBA 图层,逐帧只 paste(~0.3ms)
766
+ # · 文字用 _draw_text(字号不重采样,只过视差坐标)
767
+ # · 禁每帧全屏 GaussianBlur(30–60ms)/ 全屏双线性 resize(29ms)/ np.array 往返(8–11ms)
768
+ # · 段界 veil 只在段首 ≤0.45s 内生效,用 base plate 直接 paste/blend,不新增模糊
769
+
770
+ # ---------------- 视觉语言 / 场景字段 ----------------
771
+
772
+ _DEFAULT_LANG = {
773
+ "motion": {"ease": "out3", "enter": (0.45, 0.85), "push": 0.04, "breathe_hz": 0.85},
774
+ "motif": {"kind": "minimal", "density": 0.0, "detail": {}},
775
+ "transition": {"kind": "cut", "dur": 0.0},
776
+ "typography": {"tracking": 0.0, "line_gap": 1.30, "align": "left",
777
+ "number": "dim", "ratio": 1.50},
778
+ "layout_cycle": ("left-stack",),
779
+ }
780
+
781
+ _DECOR_LEVELS = (0.55, 0.75, 1.0)
782
+
783
+
784
+ def _lang(plan, th):
785
+ """本片的视觉语言包。plan["language"] 由 stylekit 解析时塞进来;没有就按主题 id 查。"""
786
+ got = (plan or {}).get("language")
787
+ if isinstance(got, dict):
788
+ return got
789
+ try:
790
+ return ST.visual_language(str((th or {}).get("id") or "tech-deep"))
791
+ except Exception: # noqa: BLE001 - 老 stylekit 没有这一层
792
+ return _DEFAULT_LANG
793
+
794
+
795
+ def _scene_type(scene):
796
+ """本段用哪个绘制器:优先 timeline 里 grammar 写好的 scene_type,其次按 kind 映射。"""
797
+ t = str(scene.get("scene_type") or "").strip()
798
+ if t:
799
+ return t
800
+ kind = str(scene.get("kind") or "body")
801
+ return {"hook": "hero", "outro": "outro", "quote": "quote",
802
+ "emphasis": "statement", "calm": "statement"}.get(kind, "list")
803
+
804
+
805
+ def _decor_scale(scene, lang):
806
+ """装饰强度 = 片内密度档(0/1/2 -> 0.55/0.75/1.0)× 该风格母题密度。"""
807
+ try:
808
+ lvl = int(scene.get("scene_decor") or 0)
809
+ except (TypeError, ValueError, OverflowError):
810
+ lvl = 0
811
+ lvl = max(0, min(2, lvl))
812
+ base = _DECOR_LEVELS[lvl]
813
+ dens = float((lang.get("motif") or {}).get("density") or 1.0)
814
+ return base * dens
815
+
816
+
817
+ def _progress(tg, script):
818
+ """全片进度(0..1),HUD 进度条用。_total 由 render.py 注入(st_frames/st_stills)。"""
819
+ return clamp(float(tg) / max(0.1, float(script.get("_total") or 1.0)))
820
+
821
+
822
+ def _phase(scene):
823
+ """段相位(弧度)。NaN/Inf 一律当 0 —— 它们会让 int(round()) 直接抛
824
+ ValueError: cannot convert float NaN to integer,而那是在渲染第 N 帧才炸。"""
825
+ try:
826
+ v = float(scene.get("scene_phase") or 0.0)
827
+ except (TypeError, ValueError):
828
+ return 0.0
829
+ if v != v or v in (float("inf"), float("-inf")): # NaN / ±Inf
830
+ return 0.0
831
+ return v
832
+
833
+
834
+ def _variant(scene):
835
+ try:
836
+ return int(scene.get("scene_variant") or 0) % 3
837
+ except (TypeError, ValueError, OverflowError):
838
+ return 0
839
+
840
+
841
+ def _scene_layout(scene, th):
842
+ """本段版式:grammar 按 chapter 写好的 scene_layout 优先,其次主题默认。"""
843
+ name = str(scene.get("scene_layout") or th.get("layout") or "left-stack")
844
+ return ST.LAYOUTS.get(name) or ST.LAYOUTS["left-stack"]
845
+
846
+
847
+ def _typo(lang):
848
+ return lang.get("typography") or _DEFAULT_LANG["typography"]
849
+
850
+
851
+ def _bias(lang):
852
+ """该风格的整体重心偏移 (dx, dy, inset),单位 px。
853
+
854
+ 为什么需要它:剪影判据里 Cx/Cy 是"前景质心",而"同样左对齐、同样字号"的两套浅色主题
855
+ (paper / lo-fi)即使装饰不同,质心也几乎重合——实测这对 ΔCx 只有 2.3pp。
856
+ 把"这套风格的画面重心往哪偏"做成数据,是最便宜、也最像"导演习惯"的差异化手段。
857
+ """
858
+ b = (lang or {}).get("bias") or {}
859
+ try:
860
+ return (float(b.get("x") or 0.0), float(b.get("y") or 0.0), float(b.get("inset") or 0.0))
861
+ except (TypeError, ValueError):
862
+ return (0.0, 0.0, 0.0)
863
+
864
+
865
+ def _tracking(th, lang):
866
+ return float(th.get("tracking") or 0.0) + float(_typo(lang).get("tracking") or 0.0)
867
+
868
+
869
+ def _gap(th, lang):
870
+ return float(th.get("line_gap") or _typo(lang).get("line_gap") or 1.3)
871
+
872
+
873
+ # ---------------- 装饰母题(一次烘焙,逐帧只 paste) ----------------
874
+
875
+ def _motif_key(th, lang):
876
+ """母题图层/底版的缓存键(主题 + 母题 + 密度 + 参与绘制的颜色)。
877
+
878
+ 单独抽出来是为了让 _motif_layer(RGBA 图层)与 _plate(合成后的底版)用同一把钥匙:
879
+ 两者必须一起失效,否则改了主题色会拿到"新图层 + 旧底版"的错配画面。
880
+ """
881
+ mf = lang.get("motif") or _DEFAULT_LANG["motif"]
882
+ return _hashable(("motif", th.get("id"), mf.get("kind"),
883
+ tuple(sorted((str(k), _hashable(v)) for k, v in (mf.get("detail") or {}).items())),
884
+ round(float(mf.get("density") or 0.0), 3),
885
+ th["accent"], th["rule"], th["bg"], th["bg2"]))
886
+
887
+
888
+ def _motif_layer(th, lang):
889
+ """把该风格独有的装饰图形烘焙成一张全屏 RGBA 图层(不含动态)。
890
+
891
+ 为什么烘焙:这些图形是静态的(括角、页眉线、letterbox、信号条),
892
+ 逐帧重画一次就是 3–8ms。注意:v0.9.0 这里写的是"paste 只要 0.3ms",
893
+ 实测全屏 RGBA paste 是 **5.6ms**(Pillow 13 / 1080p),所以 v0.9.1 起
894
+ 装饰直接烘焙进底版(见 _plate),逐帧只 copy 底版,不再单独 paste。
895
+ """
896
+ mf = lang.get("motif") or _DEFAULT_LANG["motif"]
897
+ key = _motif_key(th, lang)
898
+ got = _CACHE.get(key)
899
+ if got is not None:
900
+ return got
901
+ kind = str(mf.get("kind") or "minimal")
902
+ det = mf.get("detail") or {}
903
+ dens = float(mf.get("density") or 0.0)
904
+ lay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
905
+ d = ImageDraw.Draw(lay, "RGBA")
906
+ accent = th["accent"]
907
+ rule = th["rule"]
908
+ ink = th["ink"]
909
+ dark = ST.is_dark(th)
910
+
911
+ def a(v):
912
+ return int(max(0, min(255, round(v))))
913
+
914
+ if kind == "brackets":
915
+ # 四角 L 形括角 + 右侧数据刻度尺:科技风的"仪器感"
916
+ c = int(det.get("corner") or 24)
917
+ t = 2
918
+ for (x, y, dx, dy) in ((48, 40, 1, 1), (W - 48, 40, -1, 1),
919
+ (48, H - 48, 1, -1), (W - 48, H - 48, -1, -1)):
920
+ d.line([(x, y), (x + dx * c, y)], fill=_rgba(accent, a(150 * dens)), width=t)
921
+ d.line([(x, y), (x, y + dy * c)], fill=_rgba(accent, a(150 * dens)), width=t)
922
+ step = int(det.get("rule_step") or 120)
923
+ for i, yy in enumerate(range(150, 860, step)):
924
+ long = (i % 3 == 0)
925
+ d.line([(W - 34, yy), (W - 34 + (18 if long else 10), yy)],
926
+ fill=_rgba(rule, a(70 * dens)), width=1)
927
+ if det.get("scan"):
928
+ d.rectangle([0, 120, W, 124], fill=_rgba(rule, a(18 * dens)))
929
+ elif kind == "hairlines":
930
+ # 杂志:双横线页眉 + 栏间细线 + 右上页码位
931
+ d.rectangle([64, 112, W - 64, 114], fill=_rgba(rule, a(120 * dens)))
932
+ d.rectangle([64, 118, W - 64, 119], fill=_rgba(rule, a(60 * dens)))
933
+ if det.get("column_rule"):
934
+ d.rectangle([W // 2, 160, W // 2 + 1, 820], fill=_rgba(rule, a(26 * dens)))
935
+ d.rectangle([64, 838, W - 64, 839], fill=_rgba(rule, a(70 * dens)))
936
+ elif kind == "handdrawn":
937
+ # 纸感:不规则内框(vnoise 抖动)+ 四角胶带 + 方印
938
+ wob = float(det.get("wobble") or 3.0)
939
+ # 底边收在 1016:字幕带从 872 开始,外框若画到 1024 会被字幕底板压住一截,
940
+ # 看起来像"框没画完"。
941
+ x0, y0, x1, y1 = 56, 52, W - 56, 1016
942
+ n = 10
943
+ pts = []
944
+ for i in range(n): # 上边
945
+ pts.append((x0 + (x1 - x0) * i / (n - 1.0), y0 + wob * (K.vnoise(i * 0.7, 5) - 0.5)))
946
+ for i in range(n): # 右边
947
+ pts.append((x1 + wob * (K.vnoise(i * 0.9, 7) - 0.5), y0 + (y1 - y0) * i / (n - 1.0)))
948
+ for i in range(n): # 下边
949
+ pts.append((x1 - (x1 - x0) * i / (n - 1.0), y1 + wob * (K.vnoise(i * 0.6, 9) - 0.5)))
950
+ for i in range(n): # 左边
951
+ pts.append((x0 + wob * (K.vnoise(i * 0.8, 11) - 0.5), y1 - (y1 - y0) * i / (n - 1.0)))
952
+ d.line(pts + [pts[0]], fill=_rgba(rule, a(120 * dens)), width=2, joint="curve")
953
+ if det.get("tape"):
954
+ for (tx, ty, ww, hh) in ((44, 40, 96, 26), (W - 140, 40, 96, 26)):
955
+ d.polygon([(tx, ty + 4), (tx + ww, ty), (tx + ww, ty + hh), (tx, ty + hh + 4)],
956
+ fill=_rgba(_mix(ink, th["bg"], 0.72), a(70 * dens)))
957
+ if det.get("seal"):
958
+ sx, sy = W - 150, 128
959
+ d.rectangle([sx, sy, sx + 58, sy + 58], outline=_rgba(th["accent2"], a(150 * dens)),
960
+ width=3)
961
+ elif kind == "slabs":
962
+ # 撞色:粗色条横贯 + 斜切块 + 圆形贴纸
963
+ bh = int(det.get("bar_h") or 24)
964
+ d.rectangle([0, 118, W, 118 + bh], fill=_rgba(accent, a(200 * dens)))
965
+ sl = int(det.get("slant") or 18)
966
+ d.polygon([(0, 118 + bh), (W, 118 + bh), (W, 118 + bh + sl), (0, 118 + bh + sl + 8)],
967
+ fill=_rgba(th["accent2"], a(120 * dens)))
968
+ d.rectangle([0, 846, W, 852], fill=_rgba(accent, a(140 * dens)))
969
+ if det.get("sticker"):
970
+ for (cx, cy, r) in ((96, 196, 34), (W - 96, 196, 22)):
971
+ d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=_rgba(accent, a(190 * dens)),
972
+ width=8)
973
+ elif kind == "minimal":
974
+ # 极简:只有一条 2px 横线(其余全是留白,这就是它的"装饰语言")
975
+ d.rectangle([W // 2 - 600, 700, W // 2 + 600, 702], fill=_rgba(rule, a(200 * dens)))
976
+ d.rectangle([W // 2 - 120, 742, W // 2 + 120, 743], fill=_rgba(rule, a(90 * dens)))
977
+ elif kind == "letterbox":
978
+ # 电影:上下遮幅(画幅外一律黑),内缘一条 1px 亮线
979
+ top = int(det.get("top") or 40)
980
+ bot = int(det.get("bottom") or 44)
981
+ d.rectangle([0, 0, W, top], fill=(0, 0, 0, 255))
982
+ d.rectangle([0, H - bot, W, H], fill=(0, 0, 0, 255))
983
+ d.rectangle([0, top, W, top + 1], fill=_rgba(rule, a(40 * dens)))
984
+ d.rectangle([0, H - bot - 1, W, H - bot], fill=_rgba(rule, a(40 * dens)))
985
+ if det.get("flares"):
986
+ img = Image.fromarray(np.zeros((2, 2, 4), dtype=np.uint8), "RGBA")
987
+ elif kind == "signal":
988
+ # lo-fi:底部 12 格信号条 + 时间戳位
989
+ bars = int(det.get("bars") or 12)
990
+ bw, gap = 10, 6
991
+ total = bars * bw + (bars - 1) * gap
992
+ x0 = W - 64 - total
993
+ y0 = 806
994
+ for i in range(bars):
995
+ on = i < bars - 2
996
+ d.rectangle([x0 + i * (bw + gap), y0, x0 + i * (bw + gap) + bw, y0 + 14],
997
+ fill=_rgba(accent if on else rule, a((170 if on else 40) * dens)))
998
+ elif kind == "rays":
999
+ # 史诗:上下双装饰线 + 18 条放射线 + 内细框
1000
+ d.rectangle([0, 104, W, 106], fill=_rgba(accent, a(150 * dens)))
1001
+ d.rectangle([0, 110, W, 111], fill=_rgba(accent, a(60 * dens)))
1002
+ d.rectangle([0, H - 112, W, H - 110], fill=_rgba(accent, a(150 * dens)))
1003
+ d.rectangle([0, H - 106, W, H - 105], fill=_rgba(accent, a(60 * dens)))
1004
+ cx, cy = W * 0.5, 470.0
1005
+ for i in range(int(det.get("rays") or 18)):
1006
+ ang = (i / 18.0) * 2.0 * math.pi
1007
+ r0, r1 = 250.0, 430.0 + 40.0 * K.hash2(i, 3)
1008
+ d.line([(cx + math.cos(ang) * r0, cy + math.sin(ang) * r0 * 0.62),
1009
+ (cx + math.cos(ang) * r1, cy + math.sin(ang) * r1 * 0.62)],
1010
+ fill=_rgba(accent, a(26 * dens)), width=1)
1011
+ if det.get("frame"):
1012
+ d.rectangle([52, 96, W - 52, 1016], outline=_rgba(accent, a(50 * dens)), width=1)
1013
+ _CACHE[key] = lay
1014
+ return lay
1015
+
1016
+
1017
+ _PLATE_ORDER = []
1018
+
1019
+
1020
+ def _plate(th, lang, scene, s_bg):
1021
+ """背景板 + 装饰母题合成后的整帧底版(v0.9.1)。
1022
+
1023
+ 这是单帧预算里最大的一处优化:装饰是静态的,逐帧单独 paste 一张全屏 RGBA 要 5.6ms,
1024
+ 合成进底版后逐帧只剩 copy(1.3ms)或 EXTENT 缩放(1.7–2.0ms)。
1025
+ 顺带一个好处:镜头缓推现在会把装饰一起推——装饰本来就该跟着镜头走,
1026
+ 以前"背景推、装饰不动"在强 push 的风格(cinema 0.12 / epic 0.14)里其实是穿帮。
1027
+
1028
+ 缓存:一个片子只用一套主题 + 一套母题,密度档最多 3 档 -> 最多 3 张底版(约 18MB)。
1029
+ 用 LRU 限 4 张,防多片/多主题同进程时无限增长。
1030
+ """
1031
+ base = _bg(th)
1032
+ if _decor_scale(scene, lang) <= 0.02:
1033
+ if s_bg <= 1.0008:
1034
+ return base.copy()
1035
+ return _bg_plate(th, s_bg)
1036
+ key = _hashable(("plate", _motif_key(th, lang),
1037
+ int(max(0, min(2, int(scene.get("scene_decor") or 0))))))
1038
+ got = _CACHE.get(key)
1039
+ if got is None:
1040
+ lay = _motif_layer(th, lang)
1041
+ got = base.copy()
1042
+ got.paste(lay, (0, 0), lay)
1043
+ _CACHE[key] = got
1044
+ _PLATE_ORDER.append(key)
1045
+ while len(_PLATE_ORDER) > 4: # LRU:只留最近 4 张
1046
+ old = _PLATE_ORDER.pop(0)
1047
+ if old != key:
1048
+ _CACHE.pop(old, None)
1049
+ if s_bg <= 1.0008:
1050
+ return got.copy()
1051
+ bw, bh = W / s_bg, H / s_bg
1052
+ x0 = (W - bw) * 0.5
1053
+ y0 = (H - bh) * 0.5
1054
+ return got.transform((W, H), Image.EXTENT, (x0, y0, x0 + bw, y0 + bh), Image.NEAREST)
1055
+
1056
+
1057
+ def _apply_motif(img, th, lang, scene):
1058
+ """逐帧贴装饰层(0.3ms)。密度为 0 时直接跳过。"""
1059
+ if _decor_scale(scene, lang) <= 0.02:
1060
+ return img
1061
+ lay = _motif_layer(th, lang)
1062
+ img.paste(lay, (0, 0), lay)
1063
+ return img
1064
+
1065
+
1066
+ # ---------------- 段界转场(veil:只在段首 ≤0.45s 内生效) ----------------
1067
+
1068
+ def _apply_veil(img, th, lang, scene, t_local, s_bg):
1069
+ """段首从背景"长出来"的过场。不做跨场景 cross-dissolve(draw_frame 拿不到上一段帧),
1070
+ 只做"从本段背景板渐显 / 擦入",观感是 dip 而不是 dissolve。
1071
+
1072
+ 为什么必须做:v0.9 起相邻两段可能是完全不同的构图(数字卡 → 左右对比),
1073
+ 整屏瞬切会精确触发 qa_frames 的单帧闪烁门禁(8x8 分块灰度差 ≥6.0),
1074
+ 观众看着也像"换了一部片"。
1075
+ """
1076
+ tr = lang.get("transition") or _DEFAULT_LANG["transition"]
1077
+ kind = str(tr.get("kind") or "cut")
1078
+ dur = float(tr.get("dur") or 0.0)
1079
+ if kind == "cut" or dur <= 0.01 or t_local >= dur:
1080
+ return img
1081
+ p = clamp(t_local / dur)
1082
+ base = _plate(th, lang, scene, s_bg)
1083
+ if kind in ("dip", "wash"):
1084
+ # dip/wash 的差别是"淡向哪一层":wash 淡向主题底色(浅色主题不出现黑场),
1085
+ # dip 也淡向 bg2 —— 刻意不淡向纯黑,否则暗色主题会撞黑帧门禁(平均亮度 <0.045)。
1086
+ return Image.blend(base, img, float(p))
1087
+ # 擦入类:用背景板盖住"还没露出来"的部分(paste 比 mask 便宜)
1088
+ if kind == "wipe": # 自左向右
1089
+ cut = int(round(W * float(p)))
1090
+ if cut < W:
1091
+ img.paste(base.crop((cut, 0, W, H)), (cut, 0))
1092
+ return img
1093
+ if kind == "paper": # 自右向左(纸卷)
1094
+ cut = int(round(W * (1.0 - float(p))))
1095
+ if cut > 0:
1096
+ img.paste(base.crop((0, 0, cut, H)), (0, 0))
1097
+ return img
1098
+ if kind == "iris": # 中心向两侧展开
1099
+ half = int(round(W * 0.5 * float(p)))
1100
+ if half < W // 2:
1101
+ img.paste(base.crop((0, 0, W // 2 - half, H)), (0, 0))
1102
+ img.paste(base.crop((W // 2 + half, 0, W, H)), (W // 2 + half, 0))
1103
+ return img
1104
+ return img
1105
+
1106
+
1107
+ # ---------------- 通用:数字(等宽)+ 编号写法 ----------------
1108
+
1109
+ _NUM_WEIGHT = "mono"
1110
+
1111
+
1112
+ def _draw_num(img, th, v, text, x, y, size, color, align="center", alpha=1.0):
1113
+ """数字用等宽字体:滚动/跳变时字宽不抖(consola 只有 ASCII,中文标签另用 reg)。"""
1114
+ _draw_text(img, th, v, text, x, y, size, _NUM_WEIGHT, color, align, alpha)
1115
+
1116
+
1117
+ def _chapter_index(chapter):
1118
+ """从 "03 · 画质关键" 里取 3;取不到返回 None(章节卡要用巨型编号)。"""
1119
+ s = str(chapter or "")
1120
+ for i in range(len(s) - 1):
1121
+ if s[i].isdigit() and s[i + 1].isdigit():
1122
+ return int(s[i:i + 2])
1123
+ for ch in s:
1124
+ if ch.isdigit():
1125
+ return int(ch)
1126
+ return None
1127
+
1128
+
1129
+ _CN_NUM = "零一二三四五六七八九十"
1130
+
1131
+
1132
+ def _number_text(style, n, fallback=""):
1133
+ """按视觉语言的编号写法生成编号文本(dim/serif/cn/block/roman/timecode/dot/gilt)。"""
1134
+ if n is None:
1135
+ return fallback
1136
+ if style in ("dim", "serif", "gilt"):
1137
+ return "%02d" % n
1138
+ if style == "cn":
1139
+ return _CN_NUM[n] + "、" if 0 < n < 11 else "%02d" % n
1140
+ if style == "block":
1141
+ return "%02d" % n
1142
+ if style == "roman":
1143
+ rom = ("I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X")
1144
+ return rom[n - 1] if 0 < n <= 10 else "%02d" % n
1145
+ if style == "timecode":
1146
+ return "00:%02d" % (n * 4 % 60)
1147
+ if style == "dot":
1148
+ return "· %02d" % n
1149
+ return "%02d" % n
1150
+
1151
+
1152
+ # ---------------- 1) hero 封面 ----------------
1153
+
1154
+ def _draw_hero(scene, t, dur, tg, script, frame, plan, th):
1155
+ lang = _lang(plan, th)
1156
+ s_bg = _push_scale(th, t, dur)
1157
+ v = _view(th, s_bg)
1158
+ img = _plate(th, lang, scene, s_bg)
1159
+ lay = _scene_layout(scene, th)
1160
+ var = _variant(scene)
1161
+ ease = _ease((lang.get("motion") or {}).get("ease") or th.get("ease") or "out3")
1162
+ trk = _tracking(th, lang)
1163
+ gap = _gap(th, lang)
1164
+ ph = _phase(scene)
1165
+
1166
+ # 重心:variant 决定标题落在左/中/右 —— 这是"换版式"最便宜也最明显的一种;
1167
+ # 再叠上该风格的 bias(比如 paper 整体压左下、lo-fi 整体偏右上)。
1168
+ bdx, bdy, bins = _bias(lang)
1169
+ if lay.get("center") or var == 2:
1170
+ ax, align = W * 0.5 + bdx, "center"
1171
+ elif var == 1:
1172
+ ax, align = float(W - 150) + bdx, "right"
1173
+ else:
1174
+ ax, align = 150.0 + bdx + bins, "left"
1175
+
1176
+
1177
+ # 持续动态:背景里缓慢扩散的同心弧(每套风格都在动,但颜色/透明度随主题)
1178
+ p_ring = smooth(rng(t, 0.15, 2.4))
1179
+ d = ImageDraw.Draw(img, "RGBA")
1180
+ cx, cy = (ax, 470.0) if align != "center" else (W * 0.5, 430.0)
1181
+ for i in range(3):
1182
+ rr = (120.0 + 260.0 * i) * (0.4 + 0.6 * p_ring)
1183
+ d.arc([v.x(cx - rr), v.y(cy - rr * 0.62), v.x(cx + rr), v.y(cy + rr * 0.62)],
1184
+ start=int(200 + i * 40), end=int(340 + i * 40),
1185
+ fill=_rgba(th["accent"], int(46 * p_ring)), width=2)
1186
+
1187
+ size = int(th["scale"]["display"][0])
1188
+ big = scene.get("big") or script.get("title") or ""
1189
+ max_w = min(1560.0, float(W - 300))
1190
+ lines = _wrap(big, size, max_w, "bold", th)
1191
+ while len(lines) > 3 and size > 42:
1192
+ size = int(size * 0.88)
1193
+ lines = _wrap(big, size, max_w, "bold", th)
1194
+ lh = int(size * gap)
1195
+ ksize = int(th["scale"]["kicker"][0])
1196
+ kicker = scene.get("kicker") or "00 · 开场"
1197
+
1198
+ p0 = ease(rng(t, 0.05, 0.85))
1199
+ _draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
1200
+ float(lay["kicker_y"]) + _entrance_y(th, p0), ksize, "bold",
1201
+ th["accent"], align, p0, trk * 0.5)
1202
+
1203
+ y = float(lay["title_y"]) + bdy
1204
+ for i, ln in enumerate(lines):
1205
+ p = ease(rng(t, 0.12 + 0.13 * i, 0.88 + 0.13 * i))
1206
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p, align),
1207
+ y + i * lh + _entrance_y(th, p), size, "bold",
1208
+ th["ink"], align, p, trk)
1209
+
1210
+ sub = scene.get("lead") or scene.get("sub") or ""
1211
+ # 副标题与大字重复时不要画第二遍:模板片里 big 与 sub 都是"零素材,全代码生成",
1212
+ # 照抄会让画面出现两行一样的字(v0.9 实测第一版就是这样)。
1213
+ if sub and (str(sub).strip() == str(big).strip()
1214
+ or (len(str(sub)) >= 6 and str(sub).strip() in str(big).strip())):
1215
+ sub = ""
1216
+ sub_bottom = y + len(lines) * lh
1217
+ if sub:
1218
+ ssize = int(th["scale"]["sub"][0])
1219
+ sy = sub_bottom + int(size * 0.24)
1220
+ p2 = ease(rng(t, 0.30, 1.40))
1221
+ for j, ln in enumerate(_wrap(sub, ssize, float(lay["sub_w"]), "light", th)[:3]):
1222
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p2, align),
1223
+ sy + j * int(ssize * gap), ssize, "light", th["muted"], align, p2)
1224
+ sub_bottom = sy + int(ssize * gap)
1225
+
1226
+ # 持续动态:标题下呼吸强调线(相位随段偏移,避免每段同频"复印")
1227
+ hz = float((lang.get("motion") or {}).get("breathe_hz") or 0.85)
1228
+ amp = 0.45 + 0.55 * (0.5 + 0.5 * math.sin(hz * t + ph))
1229
+ line_w = int(min(float(lay["title_w"]), 760) * 0.55 * amp)
1230
+ line_y = sub_bottom + int(size * 0.42)
1231
+ if line_y < 856:
1232
+ lx = ax if align == "left" else (ax - line_w if align == "right" else ax - line_w // 2)
1233
+ ImageDraw.Draw(img, "RGBA").line(
1234
+ [v.x(lx), v.y(line_y), v.x(lx + line_w), v.y(line_y)],
1235
+ fill=_rgba(th["accent"], 190), width=3)
1236
+
1237
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1238
+ return _hud(img, th, scene.get("chapter") or "01 · 开场", _progress(tg, script),
1239
+ script.get("title", ""))
1240
+
1241
+
1242
+ # ---------------- 2) statement 金句(居中大字,无卡片) ----------------
1243
+
1244
+ def _draw_statement(scene, t, dur, tg, script, frame, plan, th):
1245
+ lang = _lang(plan, th)
1246
+ s_bg = _push_scale(th, t, dur)
1247
+ v = _view(th, s_bg)
1248
+ img = _plate(th, lang, scene, s_bg)
1249
+ var = _variant(scene)
1250
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1251
+ trk = _tracking(th, lang)
1252
+ gap = _gap(th, lang)
1253
+ ph = _phase(scene)
1254
+
1255
+ big = str(scene.get("big") or scene.get("text") or "")[:24]
1256
+ size = int(th["scale"]["display"][0] * 1.10)
1257
+ max_w = 1400.0 if var != 1 else 1180.0
1258
+ lines = _wrap(big, size, max_w, "bold", th)
1259
+ while len(lines) > 2 and size > 52:
1260
+ size = int(size * 0.90)
1261
+ lines = _wrap(big, size, max_w, "bold", th)
1262
+ lh = int(size * gap)
1263
+ top = 470.0 - (len(lines) - 1) * lh * 0.5
1264
+ if var == 1: # variant 1:整体上移,留出落款区
1265
+ top -= 40.0
1266
+
1267
+ # 逐字显现:4 字一组(单字一次 text 太贵;整行一次又没"写出来"的感觉)
1268
+ reveal = clamp(t / max(0.35, min(1.6, dur * 0.45)))
1269
+ total_chars = sum(len(x) for x in lines)
1270
+ shown = int(total_chars * ease(reveal))
1271
+ walked = 0
1272
+ for i, ln in enumerate(lines):
1273
+ if walked >= shown:
1274
+ break
1275
+ take = max(0, min(len(ln), shown - walked))
1276
+ walked += len(ln)
1277
+ seg = ln[:take]
1278
+ if not seg:
1279
+ continue
1280
+ w = _text_w(seg, size, "bold", th)
1281
+ x0 = (W - w) * 0.5
1282
+ _draw_text(img, th, v, seg, x0, top + i * lh, size, "bold", th["ink"],
1283
+ "left", 1.0, trk)
1284
+
1285
+ # 持续动态:字距呼吸 + 下划线从左生长(保证"3–6 秒内有可见变化")
1286
+ hz = float((lang.get("motion") or {}).get("breathe_hz") or 0.85)
1287
+ bre = 0.5 + 0.5 * math.sin(hz * 0.9 * t + ph)
1288
+ ul_w = int(280 * (0.7 + 0.3 * bre))
1289
+ ul_y = top + len(lines) * lh + int(size * 0.22)
1290
+ if ul_y < 856:
1291
+ d = ImageDraw.Draw(img, "RGBA")
1292
+ x0 = W * 0.5 - ul_w * 0.5
1293
+ d.line([v.x(x0), v.y(ul_y), v.x(x0 + ul_w), v.y(ul_y)],
1294
+ fill=_rgba(th["accent"], 170), width=3)
1295
+
1296
+ sub = scene.get("sub") or ""
1297
+ if sub and (str(sub).strip() == str(big).strip()
1298
+ or (len(str(sub)) >= 6 and str(sub).strip() in str(big).strip())):
1299
+ sub = "" # 与大字重复的落款不画(理由同 hero)
1300
+ if sub and var != 2:
1301
+ ssize = int(th["scale"]["sub"][0])
1302
+ p2 = ease(rng(t, 0.55, 1.55))
1303
+ for j, ln in enumerate(_wrap(sub, ssize, 1100, "light", th)[:2]):
1304
+ _draw_text(img, th, v, ln, W * 0.5,
1305
+ top + len(lines) * lh + int(size * 0.55) + j * int(ssize * gap),
1306
+ ssize, "light", th["muted"], "center", p2)
1307
+
1308
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1309
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1310
+ script.get("title", ""))
1311
+
1312
+
1313
+ # ---------------- 3) list 要点(左标题 + 右条目 / 全宽序号列) ----------------
1314
+
1315
+ def _draw_list(scene, t, dur, tg, script, frame, plan, th):
1316
+ lang = _lang(plan, th)
1317
+ s_bg = _push_scale(th, t, dur)
1318
+ v = _view(th, s_bg)
1319
+ img = _plate(th, lang, scene, s_bg)
1320
+ lay = _scene_layout(scene, th)
1321
+ var = _variant(scene)
1322
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1323
+ trk = _tracking(th, lang)
1324
+ gap = _gap(th, lang)
1325
+ ph = _phase(scene)
1326
+
1327
+ items = list(scene.get("items") or [])
1328
+ stagger = float((plan.get("pacing") or {}).get("item_stagger") or 0.32)
1329
+ nstyle = str(_typo(lang).get("number") or "dim")
1330
+
1331
+ # variant 0/2:左标题 + 右条目列;variant 1:全宽(标题在上,序号列在左)
1332
+ full = (var == 1) or lay.get("center")
1333
+ bdx, bdy, bins = _bias(lang)
1334
+ if full:
1335
+ ax, align = 150.0 + bdx + bins, "left"
1336
+ title_w = 1620.0
1337
+ panel_x, panel_w = 150.0 + bdx + bins, 1620.0
1338
+ panel_y = 400.0 + bdy
1339
+ else:
1340
+ ax, align = _title_anchor(lay)
1341
+ ax += bdx + (bins if align == "left" else 0.0)
1342
+ title_w = float(lay["title_w"])
1343
+ panel_x, panel_w = _panel_x(lay) + bdx, float(lay["panel_w"])
1344
+ panel_y = max(float(lay["panel_y"]) + bdy, 210.0)
1345
+
1346
+ kicker = scene.get("kicker") or ""
1347
+ p0 = ease(rng(t, 0.0, 0.75))
1348
+ if kicker:
1349
+ _draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
1350
+ float(lay["kicker_y"]) + _entrance_y(th, p0),
1351
+ int(th["scale"]["kicker"][0]), "bold", th["accent"], align, p0, trk * 0.5)
1352
+
1353
+ big = scene.get("big") or (scene.get("text") or "")[:18]
1354
+ bsize = int(th["scale"]["heading"][0])
1355
+ lines = _wrap(big, bsize, title_w, "bold", th)
1356
+ while len(lines) > 3 and bsize > 40:
1357
+ bsize = int(bsize * 0.9)
1358
+ lines = _wrap(big, bsize, title_w, "bold", th)
1359
+ lh = int(bsize * gap)
1360
+ p1 = ease(rng(t, 0.06, 0.80))
1361
+ y0 = float(lay["title_y"]) + bdy + _entrance_y(th, p1)
1362
+ for i, ln in enumerate(lines):
1363
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p1, align),
1364
+ y0 + i * lh, bsize, "bold", th["ink"], align, p1, trk)
1365
+ sub_bottom = y0 + len(lines) * lh
1366
+
1367
+ sub = scene.get("sub") or ""
1368
+ if sub:
1369
+ ssize = int(th["scale"]["body"][0])
1370
+ sy = max(sub_bottom + int(bsize * 0.30),
1371
+ float(lay["sub_y"]) if not lay.get("center") else 0.0)
1372
+ p2 = ease(rng(t, 0.20, 0.95))
1373
+ for j, ln in enumerate(_wrap(sub, ssize, title_w, "reg", th)[:2]):
1374
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p2, align),
1375
+ sy + j * int(ssize * gap), ssize, "reg", th["muted"], align, p2)
1376
+ sub_bottom = sy + int(ssize * gap)
1377
+
1378
+ if not items:
1379
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1380
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1381
+ script.get("title", ""))
1382
+
1383
+ # 条目几何:全宽模式一行一条;双栏模式按行距排
1384
+ top = max(panel_y, sub_bottom + int(bsize * 0.45)) if full else panel_y
1385
+ top = max(top, 200.0)
1386
+ body = max(22, int(th["scale"]["body"][0]) - 1)
1387
+ # 行高:优先"每条 96–118px",条目多时往下压,硬下限 56px(再低中文就糊成一片)。
1388
+ # 为什么不是固定行高:模板片 5 条要点在 9.3s 的段里,固定 118px 会溢出 856 硬边界,
1389
+ # 于是第 5 条被静默截断(v0.9 第一版实测),观众看到"五步"只列出四步。
1390
+ avail = 856.0 - top
1391
+ row_h = avail / max(1, len(items)) if items else 118.0
1392
+ row_h = min(118.0, max(56.0, row_h - 6.0))
1393
+ shown = int(avail // row_h)
1394
+ if shown < len(items):
1395
+ _warn_once(("list", scene.get("id"), len(items)),
1396
+ "本段 %d 个条目在版式里只放得下 %d 个(其余已省略);请拆段或减条目"
1397
+ % (len(items), max(0, shown)))
1398
+ for i, it in enumerate(items[:max(0, shown)]):
1399
+ p = ease(rng(t, 0.42 + stagger * i, 1.12 + stagger * i))
1400
+ if p <= 0.0:
1401
+ continue
1402
+ txt = str(it.get("text") if isinstance(it, dict) else it)
1403
+ ok = bool(it.get("ok", True)) if isinstance(it, dict) else True
1404
+ yy = top + i * row_h
1405
+ alpha = clamp(p)
1406
+ # 焦点行:每 2.2s 换一条高亮(左侧竖条 + 行底微亮),保证画面不静止
1407
+ focus = int((t / 2.2) + ph) % max(1, min(len(items), 6))
1408
+ is_focus = (i == focus)
1409
+ d = ImageDraw.Draw(img, "RGBA")
1410
+ if is_focus:
1411
+ d.rectangle([v.x(panel_x - 26), v.y(yy + 8), v.x(panel_x - 20), v.y(yy + row_h - 18)],
1412
+ fill=_rgba(th["accent"], int(190 * alpha)))
1413
+ d.rectangle([v.x(panel_x - 14), v.y(yy + row_h - 14), v.x(panel_x + panel_w),
1414
+ v.y(yy + row_h - 12)],
1415
+ fill=_rgba(th["rule"], int(26 * alpha)))
1416
+ col = th["good"] if ok else th["bad"]
1417
+ img = _soft_dot(img, v.x(panel_x + 12), v.y(yy + row_h * 0.5),
1418
+ 7.0 + 1.4 * math.sin(2.0 * t + i + ph), col, 0.95 * alpha)
1419
+ d = ImageDraw.Draw(img, "RGBA")
1420
+ num = _number_text(nstyle, i + 1)
1421
+ nsize = int(body * 0.78)
1422
+ nx = panel_x + 34
1423
+ # 编号字体:纯数字/符号走等宽(滚动不抖),带中文的写法(一、二、三)退回 reg
1424
+ # —— consola 没有中文字形,用 mono 画"一、"会得到两个方框。
1425
+ nweight = "reg" if any("\u4e00" <= ch <= "\u9fff" for ch in num) else _NUM_WEIGHT
1426
+ _draw_text(img, th, v, num, nx, yy + (row_h - nsize * 1.25) * 0.5,
1427
+ nsize, nweight, th["dim"], "left", alpha * 0.9)
1428
+ tx = nx + max(70.0, _text_w(num, nsize, nweight, th) + 26.0)
1429
+ max_w = panel_x + panel_w - tx
1430
+ tsize = _fit_size(txt, max_w, body, "reg", th, 18)
1431
+ _draw_text(img, th, v, txt, tx, yy + (row_h - tsize * 1.2) * 0.5,
1432
+ tsize, "reg", th["ink"], "left", alpha)
1433
+
1434
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1435
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1436
+ script.get("title", ""))
1437
+
1438
+
1439
+ # ---------------- 4) stat 数字卡(巨数字 count-up) ----------------
1440
+
1441
+ def _draw_stat(scene, t, dur, tg, script, frame, plan, th):
1442
+ lang = _lang(plan, th)
1443
+ s_bg = _push_scale(th, t, dur)
1444
+ v = _view(th, s_bg)
1445
+ img = _plate(th, lang, scene, s_bg)
1446
+ var = _variant(scene)
1447
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1448
+ trk = _tracking(th, lang)
1449
+ ph = _phase(scene)
1450
+
1451
+ stats = scene.get("_stats")
1452
+ if not stats:
1453
+ try:
1454
+ import grammar as _G
1455
+ except Exception: # noqa: BLE001
1456
+ _G = None
1457
+ stats = (_G.derive_stats(scene) if _G is not None else []) or []
1458
+ if not stats:
1459
+ return _draw_statement(scene, t, dur, tg, script, frame, plan, th)
1460
+
1461
+ main = stats[0]
1462
+ raw = str(main.get("value") if main.get("value") is not None else "")
1463
+ unit = str(main.get("unit") or "")
1464
+ label = str(main.get("label") or "")
1465
+ notes = [s for s in stats[1:3] if str(s.get("label") or "")]
1466
+
1467
+ # count-up:0 -> target(整数位,小数位保留原样);跳变 ≤2 位/帧,避免闪烁门禁
1468
+ target = None
1469
+ try:
1470
+ target = float(raw)
1471
+ except (TypeError, ValueError):
1472
+ target = None
1473
+ p = ease(clamp(t / max(0.5, min(1.2, dur * 0.42))))
1474
+ if target is None:
1475
+ shown_txt = raw
1476
+ elif "." in raw:
1477
+ dec = len(raw.split(".")[1])
1478
+ shown_txt = ("%." + str(dec) + "f") % (target * p)
1479
+ else:
1480
+ shown_txt = "%d" % int(round(target * p))
1481
+
1482
+ size = int(240 * (1.0 if var != 1 else 0.86))
1483
+ max_w = 1500.0
1484
+ while size > 90 and _text_w(shown_txt + unit, size, _NUM_WEIGHT, th) > max_w:
1485
+ size = int(size * 0.9)
1486
+ cx = W * 0.5 if var != 1 else W * 0.5 - 160.0
1487
+ cy = 400.0
1488
+ total_w = _text_w(shown_txt, size, _NUM_WEIGHT, th) + _text_w(unit, int(size * 0.42), "reg", th)
1489
+ x0 = cx - total_w * 0.5
1490
+ _draw_num(img, th, v, shown_txt, x0, cy - size * 0.62, size, th["accent"], "left",
1491
+ 0.25 + 0.75 * p)
1492
+ if unit:
1493
+ pu = ease(rng(t, 0.35, 0.95))
1494
+ _draw_text(img, th, v, unit, x0 + _text_w(shown_txt, size, _NUM_WEIGHT, th) + 10,
1495
+ cy + size * 0.22, int(size * 0.42), "reg", th["muted"], "left", pu)
1496
+
1497
+ if label:
1498
+ pl = ease(rng(t, 0.45, 1.10))
1499
+ for j, ln in enumerate(_wrap(label, int(th["scale"]["sub"][0]), 1200, "light", th)[:2]):
1500
+ _draw_text(img, th, v, ln, W * 0.5,
1501
+ 660.0 + j * int(th["scale"]["sub"][0] * 1.3),
1502
+ int(th["scale"]["sub"][0]), "light", th["ink"], "center", pl)
1503
+
1504
+ # 持续动态:底部 12 格刻度逐格点亮(0.55s/格,循环)
1505
+ bars = 12
1506
+ bw, gapb = 46, 14
1507
+ tot = bars * bw + (bars - 1) * gapb
1508
+ bx = W * 0.5 - tot * 0.5
1509
+ lit = int((t / 0.55 + ph) % bars)
1510
+ d = ImageDraw.Draw(img, "RGBA")
1511
+ for i in range(bars):
1512
+ on = i <= lit
1513
+ d.rectangle([v.x(bx + i * (bw + gapb)), v.y(760), v.x(bx + i * (bw + gapb) + bw),
1514
+ v.y(768)], fill=_rgba(th["accent"] if on else th["rule"],
1515
+ int((150 if on else 34))))
1516
+ for j, s in enumerate(notes):
1517
+ pn = ease(rng(t, 0.6 + 0.15 * j, 1.3 + 0.15 * j))
1518
+ txt = str(s.get("label") or "")
1519
+ _draw_text(img, th, v, txt, W * 0.5, 800.0 + j * 30, int(th["scale"]["body"][0] - 6),
1520
+ "reg", th["muted"], "center", pn)
1521
+
1522
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1523
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1524
+ script.get("title", ""))
1525
+
1526
+
1527
+ # ---------------- 5) compare 左右对比(中缝 + 双栏) ----------------
1528
+
1529
+ def _draw_compare(scene, t, dur, tg, script, frame, plan, th):
1530
+ lang = _lang(plan, th)
1531
+ s_bg = _push_scale(th, t, dur)
1532
+ v = _view(th, s_bg)
1533
+ img = _plate(th, lang, scene, s_bg)
1534
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1535
+ trk = _tracking(th, lang)
1536
+ gap = _gap(th, lang)
1537
+ ph = _phase(scene)
1538
+
1539
+ sides = scene.get("_sides")
1540
+ if not sides:
1541
+ try:
1542
+ import grammar as _G
1543
+ except Exception: # noqa: BLE001
1544
+ _G = None
1545
+ sides = (_G.derive_sides(scene) if _G is not None else None)
1546
+ if not sides:
1547
+ return _draw_statement(scene, t, dur, tg, script, frame, plan, th)
1548
+
1549
+ big = str(scene.get("big") or "")
1550
+ if big:
1551
+ pb = ease(rng(t, 0.0, 0.7))
1552
+ for j, ln in enumerate(_wrap(big, int(th["scale"]["heading"][0] - 6), 1500, "bold", th)[:1]):
1553
+ _draw_text(img, th, v, ln, W * 0.5, 150.0 + _entrance_y(th, pb),
1554
+ int(th["scale"]["heading"][0] - 6), "bold", th["ink"], "center", pb, trk)
1555
+
1556
+ # 中缝:从中心向上下生长(0.35s),是"对比"这件事最直接的视觉符号
1557
+ pg = ease(rng(t, 0.05, 0.40))
1558
+ mid_x = W * 0.5
1559
+ y_top, y_bot = 240.0, 820.0
1560
+ cy = (y_top + y_bot) * 0.5
1561
+ half = (y_bot - y_top) * 0.5 * pg
1562
+ d = ImageDraw.Draw(img, "RGBA")
1563
+ d.line([v.x(mid_x), v.y(cy - half), v.x(mid_x), v.y(cy + half)],
1564
+ fill=_rgba(th["rule"], 90), width=2)
1565
+ # 持续动态:中缝上缓慢下扫的高光
1566
+ scan = cy - half + 2.0 * half * (0.5 + 0.5 * math.sin(0.6 * t + ph))
1567
+ if abs(scan - cy) <= half:
1568
+ d.line([v.x(mid_x - 90), v.y(scan), v.x(mid_x + 90), v.y(scan)],
1569
+ fill=_rgba(th["accent"], 40), width=2)
1570
+
1571
+ # 两栏内容垂直居中(上界 360,下界 800):只有一两条时不会全挤在顶部留一大片空白。
1572
+ # 栏头:优先 sides 里作者写的 head;没有就**不要**写"左/右"这种零信息标签
1573
+ # (v0.9 第一版实测:观众看到"左 / 右"会以为画面坏了),改画一个 VS 徽标在中缝上方。
1574
+ cols = [("left", 150.0, 700.0, -1), ("right", W - 850.0, 700.0, 1)]
1575
+ max_rows = max(len((sides.get("left") or {}).get("items") or []),
1576
+ len((sides.get("right") or {}).get("items") or []), 1)
1577
+ rows_h = min(3, max_rows) * 130
1578
+ y_start = max(380.0, 620.0 - rows_h * 0.5)
1579
+ heads = [str((sides.get(s) or {}).get("head") or "") for s, _, _, _ in cols]
1580
+ has_head = any(heads)
1581
+ for side, x0, ww, sgn in cols:
1582
+ blk = sides.get(side) or {}
1583
+ head = str(blk.get("head") or "")
1584
+ its = [str(x) for x in (blk.get("items") or [])]
1585
+ ph2 = ease(rng(t, 0.10, 0.70))
1586
+ if head:
1587
+ hx = x0 + 40 * (1 - ph2) * sgn
1588
+ _draw_text(img, th, v, head, hx, y_start - 92.0, int(th["scale"]["body"][0]),
1589
+ "bold", th["accent"], "left", ph2)
1590
+ ImageDraw.Draw(img, "RGBA").line(
1591
+ [v.x(x0), v.y(y_start - 48.0), v.x(x0 + ww * ph2), v.y(y_start - 48.0)],
1592
+ fill=_rgba(th["accent"], 130), width=2)
1593
+ for i, txt in enumerate(its[:3]):
1594
+ pi = ease(rng(t, 0.42 + 0.22 * i, 1.05 + 0.22 * i))
1595
+ if pi <= 0:
1596
+ continue
1597
+ yy = y_start + i * 130
1598
+ # 反相高亮:左右两栏轮流亮(周期 3.2s),让"对照"这件事一直在动
1599
+ hi = ((math.sin(2 * math.pi * t / 3.2 + ph + (0 if side == "left" else math.pi)) + 1) / 2)
1600
+ alpha = 0.55 + 0.45 * hi
1601
+ tsize = _fit_size(txt, ww - 40, int(th["scale"]["body"][0] - 2), "reg", th, 18)
1602
+ _draw_text(img, th, v, txt, x0 + 40 * (1 - pi) * sgn, yy, tsize, "reg",
1603
+ th["ink"], "left", pi * alpha)
1604
+ img = _soft_dot(img, v.x(x0 - 8), v.y(yy + tsize * 0.6), 6.0,
1605
+ th["accent"], 0.7 * pi)
1606
+
1607
+ if not has_head:
1608
+ # 中缝 VS 徽标:对比这件事的"视觉锚点",也让画面重心落在中轴上
1609
+ pv = ease(rng(t, 0.2, 0.8))
1610
+ vs_size = int(th["scale"]["body"][0] - 6)
1611
+ vw = _text_w("VS", vs_size, "bold", th)
1612
+ cyv = 250.0
1613
+ ImageDraw.Draw(img, "RGBA").rectangle(
1614
+ [v.x(mid_x - vw * 0.5 - 22), v.y(cyv - 8), v.x(mid_x + vw * 0.5 + 22), v.y(cyv + 44)],
1615
+ fill=_rgba(th["surface"], int(220 * pv)), outline=_rgba(th["accent"], int(180 * pv)))
1616
+ _draw_text(img, th, v, "VS", mid_x - vw * 0.5, cyv, vs_size, "bold",
1617
+ th["accent"], "left", pv)
1618
+
1619
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1620
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1621
+ script.get("title", ""))
1622
+
1623
+
1624
+ # ---------------- 6) process 流程轴 ----------------
1625
+
1626
+ def _draw_process(scene, t, dur, tg, script, frame, plan, th):
1627
+ lang = _lang(plan, th)
1628
+ s_bg = _push_scale(th, t, dur)
1629
+ v = _view(th, s_bg)
1630
+ img = _plate(th, lang, scene, s_bg)
1631
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1632
+ trk = _tracking(th, lang)
1633
+ ph = _phase(scene)
1634
+
1635
+ steps = scene.get("_steps")
1636
+ if not steps:
1637
+ try:
1638
+ import grammar as _G
1639
+ except Exception: # noqa: BLE001
1640
+ _G = None
1641
+ steps = (_G.derive_steps(scene) if _G is not None else []) or []
1642
+ if len(steps) < 2:
1643
+ return _draw_list(scene, t, dur, tg, script, frame, plan, th)
1644
+
1645
+ big = str(scene.get("big") or "")
1646
+ pb = ease(rng(t, 0.0, 0.7))
1647
+ if big:
1648
+ for j, ln in enumerate(_wrap(big, int(th["scale"]["heading"][0] - 6), 1500, "bold", th)[:1]):
1649
+ _draw_text(img, th, v, ln, 150.0, 210.0 + _entrance_y(th, pb),
1650
+ int(th["scale"]["heading"][0] - 6), "bold", th["ink"], "left", pb, trk)
1651
+
1652
+ n = min(5, len(steps))
1653
+ x0, x1, ay = 240.0, float(W - 240), 560.0
1654
+ gapx = (x1 - x0) / max(1, n - 1)
1655
+ pg = ease(rng(t, 0.10, 0.90))
1656
+ d = ImageDraw.Draw(img, "RGBA")
1657
+ d.line([v.x(x0), v.y(ay), v.x(x0 + (x1 - x0) * pg), v.y(ay)],
1658
+ fill=_rgba(th["rule"], 70), width=2)
1659
+ for i in range(n):
1660
+ st = steps[i]
1661
+ pn = ease(rng(t, 0.35 + 0.18 * i, 1.05 + 0.18 * i))
1662
+ if pn <= 0:
1663
+ continue
1664
+ cx = x0 + gapx * i
1665
+ r = 14.0 * pn
1666
+ d.ellipse([v.x(cx - r), v.y(ay - r), v.x(cx + r), v.y(ay + r)],
1667
+ outline=_rgba(th["accent"], int(230 * pn)), width=3)
1668
+ d.ellipse([v.x(cx - 4), v.y(ay - 4), v.x(cx + 4), v.y(ay + 4)],
1669
+ fill=_rgba(th["accent"], int(200 * pn)))
1670
+ lbl = str(st.get("label") or "")
1671
+ lsize = int(th["scale"]["body"][0] - 4)
1672
+ lw = _text_w(lbl, lsize, "bold", th)
1673
+ _draw_text(img, th, v, lbl, cx - lw * 0.5, 470.0, lsize, "bold",
1674
+ th["ink"], "left", pn)
1675
+ note = str(st.get("note") or "")
1676
+ if note:
1677
+ for j, ln in enumerate(_wrap(note, int(lsize * 0.82), 300, "reg", th)[:2]):
1678
+ nw = _text_w(ln, int(lsize * 0.82), "reg", th)
1679
+ _draw_text(img, th, v, ln, cx - nw * 0.5, 610.0 + j * int(lsize * 1.05),
1680
+ int(lsize * 0.82), "reg", th["muted"], "left", pn * 0.95)
1681
+ num = _number_text(str(_typo(lang).get("number") or "dim"), i + 1)
1682
+ nw = "reg" if any("\u4e00" <= ch <= "\u9fff" for ch in num) else _NUM_WEIGHT
1683
+ _draw_text(img, th, v, num, cx - 26, ay - 66, int(lsize * 0.8), nw,
1684
+ th["dim"], "left", pn * 0.9)
1685
+
1686
+ # 持续动态:进度光点沿轴匀速(周期 = 段长),永远有运动
1687
+ if dur > 0.1:
1688
+ u = clamp((t / dur))
1689
+ px = x0 + (x1 - x0) * u
1690
+ img = _soft_dot(img, v.x(px), v.y(ay), 9.0, th["accent2"], 0.85)
1691
+
1692
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1693
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1694
+ script.get("title", ""))
1695
+
1696
+
1697
+ # ---------------- 7) quote 引语 ----------------
1698
+
1699
+ def _draw_quote(scene, t, dur, tg, script, frame, plan, th):
1700
+ lang = _lang(plan, th)
1701
+ s_bg = _push_scale(th, t, dur)
1702
+ v = _view(th, s_bg)
1703
+ img = _plate(th, lang, scene, s_bg)
1704
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1705
+ trk = _tracking(th, lang)
1706
+ gap = _gap(th, lang)
1707
+ ph = _phase(scene)
1708
+
1709
+ try:
1710
+ import grammar as _G
1711
+ except Exception: # noqa: BLE001
1712
+ _G = None
1713
+ q, by = (scene.get("quote"), scene.get("by"))
1714
+ if not q and _G is not None:
1715
+ q, by = _G.derive_quote(scene)
1716
+ q = str(q or scene.get("text") or "")
1717
+ by = str(by or "")
1718
+
1719
+ # 巨型引号:**用字形**,不用矢量线。
1720
+ # v0.9 第一版画的是两条短线拼成的「《」,实测看起来像播放器的"快退"图标,
1721
+ # 观众会以为点错了什么。雅黑粗体的 U+201C 是有字形的(preflight 查过不缺口),
1722
+ # 直接放大 150px 就是最标准的引语符号。
1723
+ pq = ease(rng(t, 0.0, 0.45))
1724
+ qx, qy = 150.0, 150.0
1725
+ _draw_text(img, th, v, "\u201c", qx, qy, 150, "bold", th["accent"], "left", 0.55 * pq)
1726
+
1727
+ qsize = 46
1728
+ max_w = 1240.0
1729
+ lines = _wrap(q, qsize, max_w, "light", th)
1730
+ while len(lines) > 4 and qsize > 30:
1731
+ qsize -= 2
1732
+ lines = _wrap(q, qsize, max_w, "light", th)
1733
+ lh = int(qsize * float(gap) * 1.16)
1734
+ top = 300.0
1735
+ reveal = clamp(t / max(0.4, min(2.2, dur * 0.55)))
1736
+ total = sum(len(x) for x in lines)
1737
+ shown = int(total * ease(reveal))
1738
+ walked = 0
1739
+ for i, ln in enumerate(lines):
1740
+ if walked >= shown:
1741
+ break
1742
+ take = max(0, min(len(ln), shown - walked))
1743
+ walked += len(ln)
1744
+ seg = ln[:take]
1745
+ if seg:
1746
+ _draw_text(img, th, v, seg, 340.0, top + i * lh, qsize, "light",
1747
+ th["ink"], "left", 1.0, trk * 0.4)
1748
+
1749
+ if by:
1750
+ pb = ease(rng(t, 0.9, 1.9))
1751
+ bw = _text_w("—— " + by, int(th["scale"]["body"][0]), "reg", th)
1752
+ _draw_text(img, th, v, "—— " + by, W - 300 - bw, top + len(lines) * lh + 46,
1753
+ int(th["scale"]["body"][0]), "reg", th["muted"], "left", pb)
1754
+
1755
+ # 持续动态:引号随呼吸缓慢上浮 6px(正弦,不做方波跳变)+ 末行光标块
1756
+ bre = 0.5 + 0.5 * math.sin(0.5 * t + ph)
1757
+ _draw_text(img, th, v, "\u201c", qx, qy - 6.0 * bre, 150, "bold",
1758
+ th["accent"], "left", 0.55 * (0.55 + 0.45 * bre))
1759
+ if int(t * 1.1) % 2 == 0:
1760
+ ly = top + len(lines) * lh - qsize
1761
+ lw = _text_w(lines[-1] if lines else "", qsize, "light", th)
1762
+ ImageDraw.Draw(img, "RGBA").rectangle(
1763
+ [v.x(340 + lw + 6), v.y(ly), v.x(340 + lw + 14), v.y(ly + qsize)],
1764
+ fill=_rgba(th["accent"], 150))
1765
+
1766
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1767
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1768
+ script.get("title", ""))
1769
+
1770
+
1771
+ # ---------------- 8) chapter 章节卡 ----------------
1772
+
1773
+ def _draw_chapter(scene, t, dur, tg, script, frame, plan, th):
1774
+ lang = _lang(plan, th)
1775
+ s_bg = _push_scale(th, t, dur)
1776
+ v = _view(th, s_bg)
1777
+ img = _plate(th, lang, scene, s_bg)
1778
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1779
+ trk = _tracking(th, lang)
1780
+ ph = _phase(scene)
1781
+
1782
+ chapter = str(scene.get("chapter") or "")
1783
+ n = _chapter_index(chapter)
1784
+ nstyle = str(_typo(lang).get("number") or "dim")
1785
+ num = _number_text(nstyle, n, fallback=chapter[:2])
1786
+
1787
+ # 巨型编号:用矩形"裁剪揭示"(先画字,再用两块背景板盖住未揭示部分),比 mask 便宜
1788
+ nsize = 240
1789
+ nx, ny = 96.0, 130.0
1790
+ _draw_num(img, th, v, num, nx, ny, nsize, th["accent"], "left", 0.30)
1791
+ p = ease(clamp(t / 0.7))
1792
+ cut = int(nx + _text_w(num, nsize, _NUM_WEIGHT, th) * p)
1793
+ if cut < nx + _text_w(num, nsize, _NUM_WEIGHT, th):
1794
+ base = _plate(th, lang, scene, s_bg)
1795
+ img.paste(base.crop((int(cut), int(ny - 10), W, int(ny + nsize * 1.3))),
1796
+ (int(cut), int(ny - 10)))
1797
+
1798
+ name = str(scene.get("big") or "").strip() or chapter
1799
+ pn = ease(rng(t, 0.25, 1.0))
1800
+ for j, ln in enumerate(_wrap(name, 68, 1300, "bold", th)[:2]):
1801
+ _draw_text(img, th, v, ln, 150.0, 470.0 + j * 86 + _entrance_y(th, pn),
1802
+ 68, "bold", th["ink"], "left", pn, trk)
1803
+
1804
+ kicker = str(scene.get("kicker") or "")
1805
+ if kicker:
1806
+ pk = ease(rng(t, 0.15, 0.8))
1807
+ _draw_text(img, th, v, kicker, 150.0, 410.0, int(th["scale"]["kicker"][0]),
1808
+ "bold", th["accent"], "left", pk, trk * 0.5)
1809
+
1810
+ # 横线由中点向两侧生长 + 持续动态(端点微呼吸)
1811
+ pl = ease(rng(t, 0.35, 1.1))
1812
+ bre = 0.9 + 0.1 * math.sin(1.0 * t + ph)
1813
+ half = 800.0 * pl * bre
1814
+ d = ImageDraw.Draw(img, "RGBA")
1815
+ d.line([v.x(W * 0.5 - half), v.y(700), v.x(W * 0.5 + half), v.y(700)],
1816
+ fill=_rgba(th["rule"], 120), width=2)
1817
+
1818
+ # 持续动态:编号上方一条细光带横向扫(2.8s 周期)。
1819
+ # 为什么不做"整条竖光带":竖着扫过编号会盖住笔画,看起来像渲染错误(实测第一版就是这样),
1820
+ # 而且一根 640px 高的半透明条会把剪影里的前景占比拉高,让章节卡和别的类型混淆。
1821
+ # 细光带只在编号上缘 6px 高,既有运动又不动主体。
1822
+ sx = 96.0 + (W - 400.0) * ((t / 2.8 + ph / 6.28) % 1.0)
1823
+ ImageDraw.Draw(img, "RGBA").rectangle(
1824
+ [v.x(sx), v.y(118), v.x(sx + 180), v.y(124)], fill=_rgba(th["accent"], 60))
1825
+
1826
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1827
+ return _hud(img, th, chapter or "02 · 正文", _progress(tg, script),
1828
+ script.get("title", ""))
1829
+
1830
+
1831
+ # ---------------- 9) chart 数据条 ----------------
1832
+
1833
+ def _draw_chart(scene, t, dur, tg, script, frame, plan, th):
1834
+ lang = _lang(plan, th)
1835
+ s_bg = _push_scale(th, t, dur)
1836
+ v = _view(th, s_bg)
1837
+ img = _plate(th, lang, scene, s_bg)
1838
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1839
+ trk = _tracking(th, lang)
1840
+ ph = _phase(scene)
1841
+
1842
+ series = scene.get("_series")
1843
+ if not series:
1844
+ try:
1845
+ import grammar as _G
1846
+ except Exception: # noqa: BLE001
1847
+ _G = None
1848
+ series = (_G.derive_series(scene) if _G is not None else []) or []
1849
+ if len(series) < 2:
1850
+ return _draw_stat(scene, t, dur, tg, script, frame, plan, th)
1851
+
1852
+ big = str(scene.get("big") or "")
1853
+ if big:
1854
+ pb = ease(rng(t, 0.0, 0.7))
1855
+ _draw_text(img, th, v, big, 150.0, 170.0 + _entrance_y(th, pb),
1856
+ int(th["scale"]["heading"][0] - 20), "bold", th["ink"], "left", pb, trk)
1857
+
1858
+ vals = []
1859
+ for s in series[:6]:
1860
+ try:
1861
+ vals.append(float(s.get("value")))
1862
+ except (TypeError, ValueError):
1863
+ vals.append(0.0)
1864
+ vmax = max(vals) if vals else 1.0
1865
+ if vmax <= 0:
1866
+ vmax = 1.0
1867
+ n = len(vals)
1868
+ bar_x0, bar_x1 = 640.0, 1600.0
1869
+ pitch = min(96.0, (760.0 - 250.0) / max(1, n))
1870
+ top = 260.0
1871
+ # 网格(一次画完,1px 低 alpha)
1872
+ d = ImageDraw.Draw(img, "RGBA")
1873
+ pg = ease(rng(t, 0.0, 0.35))
1874
+ for k in range(5):
1875
+ gx = bar_x0 + (bar_x1 - bar_x0) * k / 4.0
1876
+ d.line([v.x(gx), v.y(240), v.x(gx), v.y(240 + pitch * n * pg)],
1877
+ fill=_rgba(th["rule"], 22), width=1)
1878
+
1879
+ for i, s in enumerate(series[:n]):
1880
+ p = ease(rng(t, 0.20 + 0.15 * i, 0.95 + 0.15 * i))
1881
+ if p <= 0:
1882
+ continue
1883
+ yy = top + i * pitch
1884
+ frac = vals[i] / vmax
1885
+ wpx = (bar_x1 - bar_x0) * frac * p
1886
+ d = ImageDraw.Draw(img, "RGBA")
1887
+ d.rectangle([v.x(bar_x0), v.y(yy), v.x(bar_x0 + wpx), v.y(yy + 42)],
1888
+ fill=_rgba(th["accent"] if i == 0 else th["accent2"], 190))
1889
+ lbl = str(s.get("label") or "")
1890
+ lsize = int(th["scale"]["body"][0] - 6)
1891
+ lw = _text_w(lbl, lsize, "reg", th)
1892
+ _draw_text(img, th, v, lbl, bar_x0 - 24 - lw, yy + 6, lsize, "reg",
1893
+ th["ink"], "left", p)
1894
+ num = ("%g" % vals[i]) + str(s.get("unit") or "")
1895
+ _draw_num(img, th, v, num, bar_x0 + wpx + 16, yy + 4, int(lsize * 1.05),
1896
+ th["ink"], "left", p)
1897
+
1898
+ # 持续动态:最大条端点呼吸 + 一条网格高亮扫描
1899
+ if vals:
1900
+ i0 = vals.index(max(vals))
1901
+ yy = top + i0 * pitch
1902
+ r = 4.0 + 3.0 * (0.5 + 0.5 * math.sin(1.8 * t + ph))
1903
+ img = _soft_dot(img, v.x(bar_x1), v.y(yy + 21), r, th["accent"], 0.8)
1904
+ scan_y = 240.0 + (pitch * n) * ((t / 2.6 + ph / 6.28) % 1.0)
1905
+ ImageDraw.Draw(img, "RGBA").line(
1906
+ [v.x(bar_x0), v.y(scan_y), v.x(bar_x1), v.y(scan_y)],
1907
+ fill=_rgba(th["accent"], 26), width=2)
1908
+
1909
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1910
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
1911
+ script.get("title", ""))
1912
+
1913
+
1914
+ # ---------------- 10) outro 收尾 ----------------
1915
+
1916
+ def _draw_outro(scene, t, dur, tg, script, frame, plan, th):
1917
+ lang = _lang(plan, th)
1918
+ s_bg = _push_scale(th, t, dur)
1919
+ v = _view(th, s_bg)
1920
+ img = _plate(th, lang, scene, s_bg)
1921
+ lay = _scene_layout(scene, th)
1922
+ ease = _ease((lang.get("motion") or {}).get("ease") or "out3")
1923
+ trk = _tracking(th, lang)
1924
+ gap = _gap(th, lang)
1925
+ ph = _phase(scene)
1926
+
1927
+ # 光点环:数量随视觉语言母题走(epic 18 / cinema 12 / mono 0),且比 v0.5 更少
1928
+ mf = str((lang.get("motif") or {}).get("kind") or "minimal")
1929
+ ring_n = {"rays": 18, "letterbox": 12, "brackets": 10, "signal": 8,
1930
+ "handdrawn": 6, "slabs": 12, "hairlines": 6, "minimal": 0}.get(mf, 8)
1931
+ p = ease(rng(t, 0.0, 1.0))
1932
+ for i in range(ring_n):
1933
+ a = (i / float(max(1, ring_n))) * 2.0 * math.pi + 0.15 * t + ph
1934
+ rr = 460.0 * (1.0 - p) + 70.0 + 16.0 * K.hash2(i, 4)
1935
+ img = _soft_dot(img, v.x(W * 0.5 + math.cos(a) * rr),
1936
+ v.y(520.0 + math.sin(a) * rr * 0.5), 3.0,
1937
+ th["accent2"], 0.5 * (1.0 - p * 0.7))
1938
+
1939
+ big = scene.get("big") or script.get("end_text") or "完"
1940
+ size = int(th["scale"]["display"][0])
1941
+ lines = _wrap(big, size, min(float(lay["title_w"]), 1520.0), "bold", th)
1942
+ while len(lines) > 3 and size > 42:
1943
+ size = int(size * 0.88)
1944
+ lines = _wrap(big, size, min(float(lay["title_w"]), 1520.0), "bold", th)
1945
+ lh = int(size * gap)
1946
+ pa = ease(rng(t, 0.30, 1.30))
1947
+ top = 380.0 - (len(lines) - 1) * lh * 0.5
1948
+ for i, ln in enumerate(lines):
1949
+ _draw_text(img, th, v, ln, W * 0.5, top + i * lh + _entrance_y(th, pa),
1950
+ size, "bold", th["ink"], "center", 0.02 + 0.98 * pa, trk)
1951
+ bottom = top + len(lines) * lh + _entrance_y(th, pa)
1952
+
1953
+ sub = scene.get("sub") or ""
1954
+ # 与大字重复的副标题不画(同 hero:模板片 sub 与 big 是同一句)
1955
+ if sub and (str(sub).strip() == str(big).strip()
1956
+ or (len(str(sub)) >= 6 and str(sub).strip() in str(big).strip())):
1957
+ sub = ""
1958
+ if sub:
1959
+ ssize = int(th["scale"]["sub"][0])
1960
+ p2 = ease(rng(t, 0.55, 1.55))
1961
+ for j, ln in enumerate(_wrap(sub, ssize, 1400, "light", th)[:2]):
1962
+ _draw_text(img, th, v, ln, W * 0.5,
1963
+ bottom + int(size * 0.22) + j * int(ssize * gap),
1964
+ ssize, "light", th["muted"], "center", p2)
1965
+ bottom = bottom + int(size * 0.22) + int(ssize * gap)
1966
+
1967
+ grow = ease(rng(t, 0.45, 1.45))
1968
+ bre = 0.82 + 0.18 * math.sin(0.9 * t + ph)
1969
+ line_w = int(340 * grow * bre)
1970
+ ly = min(bottom + int(size * 0.30), 840)
1971
+ ImageDraw.Draw(img, "RGBA").line(
1972
+ [v.x(W * 0.5 - line_w * 0.5), v.y(ly), v.x(W * 0.5 + line_w * 0.5), v.y(ly)],
1973
+ fill=_rgba(th["accent"], 200), width=3)
1974
+
1975
+ img = _apply_veil(img, th, lang, scene, t, s_bg)
1976
+ return _hud(img, th, scene.get("chapter") or "99 · 收尾", 1.0, script.get("title", ""))
1977
+
1978
+
1979
+ _DRAWERS = {
1980
+ "hero": _draw_hero,
1981
+ "statement": _draw_statement,
1982
+ "list": _draw_list,
1983
+ "stat": _draw_stat,
1984
+ "compare": _draw_compare,
1985
+ "process": _draw_process,
1986
+ "quote": _draw_quote,
1987
+ "chapter": _draw_chapter,
1988
+ "chart": _draw_chart,
1989
+ "outro": _draw_outro,
1990
+ }
1991
+
1992
+ # 老 kind -> 场景类型(没有 scene_type 的旧 timeline / 手工构造的 scene 走这里)
1993
+ _KIND_TO_TYPE = {
1994
+ "hook": "hero", "body": "list", "quote": "quote", "outro": "outro",
1995
+ "emphasis": "statement", "calm": "statement",
1996
+ }
1997
+
1998
+
1999
+ # ============================================================================
2000
+ # 四、对外契约
2001
+ # ============================================================================
2002
+
2003
+ def draw_frame(scene, t_local, dur, t_global, script, frame):
2004
+ """按帧画一张 1920x1080 RGB 图。
2005
+
2006
+ 派发顺序(v0.9):
2007
+ 1. legacy(film.json 没有 style 字段)-> v0.4 三个老绘制器,逐字节不变;
2008
+ 2. 非 legacy -> 按 scene_type(grammar.py 选好、render.py 写进 timeline)派发到
2009
+ 10 类构图之一;没有 scene_type 的旧 timeline 按 kind 映射到兼容类型。
2010
+ 未命中的类型回落到 list("有条目就画条目"是最不会丢信息的兜底),
2011
+ 并 warn 一次——静默画错构图比报错更糟。
2012
+ """
2013
+ plan = _plan(script)
2014
+ kind = scene.get("kind") or "body"
2015
+ if plan.get("legacy"):
2016
+ fn = _LEGACY_DRAWERS.get(kind) or _legacy_scene_body
2017
+ return fn(scene, t_local, dur, t_global, script, frame)
2018
+ th = _scene_theme(plan, scene)
2019
+ stype = str(scene.get("scene_type") or "").strip()
2020
+ if not stype:
2021
+ stype = _KIND_TO_TYPE.get(kind, "list")
2022
+ fn = _DRAWERS.get(stype)
2023
+ if fn is None:
2024
+ _warn_once(("scene_type", stype),
2025
+ "场景 %r 的 scene_type=%r 不认识,已回落到 list 构图(可用:%s)"
2026
+ % (scene.get("id"), stype, " / ".join(_DRAWERS.keys())))
2027
+ fn = _draw_list
2028
+ return fn(scene, t_local, dur, t_global, script, frame, plan, th)
2029
+
2030
+
2031
+ # ---------------- 提示点(v0.3.0 起可选;v0.7 按 kind 与内容生成) ----------------
2032
+ def _sha1_int(*parts):
2033
+ """把若干文本片段拼起来取 sha1 前 8 位(提示点"内容派生"的稳定来源)。"""
2034
+ raw = "|".join(str(p) for p in parts).encode("utf-8")
2035
+ return int(hashlib.sha1(raw).hexdigest()[:8], 16)
2036
+
2037
+
2038
+ def _item_pitch(text, base=1760.0):
2039
+ """条目文本 -> ui_blip 音高(sha1 派生,不用序号)。
2040
+
2041
+ v0.7 契约 §3.4:body 条目的 pitch 必须由**条目文本**派生,不再按序号
2042
+ (旧实现 1760+110*i 每片都一样)——同一句文案换到别的片子里仍是同一个音高,
2043
+ 不同文案落到 -5~+7 半音的不同音级;万一两条碰撞,_dedupe_segment 会再升半音。
2044
+ """
2045
+ off = _sha1_int("pitch", text) % 13 - 5
2046
+ return round(float(base) * 2.0 ** (off / 12.0), 2)
2047
+
2048
+
2049
+ def _cue_gain_decay(gain, repeat):
2050
+ """同段内同类型 cue 第 repeat 次重复(0 起)时的增益:每次再低 2 dB(-2/-4/-6/…)。
2051
+
2052
+ v0.7 契约 §3.4:不许再出现"每段都一模一样的三连击"——同类型重复必须**严格递减**
2053
+ (第 2 次 -2、第 3 次 -4、第 4 次 -6……),且 pitch 必须不同。
2054
+ """
2055
+ return round(float(gain) - 2.0 * int(repeat), 2)
2056
+
2057
+
2058
+ def _dedupe_segment(seg):
2059
+ """同一段内:同类型重复的 gain 递减、pitch 去重(保证"pitch 必须不同")。
2060
+
2061
+ 正文条目多时 sha1 可能把两条吸附到同一个半音;碰撞就把后一个再升半音——
2062
+ 仍然由文本派生,只是保证同段里不出现两个一模一样的高音(听着像卡带)。
2063
+ """
2064
+ seen = {} # type -> {"n": 已出现次数, "pitch": 已用音高集}
2065
+ for c in seg:
2066
+ st = seen.setdefault(c["type"], {"n": 0, "pitch": set()})
2067
+ if st["n"]:
2068
+ c["gain"] = _cue_gain_decay(c.get("gain", 0.0), st["n"])
2069
+ st["n"] += 1
2070
+ p = c.get("pitch")
2071
+ if p is not None:
2072
+ p = float(p)
2073
+ guard = 0
2074
+ while round(p, 2) in st["pitch"] and guard < 12:
2075
+ p *= 2.0 ** (1.0 / 12.0)
2076
+ guard += 1
2077
+ c["pitch"] = round(p, 2)
2078
+ st["pitch"].add(round(p, 2))
2079
+ return seg
2080
+
2081
+
2082
+ def cues(tl, script):
2083
+ """画面提示点钩子:render.py --stage cues 会调它(没有这个函数就不调)。
2084
+
2085
+ 好处是"声音跟着画面的像素走":这里算的是卡片落位、数字滚完的时刻,
2086
+ 画面一改,声音自动跟着改,不用回来手工对时间。
2087
+
2088
+ v0.5:条目落位步长与场景绘制共用 plan["pacing"]["item_stagger"],
2089
+ 两边读同一个数,否则音效会错位(这是 v0.4 写死 0.32 留下的坑)。
2090
+
2091
+ v0.7(references/audio-variation.md §3.4):提示点改为按**场景 kind 与内容**生成,
2092
+ 不再是"每段 ui_blip 三连 + 结尾 answer_chime"的同质化固定套路(跨片音效雷同的
2093
+ 来源之一):
2094
+ hook : 段首 impact(size 按字数 0.9~1.3)+ 落定 riser
2095
+ body : 条目落位 ui_blip(pitch 由条目文本 sha1 派生)+ items>=3 补 answer_chime
2096
+ + 段尾 whoosh(转场到下一段)
2097
+ quote : reverse_swell + 落定轻 impact
2098
+ outro : answer_chime + 片尾 sub_drop
2099
+ 同段内同类型 cue 重复时 gain 逐次再降 2 dB(-2/-4/-6)、pitch 必须不同。
2100
+ 返回 schema 不变:[{"at": 时刻, "type": 配方名, ...参数}];
2101
+ at 可以写 "abs:12.5" / 12.5 / "l03+0.35"(相对该段 start)/"l03"
2102
+ type 见 references/sfx-design.md 的配方表,或 sfxkit.list_recipes()
2103
+ """
2104
+ plan = _plan(script)
2105
+ stagger = 0.32
2106
+ if not plan.get("legacy"):
2107
+ stagger = float((plan.get("pacing") or {}).get("item_stagger") or 0.32)
2108
+ scenes = list(tl.get("scenes") or [])
2109
+ out = []
2110
+ for i, s in enumerate(scenes):
2111
+ sid = str(s.get("id") or ("l%02d" % (i + 1)))
2112
+ kind = str(s.get("kind") or "body")
2113
+ end = float(s.get("end") or 0.0)
2114
+ dur = float(s.get("dur") or max(0.0, end - float(s.get("start") or 0.0)))
2115
+ has_next = (i + 1) < len(scenes)
2116
+ seg = []
2117
+
2118
+ def _at(off):
2119
+ """本段相对时刻 -> cue 的 at 写法(id+偏移,保持返回 schema 不变)。"""
2120
+ return "%s+%.2f" % (sid, max(0.0, float(off)))
2121
+
2122
+ if kind == "hook":
2123
+ # 开场重击按文案字数给"体量":字数越多 size 越大(0.9~1.3),避免每片同一记闷响
2124
+ text = str(s.get("text") or s.get("big") or "")
2125
+ n_char = len("".join(text.split()))
2126
+ size = round(0.9 + 0.4 * min(1.0, n_char / 40.0), 2)
2127
+ seg.append({"at": _at(0.10), "type": "impact", "size": size, "gain": -2})
2128
+ seg.append({"at": _at(min(1.2, max(0.35, dur * 0.28))),
2129
+ "type": "riser", "dur": 1.2, "gain": -6})
2130
+ elif kind == "quote":
2131
+ # 引语段:先来一层反向渐强铺底,落定时轻一点的一击
2132
+ seg.append({"at": _at(0.05), "type": "reverse_swell", "dur": 1.2, "gain": -5})
2133
+ seg.append({"at": _at(min(1.4, max(0.5, dur * 0.30))),
2134
+ "type": "impact", "size": 0.6, "gain": -8})
2135
+ elif kind == "outro":
2136
+ # 收尾:答对钟声 + 片尾 sub_drop(尾巴正好落在段尾,不留半截低频)
2137
+ seg.append({"at": _at(0.35), "type": "answer_chime", "gain": -4})
2138
+ seg.append({"at": _at(max(0.30, dur - 1.60)),
2139
+ "type": "sub_drop", "dur": 1.6, "gain": -5})
2140
+ else:
2141
+ # body(以及 emphasis/calm 等未单独设计的 kind:按正文处理,向后兼容)
2142
+ items = list(s.get("items") or [])
2143
+ for j, it in enumerate(items):
2144
+ # 条目逐条落位:第一项在 0.48s 出现,之后每 stagger 秒一个(与 _draw_items 同一拍)
2145
+ at = 0.48 + stagger * j
2146
+ if at > dur - 0.12: # 极短段:放不下的条目不再排提示点(避免响到下一段)
2147
+ break
2148
+ txt = it.get("text") if isinstance(it, dict) else it
2149
+ seg.append({"at": _at(at), "type": "ui_blip",
2150
+ "pitch": _item_pitch(txt), "gain": -6})
2151
+ blips = [c for c in seg if c["type"] == "ui_blip"]
2152
+ if len(items) >= 3 and blips and dur > 0.6:
2153
+ # 最后一个条目落定后的下一拍补一声"猜中了",给列表收尾
2154
+ last_at = float(blips[-1]["at"].split("+", 1)[1])
2155
+ t_chime = min(last_at + stagger, max(last_at + 0.20, dur - 0.30))
2156
+ seg.append({"at": _at(t_chime), "type": "answer_chime", "gain": -6})
2157
+ if has_next and dur > 0.6:
2158
+ # 段尾 whoosh:从本段扫进下一段(C 路会在段界附近给转场类音效让位)
2159
+ seg.append({"at": _at(max(0.0, dur - 0.45)),
2160
+ "type": "whoosh", "dur": 0.9, "gain": -6})
2161
+ out.extend(_dedupe_segment(seg))
2162
+ return out