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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +93 -18
  2. package/docs/styles/demo-results.json +76 -0
  3. package/docs/styles/sheet-8-styles.png +0 -0
  4. package/docs/styles/sheet-ai.png +0 -0
  5. package/docs/styles/sheet-craft.png +0 -0
  6. package/docs/styles/sheet-night.png +0 -0
  7. package/index.js +20 -7
  8. package/package.json +13 -3
  9. package/schemas/film.schema.json +599 -0
  10. package/skills/video-studio/SKILL.md +252 -28
  11. package/skills/video-studio/assets/film.json +78 -24
  12. package/skills/video-studio/assets/scenes.py +1005 -42
  13. package/skills/video-studio/assets/web/engine.js +18 -1
  14. package/skills/video-studio/assets/web/film.js +29 -4
  15. package/skills/video-studio/assets/web/index.html +5 -0
  16. package/skills/video-studio/references/audio-variation.md +158 -0
  17. package/skills/video-studio/references/audio.md +96 -6
  18. package/skills/video-studio/references/change-impact.md +54 -0
  19. package/skills/video-studio/references/quality-gates.md +1 -1
  20. package/skills/video-studio/references/style-packs.md +178 -0
  21. package/skills/video-studio/references/web-engine.md +1 -1
  22. package/skills/video-studio/requirements.txt +13 -1
  23. package/skills/video-studio/scripts/filmcheck.py +725 -0
  24. package/skills/video-studio/scripts/filmkit.py +953 -76
  25. package/skills/video-studio/scripts/gates.py +509 -0
  26. package/skills/video-studio/scripts/qa_frames.py +109 -19
  27. package/skills/video-studio/scripts/render.py +942 -45
  28. package/skills/video-studio/scripts/scorekit.py +1887 -27
  29. package/skills/video-studio/scripts/selfcheck.py +648 -2
  30. package/skills/video-studio/scripts/sfxkit.py +132 -30
  31. package/skills/video-studio/scripts/stagewatch.py +405 -0
  32. package/skills/video-studio/scripts/stylekit.py +1012 -0
  33. package/skills/video-studio/scripts/voicekit.py +880 -0
  34. package/skills/video-studio/scripts/webengine.py +68 -14
  35. package/tools/audio_similarity.py +279 -0
  36. package/tools/ci_check.py +450 -0
  37. package/tools/estimate.py +657 -0
  38. package/tools/selftest_noscipy.py +70 -0
@@ -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,62 @@
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
 
32
+ import hashlib
22
33
  import math
34
+ import os
35
+ import sys
36
+ import warnings
23
37
 
24
38
  from PIL import Image, ImageDraw, ImageFilter
25
39
  import numpy as np
26
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
+
27
53
  import filmkit as K
28
54
  # 只 import 模板**当下真的用到**的名字(多余导入会被 pyflakes 记一笔,也容易让人以为哪都能用)。
29
55
  # 写自己的场景时,缓动/噪声直接走模块:K.lerp / K.ease_in / K.ease_in_out / K.vnoise / K.hash2 …
30
56
  from filmkit import W, H, clamp, smooth, ease_out, rng
31
57
 
32
- # ---------------- 主题(改这里换整片气质) ----------------
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) ----------------
33
67
  THEME = dict(
34
68
  bg_top=(7, 14, 26), bg_bot=(17, 33, 54),
35
69
  ink=(240, 245, 252), muted=(150, 174, 200), dim=(96, 118, 144),
@@ -69,15 +103,6 @@ def _vignette_mask(w, h, strength=0.40):
69
103
  return mask
70
104
 
71
105
 
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
106
  def _soft_dot(img, x, y, r, color, alpha=1.0):
82
107
  """柔边圆点精灵(预渲染一次,之后只 paste)。"""
83
108
  if r < 0.6 or alpha <= 0.004:
@@ -96,7 +121,16 @@ def _soft_dot(img, x, y, r, color, alpha=1.0):
96
121
  return img
97
122
 
98
123
 
99
- def _hud(img, chapter, progress, title):
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):
100
134
  d = ImageDraw.Draw(img, "RGBA")
101
135
  d.rectangle([0, 0, int(W * clamp(progress)), 3], fill=THEME["accent"])
102
136
  d.rectangle([0, 0, W, 3], fill=(255, 255, 255, 11))
@@ -112,7 +146,7 @@ def _hud(img, chapter, progress, title):
112
146
  return img
113
147
 
114
148
 
115
- def _cover(img, t, dur, kicker, big, sub, progress, title):
149
+ def _legacy_cover(img, t, dur, kicker, big, sub, progress, title):
116
150
  d = ImageDraw.Draw(img, "RGBA")
117
151
  p = ease_out(rng(t, 0.0, 0.9))
118
152
  p2 = ease_out(rng(t, 0.25, 1.3))
@@ -126,12 +160,11 @@ def _cover(img, t, dur, kicker, big, sub, progress, title):
126
160
  for j, ln in enumerate(K.wrap_cn_cached(sub, 38, 1400, "light")):
127
161
  d.text((150, y + 26 + j * 56), ln, font=K.font(38, "light"),
128
162
  fill=THEME["muted"] + (int(242 * p2),))
129
- return _hud(img, "01 · 开场", progress, title)
163
+ return _legacy_hud(img, "01 · 开场", progress, title)
130
164
 
131
165
 
132
- # ---------------- 场景 ----------------
133
- def scene_hook(scene, t, dur, tg, script, frame):
134
- img = _bg().copy()
166
+ def _legacy_scene_hook(scene, t, dur, tg, script, frame):
167
+ img = _legacy_bg().copy()
135
168
  d = ImageDraw.Draw(img, "RGBA")
136
169
  cx, cy = W // 2, H // 2 - 40
137
170
  p = smooth(rng(t, 0.15, 2.6))
@@ -143,13 +176,13 @@ def scene_hook(scene, t, dur, tg, script, frame):
143
176
  if K.hash2(i, 9) > 0.45:
144
177
  img = _soft_dot(img, x2, y2, 3 + 5 * K.hash2(i, 5), THEME["accent2"], 0.55 * p)
145
178
  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", ""))
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", ""))
149
182
 
150
183
 
151
- def scene_body(scene, t, dur, tg, script, frame):
152
- img = _bg().copy()
184
+ def _legacy_scene_body(scene, t, dur, tg, script, frame):
185
+ img = _legacy_bg().copy()
153
186
  d = ImageDraw.Draw(img, "RGBA")
154
187
  p = ease_out(rng(t, 0.0, 0.8))
155
188
  d.text((150, 250 + int(14 * (1 - p))), scene.get("kicker", ""),
@@ -173,12 +206,12 @@ def scene_body(scene, t, dur, tg, script, frame):
173
206
  d = ImageDraw.Draw(img, "RGBA")
174
207
  d.text((x + 60, y + 28), it["text"], font=K.font(32, "reg"),
175
208
  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", ""))
209
+ return _legacy_hud(img, scene.get("chapter") or "02 · 正文",
210
+ tg / max(0.1, script.get("_total", 1.0)), script.get("title", ""))
178
211
 
179
212
 
180
- def scene_outro(scene, t, dur, tg, script, frame):
181
- img = _bg().copy()
213
+ def _legacy_scene_outro(scene, t, dur, tg, script, frame):
214
+ img = _legacy_bg().copy()
182
215
  d = ImageDraw.Draw(img, "RGBA")
183
216
  p = ease_out(rng(t, 0.0, 1.0))
184
217
  cx, cy = W // 2, H // 2 - 30
@@ -193,35 +226,965 @@ def scene_outro(scene, t, dur, tg, script, frame):
193
226
  for i, ln in enumerate(lines):
194
227
  d.text(((W - K.text_w_cached(ln, 96, "bold")) / 2, 380 + i * 118), ln,
195
228
  font=K.font(96, "bold"), fill=THEME["ink"] + (alpha,))
196
- return _hud(img, "99 · 收尾", 1.0, script.get("title", ""))
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
+
197
586
 
587
+ # ---------------- 版式几何(全部从 ST.LAYOUTS 取) ----------------
198
588
 
199
- DRAWERS = {"hook": scene_hook, "body": scene_body, "outro": scene_outro}
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"
200
596
 
201
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
+
202
1047
  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)
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
205
1108
 
206
1109
 
207
- # ---------------- 提示点(v0.3.0,可选) ----------------
208
1110
  def cues(tl, script):
209
1111
  """画面提示点钩子:render.py --stage cues 会调它(没有这个函数就不调)。
210
1112
 
211
1113
  好处是"声音跟着画面的像素走":这里算的是卡片落位、数字滚完的时刻,
212
1114
  画面一改,声音自动跟着改,不用回来手工对时间。
213
1115
 
214
- 返回 [{"at": 时刻, "type": 配方名, ...参数}];
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": 配方名, ...参数}];
215
1129
  at 可以写 "abs:12.5" / 12.5 / "l03+0.35"(相对该段 start)/"l03"
216
1130
  type 见 references/sfx-design.md 的配方表,或 sfxkit.list_recipes()
217
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 [])
218
1137
  out = []
219
- for s in tl["scenes"]:
220
- if s.get("kind") == "body":
221
- # 条目逐条落位:第一项在 0.45s 出现,之后每 0.32s 一个(和场景里 _soft_dot 的节拍一致)
222
- for i, _it in enumerate(s.get("items") or []):
223
- out.append({"at": "%s+%.2f" % (s["id"], 0.48 + 0.32 * i),
224
- "type": "ui_blip", "pitch": 1760.0 + 110.0 * i, "gain": -6})
225
- if s.get("kind") == "outro":
226
- out.append({"at": "%s+0.35" % s["id"], "type": "answer_chime", "gain": -4})
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))
227
1190
  return out