@dsh-gis/dsh-video-studio-skill 0.4.0 → 0.6.0

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.
@@ -1,5 +1,5 @@
1
1
  # -*- coding: utf-8 -*-
2
- """scenes.py — 场景绘制模板(v0.3.0 起带 cues() 提示点钩子)。
2
+ """scenes.py — 场景绘制模板(v0.5.0:画风随内容变化)。
3
3
 
4
4
  唯一约定:提供 draw_frame(scene, t_local, dur, t_global, script, frame) -> PIL.Image
5
5
  scene : timeline.json 里的一段(含 id/text/sub/start/end/dur)
@@ -8,28 +8,61 @@
8
8
  t_global: 全片秒数
9
9
  frame : 全片帧号(确定性:同一帧号必须画出同一张图)
10
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
+
11
19
  铁律(决定画质与可复现性):
12
20
  1. 不许用 random / time.time();要随机用 K.hash2 / K.hash3 / vnoise(seed)
13
21
  2. 每帧从零画,不依赖上一帧的变量
14
- 3. 静态背景预渲染一次并缓存(见 _bg()),不要每帧重算渐变
15
- 4. 文字排版一律用 K.wrap_cn_cached / K.text_w_cached:直接调 wrap_cn 会让单帧慢 2-3 倍
22
+ 3. 静态背景预渲染一次并缓存(见 _bg()),不要每帧重算渐变 / 纹理 / 暗角
23
+ 4. 文字排版一律用本文件的 _wrap / _text_w(带缓存):直接调 wrap_cn 会让单帧慢 2-3 倍
16
24
  5. 所有正文落在 K.SAFE 内(y <= 974),字幕由 render.py 统一画在 y >= 872
17
25
  6. 每段至少一个动态元素,静止不要超过 6 秒;镜头缓推 3-15%
18
26
  7. 缺字形会画成方块:用 K.glyph_missing_chars 自查,别写 ✓ × ↵ 这类符号(改用矢量线条)
27
+ 8. 性能:1080p 单帧预算 ~15ms。重活(渐变/暗角/纹理/精灵)全部预烘焙进 _CACHE;
28
+ 不许每帧全屏高斯模糊 / 全屏双线性 resize(实测 30ms/帧,直接吃掉预算)
19
29
  """
20
30
  from __future__ import annotations
21
31
 
22
32
  import math
33
+ import os
34
+ import sys
35
+ import warnings
23
36
 
24
37
  from PIL import Image, ImageDraw, ImageFilter
25
38
  import numpy as np
26
39
 
40
+ # stylekit 的单向依赖:stylekit 不 import 本文件(也不 import filmkit),不会成环。
41
+ # 用户会把 scenes.py 与 scripts/*.py 拷到同一目录,那种情况下直接 import 就行;
42
+ # 本仓库里是 assets/scenes.py 与 scripts/stylekit.py 两个目录,所以兜底加 ../scripts。
43
+ try:
44
+ import stylekit as ST
45
+ except ImportError:
46
+ _SCRIPTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, "scripts")
47
+ _SCRIPTS = os.path.normpath(_SCRIPTS)
48
+ if _SCRIPTS not in sys.path:
49
+ sys.path.insert(0, _SCRIPTS)
50
+ import stylekit as ST
51
+
27
52
  import filmkit as K
28
53
  # 只 import 模板**当下真的用到**的名字(多余导入会被 pyflakes 记一笔,也容易让人以为哪都能用)。
29
54
  # 写自己的场景时,缓动/噪声直接走模块:K.lerp / K.ease_in / K.ease_in_out / K.vnoise / K.hash2 …
30
55
  from filmkit import W, H, clamp, smooth, ease_out, rng
31
56
 
32
- # ---------------- 主题(改这里换整片气质) ----------------
57
+ # ============================================================================
58
+ # 零、v0.4 兼容层(legacy 逐字节基线,别动这里的任何一行)
59
+ # ============================================================================
60
+ #
61
+ # legacy = 老项目(film.json 没有 style 字段)。这组函数是 v0.4 的原文,
62
+ # 一个字符都不许改:Lead 的基线三帧 sha256 直接对拍这里的输出。
63
+ # 新主题化代码只在 _legacy_* 之外生长,互不影响。
64
+
65
+ # ---------------- 主题(legacy 专用;新代码走 stylekit.STYLES / THEMES) ----------------
33
66
  THEME = dict(
34
67
  bg_top=(7, 14, 26), bg_bot=(17, 33, 54),
35
68
  ink=(240, 245, 252), muted=(150, 174, 200), dim=(96, 118, 144),
@@ -69,15 +102,6 @@ def _vignette_mask(w, h, strength=0.40):
69
102
  return mask
70
103
 
71
104
 
72
- def _bg():
73
- """预烘焙背景:渐变 + 暗角一次算好,每帧只 copy(实测省 ~4.5ms/帧)。"""
74
- if "baked" not in _CACHE:
75
- base = _vgrad(W, H, THEME["bg_top"], THEME["bg_bot"])
76
- _CACHE["baked"] = Image.composite(Image.new("RGB", (W, H), (0, 0, 0)),
77
- base, _vignette_mask(W, H, 0.40))
78
- return _CACHE["baked"]
79
-
80
-
81
105
  def _soft_dot(img, x, y, r, color, alpha=1.0):
82
106
  """柔边圆点精灵(预渲染一次,之后只 paste)。"""
83
107
  if r < 0.6 or alpha <= 0.004:
@@ -96,7 +120,16 @@ def _soft_dot(img, x, y, r, color, alpha=1.0):
96
120
  return img
97
121
 
98
122
 
99
- def _hud(img, chapter, progress, title):
123
+ def _legacy_bg():
124
+ """预烘焙背景:渐变 + 暗角一次算好,每帧只 copy(实测省 ~4.5ms/帧)。"""
125
+ if "baked" not in _CACHE:
126
+ base = _vgrad(W, H, THEME["bg_top"], THEME["bg_bot"])
127
+ _CACHE["baked"] = Image.composite(Image.new("RGB", (W, H), (0, 0, 0)),
128
+ base, _vignette_mask(W, H, 0.40))
129
+ return _CACHE["baked"]
130
+
131
+
132
+ def _legacy_hud(img, chapter, progress, title):
100
133
  d = ImageDraw.Draw(img, "RGBA")
101
134
  d.rectangle([0, 0, int(W * clamp(progress)), 3], fill=THEME["accent"])
102
135
  d.rectangle([0, 0, W, 3], fill=(255, 255, 255, 11))
@@ -112,7 +145,7 @@ def _hud(img, chapter, progress, title):
112
145
  return img
113
146
 
114
147
 
115
- def _cover(img, t, dur, kicker, big, sub, progress, title):
148
+ def _legacy_cover(img, t, dur, kicker, big, sub, progress, title):
116
149
  d = ImageDraw.Draw(img, "RGBA")
117
150
  p = ease_out(rng(t, 0.0, 0.9))
118
151
  p2 = ease_out(rng(t, 0.25, 1.3))
@@ -126,12 +159,11 @@ def _cover(img, t, dur, kicker, big, sub, progress, title):
126
159
  for j, ln in enumerate(K.wrap_cn_cached(sub, 38, 1400, "light")):
127
160
  d.text((150, y + 26 + j * 56), ln, font=K.font(38, "light"),
128
161
  fill=THEME["muted"] + (int(242 * p2),))
129
- return _hud(img, "01 · 开场", progress, title)
162
+ return _legacy_hud(img, "01 · 开场", progress, title)
130
163
 
131
164
 
132
- # ---------------- 场景 ----------------
133
- def scene_hook(scene, t, dur, tg, script, frame):
134
- img = _bg().copy()
165
+ def _legacy_scene_hook(scene, t, dur, tg, script, frame):
166
+ img = _legacy_bg().copy()
135
167
  d = ImageDraw.Draw(img, "RGBA")
136
168
  cx, cy = W // 2, H // 2 - 40
137
169
  p = smooth(rng(t, 0.15, 2.6))
@@ -143,13 +175,13 @@ def scene_hook(scene, t, dur, tg, script, frame):
143
175
  if K.hash2(i, 9) > 0.45:
144
176
  img = _soft_dot(img, x2, y2, 3 + 5 * K.hash2(i, 5), THEME["accent2"], 0.55 * p)
145
177
  img = _soft_dot(img, cx, cy, 26 + 6 * math.sin(t * 2.2), THEME["accent"], 0.9 * rng(t, 0.1, 0.7))
146
- return _cover(img, t, dur, "00 · 开场", script.get("title", ""),
147
- scene.get("lead") or "零素材,全代码生成", tg / max(0.1, script.get("_total", 1.0)),
148
- script.get("title", ""))
178
+ return _legacy_cover(img, t, dur, "00 · 开场", script.get("title", ""),
179
+ scene.get("lead") or "零素材,全代码生成", tg / max(0.1, script.get("_total", 1.0)),
180
+ script.get("title", ""))
149
181
 
150
182
 
151
- def scene_body(scene, t, dur, tg, script, frame):
152
- img = _bg().copy()
183
+ def _legacy_scene_body(scene, t, dur, tg, script, frame):
184
+ img = _legacy_bg().copy()
153
185
  d = ImageDraw.Draw(img, "RGBA")
154
186
  p = ease_out(rng(t, 0.0, 0.8))
155
187
  d.text((150, 250 + int(14 * (1 - p))), scene.get("kicker", ""),
@@ -173,12 +205,12 @@ def scene_body(scene, t, dur, tg, script, frame):
173
205
  d = ImageDraw.Draw(img, "RGBA")
174
206
  d.text((x + 60, y + 28), it["text"], font=K.font(32, "reg"),
175
207
  fill=THEME["ink"] + (int(255 * p_i),))
176
- return _hud(img, scene.get("chapter") or "02 · 正文",
177
- tg / max(0.1, script.get("_total", 1.0)), script.get("title", ""))
208
+ return _legacy_hud(img, scene.get("chapter") or "02 · 正文",
209
+ tg / max(0.1, script.get("_total", 1.0)), script.get("title", ""))
178
210
 
179
211
 
180
- def scene_outro(scene, t, dur, tg, script, frame):
181
- img = _bg().copy()
212
+ def _legacy_scene_outro(scene, t, dur, tg, script, frame):
213
+ img = _legacy_bg().copy()
182
214
  d = ImageDraw.Draw(img, "RGBA")
183
215
  p = ease_out(rng(t, 0.0, 1.0))
184
216
  cx, cy = W // 2, H // 2 - 30
@@ -193,15 +225,834 @@ def scene_outro(scene, t, dur, tg, script, frame):
193
225
  for i, ln in enumerate(lines):
194
226
  d.text(((W - K.text_w_cached(ln, 96, "bold")) / 2, 380 + i * 118), ln,
195
227
  font=K.font(96, "bold"), fill=THEME["ink"] + (alpha,))
196
- return _hud(img, "99 · 收尾", 1.0, script.get("title", ""))
228
+ return _legacy_hud(img, "99 · 收尾", 1.0, script.get("title", ""))
229
+
230
+
231
+ _LEGACY_DRAWERS = {
232
+ "hook": _legacy_scene_hook,
233
+ "body": _legacy_scene_body,
234
+ "outro": _legacy_scene_outro,
235
+ }
236
+
237
+
238
+ # ============================================================================
239
+ # 一、风格计划 / 主题(v0.5)
240
+ # ============================================================================
241
+
242
+ _PLAN_CACHE = {}
243
+ _THEME_WARNED = set()
244
+ _DEFAULT_PUSH = 0.04
245
+
246
+
247
+ def _warn_once(key, msg):
248
+ if key in _THEME_WARNED:
249
+ return
250
+ _THEME_WARNED.add(key)
251
+ warnings.warn(msg, RuntimeWarning, stacklevel=3)
252
+
253
+
254
+ def _plan(script):
255
+ """取整片的风格计划:render.py 会把解析好的计划放进 script["_style"]。
256
+
257
+ 没有就自己解一次(纯函数,同参数同结果)。每帧都会调用,所以只留一条缓存;
258
+ 键里带 id(script) 之外的稳定字段,避免脚本对象被回收后 id 复用串档。
259
+ """
260
+ p = script.get("_style")
261
+ if isinstance(p, dict):
262
+ return p
263
+ key = (id(script), script.get("title"), len(script.get("lines") or []),
264
+ script.get("style"), bool(script.get("pacing")))
265
+ got = _PLAN_CACHE.get(key)
266
+ if got is None:
267
+ got = ST.resolve_script_style(script)
268
+ _PLAN_CACHE.clear()
269
+ _PLAN_CACHE[key] = got
270
+ return got
271
+
272
+
273
+ def _scene_theme(plan, scene):
274
+ """本段用哪套主题:全片主题 + 可选的 scene["theme"] 覆盖(只许同明暗族)。"""
275
+ base = plan.get("theme")
276
+ if isinstance(base, dict):
277
+ th = dict(base)
278
+ else:
279
+ th = dict(ST.THEMES[plan["theme_id"]])
280
+ sid = scene.get("theme")
281
+ if not sid or str(sid) == str(th.get("id")):
282
+ return th
283
+ alt = ST.THEMES.get(str(sid))
284
+ if alt is None:
285
+ _warn_once(("unknown", str(sid)),
286
+ "场景 %r 指定了未知主题 %r,已忽略(可用:%s)"
287
+ % (scene.get("id"), sid, " / ".join(ST.STYLE_IDS)))
288
+ return th
289
+ if not ST.family_compatible(th, alt):
290
+ # 跨明暗族 = 两部片,不是两段:段界会整屏换底,QA 的闪烁门禁必炸。
291
+ _warn_once(("family", str(th.get("id")), str(sid)),
292
+ "场景 %r 想从主题 %s 换到 %s,两者明暗族不同(同片换族会触发闪烁门禁),已忽略"
293
+ % (scene.get("id"), th.get("id"), sid))
294
+ return th
295
+ return dict(alt)
296
+
297
+
298
+ # ---------------- 包内小工具 ----------------
299
+
300
+ def _mix(a, b, p):
301
+ """两色线性插值(p=0 -> a,p=1 -> b)。"""
302
+ q = clamp(p)
303
+ return (int(round(a[0] + (b[0] - a[0]) * q)),
304
+ int(round(a[1] + (b[1] - a[1]) * q)),
305
+ int(round(a[2] + (b[2] - a[2]) * q)))
306
+
307
+
308
+ def _rgba(c, alpha=255):
309
+ """RGB + 0..255 透明度 -> 4 元组(PIL 的 RGBA 画笔要它)。"""
310
+ return (int(c[0]), int(c[1]), int(c[2]),
311
+ int(max(0, min(255, round(alpha)))))
312
+
313
+
314
+ def _ease(name):
315
+ """风格包里的缓动名 -> 函数(四个都满足 f(0)=0 / f(1)=1)。"""
316
+ got = _EASES.get(name)
317
+ if got is not None:
318
+ return got
319
+ if name == "out5":
320
+ got = lambda p: 1.0 - (1.0 - clamp(p)) ** 5
321
+ elif name == "inOut3":
322
+ got = K.ease_in_out
323
+ elif name == "outBack":
324
+ got = K.ease_out_back
325
+ else:
326
+ got = K.ease_out
327
+ _EASES[name] = got
328
+ return got
329
+
330
+
331
+ _EASES = {}
332
+
197
333
 
334
+ # ---------------- 字体:family 是 v0.5 新加的参数(旧 filmkit 没有,自动回退) ----------------
198
335
 
199
- DRAWERS = {"hook": scene_hook, "body": scene_body, "outro": scene_outro}
336
+ _FONT_CACHE = {}
337
+ _FAMILY_OK = None
200
338
 
201
339
 
340
+ def _family_ok():
341
+ """filmkit.font 是否支持 family=...(Lead 在 v0.5 加;老版本没有就回退旧签名)。"""
342
+ global _FAMILY_OK
343
+ if _FAMILY_OK is None:
344
+ try:
345
+ K.font(16, "reg", "sans")
346
+ _FAMILY_OK = True
347
+ except TypeError:
348
+ _FAMILY_OK = False
349
+ except Exception: # noqa: BLE001 - 字体族实现有 bug 时退回旧签名
350
+ _FAMILY_OK = False
351
+ return _FAMILY_OK
352
+
353
+
354
+ def _font(th, size, weight="reg"):
355
+ size = max(6, int(size))
356
+ fam = str(th.get("family") or "sans")
357
+ key = (size, weight, fam)
358
+ f = _FONT_CACHE.get(key)
359
+ if f is None:
360
+ if _family_ok():
361
+ try:
362
+ f = K.font(size, weight, fam)
363
+ except Exception: # noqa: BLE001 - 某族字体缺失时退回到默认字体
364
+ f = K.font(size, weight)
365
+ else:
366
+ f = K.font(size, weight)
367
+ _FONT_CACHE[key] = f
368
+ return f
369
+
370
+
371
+ _TW_CACHE = {}
372
+ _WRAP_CACHE = {}
373
+
374
+
375
+ def _text_w(text, size, weight, th):
376
+ """带缓存的行宽(按主题字体族;老 filmkit 回退到 K.text_w_cached)。"""
377
+ if not text:
378
+ return 0.0
379
+ size = max(6, int(size))
380
+ fam = str(th.get("family") or "sans")
381
+ key = (text, size, weight, fam)
382
+ got = _TW_CACHE.get(key)
383
+ if got is None:
384
+ if len(_TW_CACHE) > 40000:
385
+ _TW_CACHE.clear()
386
+ if _family_ok():
387
+ got = K.text_w(text, _font(th, size, weight))
388
+ else:
389
+ got = K.text_w_cached(text, size, weight)
390
+ _TW_CACHE[key] = got
391
+ return got
392
+
393
+
394
+ def _wrap(text, size, max_w, weight, th):
395
+ """带缓存的换行(同上)。"""
396
+ if not text:
397
+ return []
398
+ size = max(6, int(size))
399
+ max_w = max(80, int(max_w))
400
+ fam = str(th.get("family") or "sans")
401
+ key = (text, size, max_w, weight, fam)
402
+ got = _WRAP_CACHE.get(key)
403
+ if got is None:
404
+ if len(_WRAP_CACHE) > 20000:
405
+ _WRAP_CACHE.clear()
406
+ if _family_ok():
407
+ got = K.wrap_cn(text, _font(th, size, weight), max_w)
408
+ else:
409
+ got = K.wrap_cn_cached(text, size, max_w, weight)
410
+ _WRAP_CACHE[key] = got
411
+ return got
412
+
413
+
414
+ def _fit_size(text, max_w, size, weight, th, floor=16):
415
+ """按卡片可用宽度缩字号(条目文字长短不一,不能撑破卡片)。"""
416
+ s = int(size)
417
+ while s > floor and _text_w(text, s, weight, th) > max_w:
418
+ s -= 2
419
+ return s
420
+
421
+
422
+ # ---------------- 镜头缓推 + 前景视差 ----------------
423
+
424
+ class _View(object):
425
+ """设计坐标 -> 帧坐标。
426
+
427
+ 为什么不是整帧重采样:实测 1080p 双线性整帧变换 29ms/帧(预算 15ms),
428
+ 而整帧按 push 放大又会把 150px 边距的内容推出 SAFE。所以分两层:
429
+ 背景层做**真缩放**(EXTENT + NEAREST,1.7~3.5ms,见 _bg_plate);
430
+ 前景层做同向**视差**(0.35x,位置与卡片尺寸随镜头收放,字形不重采样保清晰)。
431
+ """
432
+ __slots__ = ("s", "cx", "cy")
433
+
434
+ def __init__(self, s):
435
+ self.s = float(s)
436
+ self.cx = W * 0.5
437
+ self.cy = H * 0.5
438
+
439
+ def x(self, x):
440
+ return int(round(self.cx + (float(x) - self.cx) * self.s))
441
+
442
+ def y(self, y):
443
+ return int(round(self.cy + (float(y) - self.cy) * self.s))
444
+
445
+ def r(self, r):
446
+ return max(0.0, float(r) * self.s)
447
+
448
+
449
+ def _push_scale(th, t_local, dur):
450
+ """镜头缓推:t=0 -> 1.0,t=dur -> 1+push,单调平滑(端点必须收敛,不许抖)。"""
451
+ u = clamp(t_local / max(0.05, float(dur)))
452
+ return 1.0 + float(th.get("push") or _DEFAULT_PUSH) * smooth(u)
453
+
454
+
455
+ def _view(th, s_bg):
456
+ return _View(1.0 + (s_bg - 1.0) * 0.35)
457
+
458
+
459
+ # ---------------- 背景:按主题烘焙一次(渐变 + 材质 + 颗粒 + 暗角) ----------------
460
+
461
+ def _grid_xy():
462
+ yy = np.arange(H, dtype=np.float32)[:, None]
463
+ xx = np.arange(W, dtype=np.float32)[None, :]
464
+ return yy, xx
465
+
466
+
467
+ def _paper_noise(th):
468
+ """纸纹:纤维条纹(vnoise)+ 多尺度斑块(hash2 小图上采样)。一次烘焙,不进逐帧路径。"""
469
+ freq = float(th.get("grain_freq") or 0.27)
470
+ xs = np.arange(W, dtype=np.float32) * freq * 0.35
471
+ fiber = np.array([K.vnoise(float(v), 3) for v in xs], dtype=np.float32) - 0.5
472
+ fine = np.array([K.vnoise(float(v) * 2.3, 9) for v in xs], dtype=np.float32) - 0.5
473
+ rows, cols = 27, 48
474
+ blobs = np.array([[K.hash2(r * 31 + 7, c * 17 + 3) for c in range(cols)]
475
+ for r in range(rows)], dtype=np.float32) - 0.5
476
+ blob_img = Image.fromarray(((blobs + 0.5) * 255.0).astype(np.uint8), "L")
477
+ blobs = np.asarray(blob_img.resize((W, H), Image.BICUBIC), dtype=np.float32) / 255.0 - 0.5
478
+ return blobs * 0.55 + fiber[None, :] * 0.30 + fine[None, :] * 0.15
479
+
480
+
481
+ def _texture_layer(th, yy, xx):
482
+ """背景材质层(灰度偏移;深色主题提亮、浅色主题压暗)。"""
483
+ kind = str(th.get("texture") or "none")
484
+ alpha = float(th.get("texture_alpha") or 0.0)
485
+ if kind == "none" or alpha <= 0.0:
486
+ return None
487
+ sign = 1.0 if ST.is_dark(th) else -1.0
488
+ amp = sign * 255.0 * alpha * 0.44
489
+ if kind == "dots":
490
+ f = float(th.get("grain_freq") or 0.31)
491
+ layer = ((np.sin(xx * f) * np.sin(yy * f)) > 0.955).astype(np.float32)
492
+ elif kind == "grid":
493
+ step = 88.0
494
+ layer = (((xx % step) < 1.0) | ((yy % step) < 1.0)).astype(np.float32)
495
+ elif kind == "scanlines":
496
+ layer = ((yy % 4.0) < 1.0).astype(np.float32)
497
+ elif kind == "paper":
498
+ layer = _paper_noise(th)
499
+ else:
500
+ return None
501
+ return layer * amp
502
+
503
+
504
+ def _grain_layer(th, yy, xx):
505
+ """确定性颗粒(坐标 hash,等价 vnoise 的 2D 版本;不用 random)。"""
506
+ g = float(th.get("grain") or 0.0)
507
+ if g <= 0.05:
508
+ return None
509
+ xi = np.arange(W, dtype=np.int64)[None, :]
510
+ yi = np.arange(H, dtype=np.int64)[:, None]
511
+ v = (xi * 374761393 + yi * 668265263) & 0xFFFFFFFF
512
+ v = (v ^ (v >> 13)) & 0xFFFFFFFF
513
+ v = (v * 1274126177) & 0xFFFFFFFF
514
+ v = (v ^ (v >> 16)) & 0xFFFFFFFF
515
+ noise = (v & 0xFFFF).astype(np.float32) / 65535.0 - 0.5
516
+ flat = np.empty((H, W), dtype=np.float32)
517
+ np.add(noise * (g * 1.2), 0.0, out=flat)
518
+ return flat
519
+
520
+
521
+ def _vignette_layer(th, yy, xx):
522
+ """暗角:浅色主题压向 vignette_tint(米灰),绝不压向纯黑。"""
523
+ strength = float(th.get("vignette") or 0.0)
524
+ if strength <= 0.0:
525
+ return None
526
+ r = np.sqrt(((xx - W * 0.5) / (W * 0.5)) ** 2 + ((yy - H * 0.5) / (H * 0.5)) ** 2) / 1.4142
527
+ m = np.clip((r - 0.40) / 0.60, 0.0, 1.0) ** 1.6 * strength
528
+ return m
529
+
530
+
531
+ def _hashable(v):
532
+ """把 list 递归转成 tuple:主题从 JSON 读回来时元组会变成 list,而 list 不能当 dict 键。
533
+
534
+ 为什么需要它:--jobs >1 时 render.py 的 worker 从剧本快照读 script,主题色的
535
+ (r,g,b) 元组变成 [r,g,b],于是下面 _bg 的缓存键抛 unhashable type: "list"。
536
+ render.py 已按剧本重解风格,但这层防御留着——scenes.py 是用户会拷走改的模板。
537
+ """
538
+ if isinstance(v, (list, tuple)):
539
+ return tuple(_hashable(x) for x in v)
540
+ if isinstance(v, dict):
541
+ return tuple(sorted((k, _hashable(x)) for k, x in v.items()))
542
+ return v
543
+
544
+
545
+ def _bg(th):
546
+ """预烘焙背景:主题色渐变 + 材质 + 颗粒 + 暗角,一次算好,逐帧只 copy / 变换。"""
547
+ key = _hashable(("baked", th.get("id"), th["bg"], th["bg2"], th["texture"], th["grain"],
548
+ th["vignette"], th["vignette_tint"], th["texture_alpha"],
549
+ th["grain_freq"]))
550
+ got = _CACHE.get(key)
551
+ if got is not None:
552
+ return got
553
+ yy, xx = _grid_xy()
554
+ a = np.linspace(0.0, 1.0, H, dtype=np.float32)[:, None, None]
555
+ top = np.array(th["bg"], dtype=np.float32)[None, None, :]
556
+ bot = np.array(th["bg2"], dtype=np.float32)[None, None, :]
557
+ arr = np.repeat(top * (1.0 - a) + bot * a, W, axis=1)
558
+ tex = _texture_layer(th, yy, xx)
559
+ if tex is not None:
560
+ arr = arr + tex[:, :, None]
561
+ grain = _grain_layer(th, yy, xx)
562
+ if grain is not None:
563
+ arr = arr + grain[:, :, None]
564
+ np.clip(arr, 0.0, 255.0, out=arr)
565
+ vig = _vignette_layer(th, yy, xx)
566
+ if vig is not None:
567
+ tint = np.array(th["vignette_tint"], dtype=np.float32)[None, None, :]
568
+ arr = arr * (1.0 - vig)[:, :, None] + tint * vig[:, :, None]
569
+ np.clip(arr, 0.0, 255.0, out=arr)
570
+ im = Image.fromarray(arr.astype(np.uint8), "RGB")
571
+ _CACHE[key] = im
572
+ return im
573
+
574
+
575
+ def _bg_plate(th, s):
576
+ """逐帧背景:按镜头缩放取景(NEAREST 重采样,实测 1.7~3.5ms/帧)。"""
577
+ base = _bg(th)
578
+ if s <= 1.0008:
579
+ return base.copy()
580
+ bw, bh = W / s, H / s
581
+ x0 = (W - bw) * 0.5
582
+ y0 = (H - bh) * 0.5
583
+ return base.transform((W, H), Image.EXTENT, (x0, y0, x0 + bw, y0 + bh), Image.NEAREST)
584
+
585
+
586
+ # ---------------- 版式几何(全部从 ST.LAYOUTS 取) ----------------
587
+
588
+ def _title_anchor(lay):
589
+ """标题锚点 + 对齐方式:left-stack 左对齐 / split-right 右对齐 / center-stack 居中。"""
590
+ if lay.get("center"):
591
+ return W * 0.5, "center"
592
+ if lay.get("title_x") is None:
593
+ return float(W - 150), "right"
594
+ return float(lay["title_x"]), "left"
595
+
596
+
597
+ def _panel_x(lay):
598
+ if lay.get("panel_x") is None:
599
+ return (W - float(lay["panel_w"])) * 0.5
600
+ return float(lay["panel_x"])
601
+
602
+
603
+ def _panel_boxes(lay, th, n, y_floor, v):
604
+ """条目卡片布局:选列数 + 压缩卡片高度,保证①不压标题②不越字幕带(856)。
605
+
606
+ panel_gap 在 stylekit 里是**行距(pitch)**不是间隙:v0.4 用 240 + i*118 排卡片,
607
+ 所以 pitch 至少得比卡片高度多 6px,否则卡片会叠在一起。条目多时先加列,再压高度,
608
+ 最后才截断——静默重叠或画到字幕带下面比少画两条更糟。
609
+ """
610
+ if n <= 0:
611
+ return []
612
+ px = _panel_x(lay)
613
+ pw = float(lay["panel_w"])
614
+ base_pitch = max(float(lay["panel_gap"]), float(lay["panel_h"]) + 6.0)
615
+ ch_base = float(lay["panel_h"])
616
+ y0 = max(float(lay["panel_y"]), float(y_floor))
617
+ ylim = v.cy + (856.0 - v.cy) / max(0.2, v.s)
618
+ avail = max(80.0, ylim - y0)
619
+ if lay.get("center"):
620
+ cand = (1, 2) if n <= 2 else ((2, 3) if n <= 6 else (3, 2))
621
+ else:
622
+ cand = (1, 2) if n <= 5 else ((2, 1) if n <= 10 else (2,))
623
+ chosen = None
624
+ for cols in cand:
625
+ rows = int(math.ceil(n / float(cols)))
626
+ for ch in (ch_base, ch_base * 0.82, 54.0):
627
+ pitch = min(base_pitch, (avail - ch) / max(1, rows - 1)) if rows > 1 else base_pitch
628
+ if pitch < ch + 6.0:
629
+ continue
630
+ if (rows - 1) * pitch + ch <= avail + 0.5:
631
+ chosen = (cols, rows, ch, pitch)
632
+ break
633
+ if chosen:
634
+ break
635
+ if chosen is None:
636
+ cols = cand[-1]
637
+ ch, pitch = 54.0, 60.0
638
+ rows = int(max(1, (avail + 6.0) // pitch))
639
+ chosen = (cols, rows, ch, pitch)
640
+ cols, rows, ch, pitch = chosen
641
+ cap = rows * cols
642
+ if n > cap:
643
+ _warn_once(("panel", lay.get("layout"), n),
644
+ "本段有 %d 个条目,版式只放得下 %d 个(其余已省略);"
645
+ "请拆成多段或减少 items" % (n, cap))
646
+ gx = 40.0 if cols > 1 else 0.0
647
+ cw = (pw - gx * (cols - 1)) / cols
648
+ boxes = []
649
+ for i in range(min(n, cap)):
650
+ r, c = divmod(i, cols)
651
+ boxes.append((px + c * (cw + gx), y0 + r * pitch, cw, ch))
652
+ return boxes
653
+
654
+
655
+ # ---------------- 文字绘制(位置过视差,字号不重采样) ----------------
656
+
657
+ def _draw_text(img, th, v, text, x, y, size, weight, color, align="left",
658
+ alpha=1.0, tracking=0.0):
659
+ text = "" if text is None else str(text)
660
+ if not text or alpha <= 0.004:
661
+ return
662
+ size = int(size)
663
+ px = v.x(x)
664
+ py = v.y(y)
665
+ font = _font(th, size, weight)
666
+ fill = _rgba(color, 255.0 * clamp(alpha))
667
+ d = ImageDraw.Draw(img, "RGBA")
668
+ tr = float(tracking or 0.0)
669
+ if tr > 0.3 and len(text) > 1:
670
+ total = _text_w(text, size, weight, th) + tr * (len(text) - 1)
671
+ if align == "center":
672
+ px -= int(total * 0.5)
673
+ elif align == "right":
674
+ px -= int(total)
675
+ for ch in text:
676
+ d.text((px, py), ch, font=font, fill=fill)
677
+ px += _text_w(ch, size, weight, th) + tr
678
+ return
679
+ w = _text_w(text, size, weight, th)
680
+ if align == "center":
681
+ px -= int(w * 0.5)
682
+ elif align == "right":
683
+ px -= int(w)
684
+ d.text((px, py), text, font=font, fill=fill)
685
+
686
+
687
+ def _entrance_x(th, p, align):
688
+ dx = float(th.get("entrance_dx") or 0)
689
+ if align == "right":
690
+ return -dx * (1.0 - p)
691
+ if align == "center":
692
+ return 0.0
693
+ return dx * (1.0 - p)
694
+
695
+
696
+ def _entrance_y(th, p):
697
+ return float(th.get("entrance_dy") or 0) * (1.0 - p)
698
+
699
+
700
+ # ============================================================================
701
+ # 二、HUD(三档:chip-left / corner-line / none)
702
+ # ============================================================================
703
+
704
+ def _hud(img, th, chapter, progress, title):
705
+ style = str(th.get("hud") or "chip-left")
706
+ if style == "none":
707
+ return img
708
+ accent = th["accent"]
709
+ rule = th["rule"]
710
+ ink = th["ink"]
711
+ prog = clamp(progress)
712
+ d = ImageDraw.Draw(img, "RGBA")
713
+ if style == "chip-left":
714
+ d.rectangle([0, 0, W, 3], fill=_rgba(rule, 14))
715
+ d.rectangle([0, 0, int(W * prog), 3], fill=_rgba(accent, 255))
716
+ cs = int(th["scale"]["chip"][0])
717
+ tw = _text_w(chapter, cs, "reg", th)
718
+ pad_l, pad_r = 16, 18
719
+ h = int(cs * 1.95)
720
+ x0, y0 = 64, 42
721
+ x1 = x0 + pad_l + tw + pad_r
722
+ fillc = _mix(th["surface"], th["bg"], 0.55)
723
+ d.rounded_rectangle([x0, y0, x1, y0 + h], radius=h // 2,
724
+ fill=_rgba(fillc, 168), outline=_rgba(rule, 36))
725
+ d.ellipse([x0 + 16, y0 + h // 2 - 5, x0 + 26, y0 + h // 2 + 5],
726
+ fill=_rgba(accent, 255))
727
+ d.text((x0 + 34, y0 + (h - cs) // 2 - 3), chapter,
728
+ font=_font(th, cs, "reg"), fill=_rgba(ink, 228))
729
+ bs = int(th["scale"]["brand"][0])
730
+ tw2 = _text_w(title, bs, "reg", th)
731
+ d.text((W - 64 - tw2, 56), title, font=_font(th, bs, "reg"),
732
+ fill=_rgba(_mix(ink, th["bg"], 0.62), 220))
733
+ return img
734
+ # corner-line:只有角标与细线,没有胶囊底
735
+ d.rectangle([0, 0, W, 3], fill=_rgba(rule, 10))
736
+ d.rectangle([0, 0, int(W * prog), 2], fill=_rgba(accent, 214))
737
+ d.line([64, 44, 64, 96], fill=_rgba(accent, 190), width=2)
738
+ d.line([64, 44, 116, 44], fill=_rgba(accent, 190), width=2)
739
+ cs = int(th["scale"]["chip"][0])
740
+ d.text((84, 54), chapter, font=_font(th, cs, "reg"), fill=_rgba(ink, 220))
741
+ bs = int(th["scale"]["brand"][0])
742
+ tw = _text_w(title, bs, "reg", th)
743
+ d.text((W - 64 - tw, 56), title, font=_font(th, bs, "reg"), fill=_rgba(ink, 120))
744
+ d.line([W - 64, 44, W - 64 - 96, 44], fill=_rgba(accent, 120), width=2)
745
+ d.line([W - 64, 44, W - 64, 96], fill=_rgba(accent, 120), width=2)
746
+ return img
747
+
748
+
749
+ # ============================================================================
750
+ # 三、三段场景(hook / body / outro)
751
+ # ============================================================================
752
+
753
+ def _progress(tg, script):
754
+ return clamp(float(tg) / max(0.1, float(script.get("_total") or 1.0)))
755
+
756
+
757
+ def _deco_rings(img, th, v, t, cx, cy, strength):
758
+ """开场放射线 + 光点(位置过视差;颜色/透明度按主题)。"""
759
+ d = ImageDraw.Draw(img, "RGBA")
760
+ rule = th["rule"]
761
+ p = smooth(rng(t, 0.15, 2.6))
762
+ opacity = clamp(0.30 if not ST.is_dark(th) else 0.27) * strength
763
+ for i in range(18):
764
+ a = (i / 18.0) * 2 * math.pi + 0.2
765
+ rr = 60 + 420 * p * (0.55 + 0.45 * K.hash2(i, 11))
766
+ x2 = cx + math.cos(a) * rr
767
+ y2 = cy + math.sin(a) * rr * 0.62
768
+ d.line([v.x(cx), v.y(cy), v.x(x2), v.y(y2)],
769
+ fill=_rgba(rule, 255 * opacity * p), width=1)
770
+ if K.hash2(i, 9) > 0.45:
771
+ img = _soft_dot(img, v.x(x2), v.y(y2), 3 + 5 * K.hash2(i, 5),
772
+ th["accent2"], 0.50 * p * strength)
773
+ img = _soft_dot(img, v.x(cx), v.y(cy), 24 + 5 * math.sin(t * 2.2),
774
+ th["accent"], 0.85 * rng(t, 0.1, 0.7) * strength)
775
+ return img
776
+
777
+
778
+ def _draw_hook(scene, t, dur, tg, script, frame, plan, th):
779
+ s_bg = _push_scale(th, t, dur)
780
+ v = _view(th, s_bg)
781
+ img = _bg_plate(th, s_bg)
782
+ lay = ST.LAYOUTS[th["layout"]]
783
+ ax, align = _title_anchor(lay)
784
+ if lay.get("center"):
785
+ cx, cy, strength = W * 0.5, 430.0, 0.55
786
+ elif align == "right":
787
+ cx, cy, strength = 620.0, 470.0, 1.0
788
+ else:
789
+ cx, cy, strength = 1300.0, 470.0, 1.0
790
+ img = _deco_rings(img, th, v, t, cx, cy, strength)
791
+
792
+ ease = _ease(th.get("ease") or "out3")
793
+ tracking = float(th.get("tracking") or 0.0)
794
+ big = scene.get("big") or script.get("title") or ""
795
+ size = int(th["scale"]["display"][0])
796
+ # 封面标题用整行宽度:版式里的 title_w 是正文栏宽(820),给 96px 大字会把标题折出孤字。
797
+ max_w = min(1560.0, float(W - 300))
798
+ lines = _wrap(big, size, max_w, "bold", th)
799
+ while len(lines) > 3 and size > 42:
800
+ size = int(size * 0.88)
801
+ lines = _wrap(big, size, max_w, "bold", th)
802
+ lh = int(size * float(th.get("line_gap") or 1.3))
803
+ ksize = int(th["scale"]["kicker"][0])
804
+ kicker = scene.get("kicker") or "00 · 开场"
805
+
806
+ p0 = ease(rng(t, 0.05, 0.85))
807
+ _draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
808
+ lay["kicker_y"] + _entrance_y(th, p0), ksize, "bold",
809
+ th["accent"], align, p0, tracking * 0.5)
810
+
811
+ y = float(lay["title_y"])
812
+ for i, ln in enumerate(lines):
813
+ p = ease(rng(t, 0.12 + 0.13 * i, 0.88 + 0.13 * i))
814
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p, align),
815
+ y + i * lh + _entrance_y(th, p), size, "bold",
816
+ th["ink"], align, p, tracking)
817
+
818
+ sub = scene.get("lead") or scene.get("sub") or ""
819
+ sub_bottom = y + len(lines) * lh
820
+ if sub:
821
+ ssize = int(th["scale"]["sub"][0])
822
+ sy = sub_bottom + int(size * 0.24)
823
+ p2 = ease(rng(t, 0.30, 1.40))
824
+ for j, ln in enumerate(_wrap(sub, ssize, float(lay["sub_w"]), "light", th)[:3]):
825
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p2, align),
826
+ sy + j * int(ssize * float(th.get("line_gap") or 1.3)),
827
+ ssize, "light", th["muted"], align, p2)
828
+ sub_bottom = sy + int(ssize * float(th.get("line_gap") or 1.3))
829
+
830
+ # 持续动态元素:标题下一条缓慢呼吸的强调线(让画面永不静止)
831
+ amp = 0.45 + 0.55 * (0.5 + 0.5 * math.sin(0.85 * t + 0.5))
832
+ line_w = int(min(lay["title_w"], 760) * 0.55 * amp)
833
+ line_y = sub_bottom + int(size * 0.42)
834
+ if line_y < 856:
835
+ lx = ax if align == "left" else (ax - line_w if align == "right" else ax - line_w // 2)
836
+ ImageDraw.Draw(img, "RGBA").line(
837
+ [v.x(lx), v.y(line_y), v.x(lx + line_w), v.y(line_y)],
838
+ fill=_rgba(th["accent"], 190), width=3)
839
+
840
+ return _hud(img, th, scene.get("chapter") or "01 · 开场", _progress(tg, script),
841
+ script.get("title", ""))
842
+
843
+
844
+ def _draw_items(img, th, v, boxes, items, t, stagger):
845
+ ease = _ease(th.get("ease") or "out3")
846
+ card = th["card"]
847
+ rule = th["rule"]
848
+ ink = th["ink"]
849
+ body = max(20, int(th["scale"]["body"][0]) - 1)
850
+ brand = int(th["scale"]["brand"][0])
851
+ pad = max(12, int(card["pad"] * 0.7))
852
+ for i, it in enumerate(items):
853
+ if i >= len(boxes):
854
+ break
855
+ x, y, w, h = boxes[i]
856
+ p = ease(rng(t, 0.48 + stagger * i, 1.18 + stagger * i))
857
+ if p <= 0.0:
858
+ continue
859
+ a = clamp(p)
860
+ dy = _entrance_y(th, p)
861
+ alpha = a
862
+ d = ImageDraw.Draw(img, "RGBA")
863
+ if card["fill_a"] > 0 or card["outline_a"] > 0:
864
+ x0, y0 = v.x(x), v.y(y + dy)
865
+ x1, y1 = v.x(x + w), v.y(y + dy + h)
866
+ radius = int(max(0, card["radius"]) * v.s)
867
+ d.rounded_rectangle([x0, y0, x1, y1], radius=radius,
868
+ fill=_rgba(rule, card["fill_a"] * alpha),
869
+ outline=_rgba(rule, card["outline_a"] * alpha))
870
+ if isinstance(it, dict):
871
+ txt = str(it.get("text") or "")
872
+ ok = bool(it.get("ok", True))
873
+ else:
874
+ txt = str(it)
875
+ ok = True
876
+ col = th["good"] if ok else th["bad"]
877
+ dot_x = v.x(x + pad + 16)
878
+ dot_y = v.y(y + dy + h * 0.5)
879
+ dot_r = 9.0 + 1.6 * math.sin(t * 2.1 + i * 0.8)
880
+ img = _soft_dot(img, dot_x, dot_y, dot_r * v.s, col, 0.95 * alpha)
881
+ d = ImageDraw.Draw(img, "RGBA")
882
+ tx = x + pad + 16 + 18 + 10
883
+ max_w = (x + w - pad) - tx - 46
884
+ tsize = _fit_size(txt, max_w, body, "reg", th, 16)
885
+ d.text((v.x(tx), v.y(y + dy + (h - tsize * 1.2) * 0.5)), txt,
886
+ font=_font(th, tsize, "reg"), fill=_rgba(ink, 236 * alpha))
887
+ if w >= 420:
888
+ d.text((v.x(x + w - pad), v.y(y + dy + (h - brand * 1.25) * 0.5)),
889
+ "%02d" % (i + 1), font=_font(th, brand, "reg"),
890
+ fill=_rgba(th["dim"], 120 * alpha), anchor="ra")
891
+ return img
892
+
893
+
894
+ def _draw_body(scene, t, dur, tg, script, frame, plan, th):
895
+ s_bg = _push_scale(th, t, dur)
896
+ v = _view(th, s_bg)
897
+ img = _bg_plate(th, s_bg)
898
+ lay = ST.LAYOUTS[th["layout"]]
899
+ ax, align = _title_anchor(lay)
900
+ ease = _ease(th.get("ease") or "out3")
901
+ tracking = float(th.get("tracking") or 0.0)
902
+ gap_ratio = float(th.get("line_gap") or 1.3)
903
+
904
+ kicker = scene.get("kicker") or ""
905
+ ksize = int(th["scale"]["kicker"][0])
906
+ p0 = ease(rng(t, 0.0, 0.75))
907
+ if kicker:
908
+ _draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
909
+ lay["kicker_y"] + _entrance_y(th, p0), ksize, "bold",
910
+ th["accent"], align, p0, tracking * 0.5)
911
+
912
+ big = scene.get("big") or (scene.get("text") or "")[:18]
913
+ bsize = int(th["scale"]["heading"][0])
914
+ max_w = float(lay["title_w"])
915
+ lines = _wrap(big, bsize, max_w, "bold", th)
916
+ while len(lines) > 3 and bsize > 40:
917
+ bsize = int(bsize * 0.9)
918
+ lines = _wrap(big, bsize, max_w, "bold", th)
919
+ lh = int(bsize * gap_ratio)
920
+ p1 = ease(rng(t, 0.06, 0.80))
921
+ y0 = float(lay["title_y"]) + _entrance_y(th, p1)
922
+ for i, ln in enumerate(lines):
923
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p1, align),
924
+ y0 + i * lh, bsize, "bold", th["ink"], align, p1, tracking)
925
+
926
+ sub_bottom = y0 + len(lines) * lh
927
+ sub = scene.get("sub") or ""
928
+ if sub:
929
+ ssize = int(th["scale"]["body"][0])
930
+ sy = max(float(lay["sub_y"]) if not lay.get("center") else sub_bottom + int(bsize * 0.30),
931
+ sub_bottom + int(bsize * 0.30))
932
+ p2 = ease(rng(t, 0.20, 0.95))
933
+ for j, ln in enumerate(_wrap(sub, ssize, float(lay["sub_w"]), "reg", th)[:2]):
934
+ _draw_text(img, th, v, ln, ax + _entrance_x(th, p2, align),
935
+ sy + j * int(ssize * gap_ratio), ssize, "reg",
936
+ th["muted"], align, p2)
937
+ sub_bottom = sy + int(ssize * gap_ratio)
938
+
939
+ # 持续动态 1:标题旁的强调线随呼吸伸缩(0.85 rad/s,永不静止)
940
+ meter_w = int(min(max_w, 780) * 0.46 * (0.55 + 0.45 * (0.5 + 0.5 * math.sin(0.85 * t + 0.6))))
941
+ meter_y = sub_bottom + int(bsize * 0.22)
942
+ if meter_y < 850:
943
+ mx = ax if align == "left" else (ax - meter_w if align == "right" else ax - meter_w // 2)
944
+ ImageDraw.Draw(img, "RGBA").line(
945
+ [v.x(mx), v.y(meter_y), v.x(mx + meter_w), v.y(meter_y)],
946
+ fill=_rgba(th["accent"], 170), width=3)
947
+
948
+ items = list(scene.get("items") or [])
949
+ if items:
950
+ stagger = float((plan.get("pacing") or {}).get("item_stagger") or 0.32)
951
+ # 居中版式里标题/副标题横跨整幅,面板顶必须让开真实底边;
952
+ # 左右版式的卡片在另一列,从 layout 的 panel_y 起步即可,不必被标题高度牵着走。
953
+ if lay.get("center"):
954
+ panel_top = sub_bottom + int(bsize * 0.55)
955
+ else:
956
+ panel_top = float(lay["panel_y"])
957
+ boxes = _panel_boxes(lay, th, len(items), panel_top, v)
958
+ # 持续动态 2:卡片区里一条缓慢上下扫的高光(sin 振荡,不做取模跳变)
959
+ if boxes:
960
+ px0 = min(b[0] for b in boxes)
961
+ px1 = max(b[0] + b[2] for b in boxes)
962
+ py0 = min(b[1] for b in boxes)
963
+ py1 = max(b[1] + b[3] for b in boxes)
964
+ k = 0.5 + 0.5 * math.sin(t * 0.55)
965
+ sy = py0 + (py1 - py0) * k
966
+ ImageDraw.Draw(img, "RGBA").line(
967
+ [v.x(px0), v.y(sy), v.x(px1), v.y(sy)],
968
+ fill=_rgba(th["accent"], 26), width=2)
969
+ img = _draw_items(img, th, v, boxes, items, t, stagger)
970
+
971
+ return _hud(img, th, scene.get("chapter") or "02 · 正文", _progress(tg, script),
972
+ script.get("title", ""))
973
+
974
+
975
+ def _deco_orbit(img, th, v, t, cx, cy, p):
976
+ """收尾旋转光点环(旋转速度恒定 -> 永远有运动)。"""
977
+ for i in range(26):
978
+ a = (i / 26.0) * 2.0 * math.pi + t * 0.15
979
+ rr = 460.0 * (1.0 - p) + 60.0 + 16.0 * K.hash2(i, 4)
980
+ img = _soft_dot(img, v.x(cx + math.cos(a) * rr),
981
+ v.y(cy + math.sin(a) * rr * 0.5), 3.0,
982
+ th["accent2"], 0.5 * (1.0 - p * 0.7))
983
+ return img
984
+
985
+
986
+ def _draw_outro(scene, t, dur, tg, script, frame, plan, th):
987
+ s_bg = _push_scale(th, t, dur)
988
+ v = _view(th, s_bg)
989
+ img = _bg_plate(th, s_bg)
990
+ lay = ST.LAYOUTS[th["layout"]]
991
+ ease = _ease(th.get("ease") or "out3")
992
+ tracking = float(th.get("tracking") or 0.0)
993
+ gap_ratio = float(th.get("line_gap") or 1.3)
994
+
995
+ p = ease(rng(t, 0.0, 1.0))
996
+ img = _deco_orbit(img, th, v, t, W * 0.5, 520.0, p)
997
+
998
+ big = scene.get("big") or script.get("end_text") or "完"
999
+ size = int(th["scale"]["display"][0])
1000
+ lines = _wrap(big, size, min(float(lay["title_w"]), 1520.0), "bold", th)
1001
+ while len(lines) > 3 and size > 42:
1002
+ size = int(size * 0.88)
1003
+ lines = _wrap(big, size, min(float(lay["title_w"]), 1520.0), "bold", th)
1004
+ lh = int(size * gap_ratio)
1005
+ pa = ease(rng(t, 0.30, 1.30))
1006
+ alpha = 0.02 + 0.98 * pa
1007
+ dy = _entrance_y(th, pa)
1008
+ top = 380.0 - (len(lines) - 1) * lh * 0.5
1009
+ for i, ln in enumerate(lines):
1010
+ _draw_text(img, th, v, ln, W * 0.5, top + i * lh + dy,
1011
+ size, "bold", th["ink"], "center", alpha, tracking)
1012
+ bottom = top + len(lines) * lh + dy
1013
+
1014
+ sub = scene.get("sub") or ""
1015
+ if sub:
1016
+ ssize = int(th["scale"]["sub"][0])
1017
+ p2 = ease(rng(t, 0.55, 1.55))
1018
+ for j, ln in enumerate(_wrap(sub, ssize, 1400, "light", th)[:2]):
1019
+ _draw_text(img, th, v, ln, W * 0.5,
1020
+ bottom + int(size * 0.22) + j * int(ssize * gap_ratio),
1021
+ ssize, "light", th["muted"], "center", p2)
1022
+ bottom = bottom + int(size * 0.22) + int(ssize * gap_ratio)
1023
+
1024
+ # 持续动态:标题下缓慢生长的强调线 + 两端呼吸
1025
+ grow = ease(rng(t, 0.45, 1.45))
1026
+ line_w = int(340 * grow * (0.82 + 0.18 * math.sin(0.9 * t)))
1027
+ ly = min(bottom + int(size * 0.30), 840)
1028
+ ImageDraw.Draw(img, "RGBA").line(
1029
+ [v.x(W * 0.5 - line_w * 0.5), v.y(ly), v.x(W * 0.5 + line_w * 0.5), v.y(ly)],
1030
+ fill=_rgba(th["accent"], 200), width=3)
1031
+
1032
+ return _hud(img, th, scene.get("chapter") or "99 · 收尾", 1.0, script.get("title", ""))
1033
+
1034
+
1035
+ _DRAWERS = {
1036
+ "hook": _draw_hook,
1037
+ "body": _draw_body,
1038
+ "outro": _draw_outro,
1039
+ }
1040
+
1041
+
1042
+ # ============================================================================
1043
+ # 四、对外契约
1044
+ # ============================================================================
1045
+
202
1046
  def draw_frame(scene, t_local, dur, t_global, script, frame):
203
- fn = DRAWERS.get(scene.get("kind") or "body", scene_body)
204
- return fn(scene, t_local, dur, t_global, script, frame)
1047
+ """按帧画一张 1920x1080 RGB 图。legacy 走 v0.4 原函数(逐字节基线)。"""
1048
+ plan = _plan(script)
1049
+ kind = scene.get("kind") or "body"
1050
+ if plan.get("legacy"):
1051
+ fn = _LEGACY_DRAWERS.get(kind) or _legacy_scene_body
1052
+ return fn(scene, t_local, dur, t_global, script, frame)
1053
+ th = _scene_theme(plan, scene)
1054
+ fn = _DRAWERS.get(kind) or _draw_body
1055
+ return fn(scene, t_local, dur, t_global, script, frame, plan, th)
205
1056
 
206
1057
 
207
1058
  # ---------------- 提示点(v0.3.0,可选) ----------------
@@ -211,16 +1062,23 @@ def cues(tl, script):
211
1062
  好处是"声音跟着画面的像素走":这里算的是卡片落位、数字滚完的时刻,
212
1063
  画面一改,声音自动跟着改,不用回来手工对时间。
213
1064
 
1065
+ v0.5:条目落位步长与场景绘制共用 plan["pacing"]["item_stagger"],
1066
+ 两边读同一个数,否则音效会错位(这是 v0.4 写死 0.32 留下的坑)。
1067
+
214
1068
  返回 [{"at": 时刻, "type": 配方名, ...参数}];
215
1069
  at 可以写 "abs:12.5" / 12.5 / "l03+0.35"(相对该段 start)/"l03"
216
1070
  type 见 references/sfx-design.md 的配方表,或 sfxkit.list_recipes()
217
1071
  """
1072
+ plan = _plan(script)
1073
+ stagger = 0.32
1074
+ if not plan.get("legacy"):
1075
+ stagger = float((plan.get("pacing") or {}).get("item_stagger") or 0.32)
218
1076
  out = []
219
1077
  for s in tl["scenes"]:
220
1078
  if s.get("kind") == "body":
221
- # 条目逐条落位:第一项在 0.45s 出现,之后每 0.32s 一个(和场景里 _soft_dot 的节拍一致)
1079
+ # 条目逐条落位:第一项在 0.48s 出现,之后每 stagger 秒一个(和 _draw_items 同一节拍)
222
1080
  for i, _it in enumerate(s.get("items") or []):
223
- out.append({"at": "%s+%.2f" % (s["id"], 0.48 + 0.32 * i),
1081
+ out.append({"at": "%s+%.2f" % (s["id"], 0.48 + stagger * i),
224
1082
  "type": "ui_blip", "pitch": 1760.0 + 110.0 * i, "gain": -6})
225
1083
  if s.get("kind") == "outro":
226
1084
  out.append({"at": "%s+0.35" % s["id"], "type": "answer_chime", "gain": -4})