@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.
- package/README.md +62 -12
- package/docs/styles/demo-results.json +76 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/package.json +11 -3
- package/schemas/film.schema.json +528 -0
- package/skills/video-studio/SKILL.md +166 -14
- package/skills/video-studio/assets/film.json +84 -23
- package/skills/video-studio/assets/scenes.py +892 -34
- package/skills/video-studio/references/audio.md +29 -1
- package/skills/video-studio/references/change-impact.md +54 -0
- package/skills/video-studio/references/quality-gates.md +1 -1
- package/skills/video-studio/references/style-packs.md +178 -0
- package/skills/video-studio/requirements.txt +11 -0
- package/skills/video-studio/scripts/filmcheck.py +656 -0
- package/skills/video-studio/scripts/filmkit.py +682 -58
- package/skills/video-studio/scripts/gates.py +446 -0
- package/skills/video-studio/scripts/render.py +562 -34
- package/skills/video-studio/scripts/scorekit.py +1642 -25
- package/skills/video-studio/scripts/selfcheck.py +346 -0
- package/skills/video-studio/scripts/stagewatch.py +295 -0
- package/skills/video-studio/scripts/stylekit.py +924 -0
- package/skills/video-studio/scripts/voicekit.py +872 -0
- package/tools/ci_check.py +339 -0
- package/tools/estimate.py +578 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""scenes.py — 场景绘制模板(v0.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
162
|
+
return _legacy_hud(img, "01 · 开场", progress, title)
|
|
130
163
|
|
|
131
164
|
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
152
|
-
img =
|
|
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
|
|
177
|
-
|
|
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
|
|
181
|
-
img =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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.
|
|
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 +
|
|
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})
|