@dsh-gis/dsh-video-studio-skill 0.9.1 → 1.0.4
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/CHANGELOG.md +473 -0
- package/README.md +263 -327
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/package.json +9 -10
- package/schemas/design.schema.json +722 -0
- package/schemas/film.schema.json +31 -42
- package/schemas/proposal.schema.json +133 -0
- package/skills/video-studio/SKILL.md +1216 -1123
- package/skills/video-studio/assets/design_fixture.json +141 -0
- package/skills/video-studio/assets/film.json +2 -4
- package/skills/video-studio/assets/golden_frames.json +13 -10
- package/skills/video-studio/assets/scenes.py +153 -65
- package/skills/video-studio/assets/voice_loudness.json +16 -0
- package/skills/video-studio/references/appearance.md +123 -0
- package/skills/video-studio/references/audio-variation.md +74 -93
- package/skills/video-studio/references/audio.md +64 -66
- package/skills/video-studio/references/change-impact.md +5 -5
- package/skills/video-studio/references/{v08-quality.md → delivery-gates.md} +16 -18
- package/skills/video-studio/references/frame-qa.md +48 -16
- package/skills/video-studio/references/pipeline.md +4 -4
- package/skills/video-studio/references/pitfalls.md +22 -3
- package/skills/video-studio/references/proposal.md +160 -0
- package/skills/video-studio/references/quality-gates.md +11 -11
- package/skills/video-studio/references/scene-authoring.md +2 -2
- package/skills/video-studio/references/scene-grammar.md +40 -42
- package/skills/video-studio/references/sfx-design.md +8 -6
- package/skills/video-studio/references/web-engine.md +8 -8
- package/skills/video-studio/references/word-timeline.md +9 -9
- package/skills/video-studio/requirements.txt +9 -13
- package/skills/video-studio/scripts/designkit.py +2115 -0
- package/skills/video-studio/scripts/filmcheck.py +22 -48
- package/skills/video-studio/scripts/filmkit.py +2980 -2904
- package/skills/video-studio/scripts/gates.py +4 -4
- package/skills/video-studio/scripts/grammar.py +73 -13
- package/skills/video-studio/scripts/musicgen.py +2042 -0
- package/skills/video-studio/scripts/preflight.py +14 -7
- package/skills/video-studio/scripts/previewkit.py +848 -0
- package/skills/video-studio/scripts/propose.py +825 -0
- package/skills/video-studio/scripts/qa_frames.py +73 -39
- package/skills/video-studio/scripts/qaasr.py +26 -7
- package/skills/video-studio/scripts/qavmaf.py +89 -23
- package/skills/video-studio/scripts/render.py +545 -231
- package/skills/video-studio/scripts/scorekit.py +55 -1755
- package/skills/video-studio/scripts/selfcheck.py +815 -215
- package/skills/video-studio/scripts/sfxkit.py +27 -26
- package/skills/video-studio/scripts/stagewatch.py +24 -8
- package/skills/video-studio/scripts/stylekit.py +207 -888
- package/skills/video-studio/scripts/voicekit.py +508 -387
- package/skills/video-studio/scripts/webengine.py +71 -72
- package/tools/audio_similarity.py +64 -37
- package/tools/ci_check.py +20 -14
- package/tools/estimate.py +34 -29
- package/tools/golden_frames.py +72 -28
- package/tools/silhouette_qa.py +520 -168
- package/docs/demo-pil-sheet.png +0 -0
- package/docs/styles/demo-results.json +0 -76
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-ai.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/docs/styles/sheet-craft.png +0 -0
- package/docs/styles/sheet-night.png +0 -0
- package/skills/video-studio/assets/score_demo.json +0 -307
- package/skills/video-studio/references/style-packs.md +0 -283
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"id": "fixture-golden",
|
|
4
|
+
"label": "金帧基准设计",
|
|
5
|
+
"pitch": "验收基准:固定设计,用于像素回归(不是给人看的方案)",
|
|
6
|
+
"structure": {
|
|
7
|
+
"segments": 4,
|
|
8
|
+
"beats": [
|
|
9
|
+
"hook",
|
|
10
|
+
"body",
|
|
11
|
+
"body",
|
|
12
|
+
"outro"
|
|
13
|
+
],
|
|
14
|
+
"hold_policy": "readable",
|
|
15
|
+
"target_sec": 30.0
|
|
16
|
+
},
|
|
17
|
+
"visual": {
|
|
18
|
+
"palette": {
|
|
19
|
+
"bg": [
|
|
20
|
+
7,
|
|
21
|
+
14,
|
|
22
|
+
26
|
|
23
|
+
],
|
|
24
|
+
"bg2": [
|
|
25
|
+
17,
|
|
26
|
+
33,
|
|
27
|
+
54
|
|
28
|
+
],
|
|
29
|
+
"surface": [
|
|
30
|
+
26,
|
|
31
|
+
42,
|
|
32
|
+
64
|
|
33
|
+
],
|
|
34
|
+
"ink": [
|
|
35
|
+
230,
|
|
36
|
+
216,
|
|
37
|
+
207
|
|
38
|
+
],
|
|
39
|
+
"muted": [
|
|
40
|
+
178,
|
|
41
|
+
157,
|
|
42
|
+
143
|
|
43
|
+
],
|
|
44
|
+
"dim": [
|
|
45
|
+
153,
|
|
46
|
+
135,
|
|
47
|
+
122
|
|
48
|
+
],
|
|
49
|
+
"accent": [
|
|
50
|
+
230,
|
|
51
|
+
106,
|
|
52
|
+
23
|
|
53
|
+
],
|
|
54
|
+
"accent2": [
|
|
55
|
+
92,
|
|
56
|
+
230,
|
|
57
|
+
216
|
|
58
|
+
],
|
|
59
|
+
"good": [
|
|
60
|
+
71,
|
|
61
|
+
178,
|
|
62
|
+
98
|
|
63
|
+
],
|
|
64
|
+
"bad": [
|
|
65
|
+
178,
|
|
66
|
+
97,
|
|
67
|
+
89
|
|
68
|
+
],
|
|
69
|
+
"rule": [
|
|
70
|
+
255,
|
|
71
|
+
255,
|
|
72
|
+
255
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"family": "sans",
|
|
76
|
+
"typography": {
|
|
77
|
+
"tracking": 0.0,
|
|
78
|
+
"line_gap": 1.3,
|
|
79
|
+
"number": "dim",
|
|
80
|
+
"ratio": 1.5,
|
|
81
|
+
"align": "left"
|
|
82
|
+
},
|
|
83
|
+
"motif": {
|
|
84
|
+
"kind": "brackets",
|
|
85
|
+
"density": 1.0,
|
|
86
|
+
"detail": {}
|
|
87
|
+
},
|
|
88
|
+
"transition": {
|
|
89
|
+
"kind": "dip",
|
|
90
|
+
"dur": 0.28
|
|
91
|
+
},
|
|
92
|
+
"motion": {
|
|
93
|
+
"ease": "out3",
|
|
94
|
+
"enter": [
|
|
95
|
+
0.45,
|
|
96
|
+
0.85
|
|
97
|
+
],
|
|
98
|
+
"push": 0.04,
|
|
99
|
+
"breathe_hz": 0.85
|
|
100
|
+
},
|
|
101
|
+
"bias": {
|
|
102
|
+
"x": 0,
|
|
103
|
+
"y": 0,
|
|
104
|
+
"inset": 0
|
|
105
|
+
},
|
|
106
|
+
"layout": "left-stack",
|
|
107
|
+
"hud": "chip-left",
|
|
108
|
+
"size": "1920x1080",
|
|
109
|
+
"fps": 30
|
|
110
|
+
},
|
|
111
|
+
"audio": {
|
|
112
|
+
"voice": {
|
|
113
|
+
"mode": "edge_id",
|
|
114
|
+
"id": "zh-CN-YunyangNeural",
|
|
115
|
+
"map": {},
|
|
116
|
+
"jitter": 0.0,
|
|
117
|
+
"max_distinct": 1
|
|
118
|
+
},
|
|
119
|
+
"music": {
|
|
120
|
+
"mode": "params",
|
|
121
|
+
"style": "tech-deep",
|
|
122
|
+
"scale": "minor",
|
|
123
|
+
"bpm": 96.0,
|
|
124
|
+
"key": "Dm",
|
|
125
|
+
"mood": "steady",
|
|
126
|
+
"seed": 7
|
|
127
|
+
},
|
|
128
|
+
"sfx": {
|
|
129
|
+
"enabled": false,
|
|
130
|
+
"density": "none",
|
|
131
|
+
"gain_db": -6
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"pacing": {
|
|
135
|
+
"lead": 0.62,
|
|
136
|
+
"tail": 0.92,
|
|
137
|
+
"min_dur": 3.0,
|
|
138
|
+
"hold": 0.0,
|
|
139
|
+
"item_stagger": 0.3
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "
|
|
2
|
+
"_comment": "自测/演示样例剧本(仓库测试夹具,不是给用户抄的模板)。外观由 work/design.json 决定(propose.py --brief 现场生成后 --confirm 冻结),这里没有 style 字段。文本仅用于跑通全链与金帧/门禁回归,换题材请自己写 lines[]。",
|
|
3
3
|
"title": "测试片 demo",
|
|
4
4
|
"end_text": "完",
|
|
5
|
-
"style": "auto",
|
|
6
5
|
"voice": "zh-CN-YunxiNeural",
|
|
7
6
|
"rate": "+6%",
|
|
8
7
|
"pitch": "+0Hz",
|
|
@@ -18,7 +17,6 @@
|
|
|
18
17
|
"music": {
|
|
19
18
|
"enabled": true,
|
|
20
19
|
"mode": "score",
|
|
21
|
-
"style": "auto",
|
|
22
20
|
"mood": "auto",
|
|
23
21
|
"bpm": "auto",
|
|
24
22
|
"key": "auto",
|
|
@@ -55,7 +53,7 @@
|
|
|
55
53
|
"enabled": true,
|
|
56
54
|
"gain_db": 0,
|
|
57
55
|
"content_seed": true,
|
|
58
|
-
"_comment": "
|
|
56
|
+
"_comment": "夹具只留 1 条转场 whoosh 作示例(见 references/audio-variation.md §3.4);具体提示点交给 assets/scenes.py 的 cues() 钩子按画面内容生成(hook=impact+riser;body=ui_blip 按条目文本定音高 + answer_chime + 段尾 whoosh;quote=reverse_swell+impact;outro=answer_chime+sub_drop)。content_seed=true(默认)= 每条 cue 的 seed 由 style/content_fp 派生(不同片子同一位置也不同),显式 false 时按 seed_base+i 只跟 cue 顺序。",
|
|
59
57
|
"cues": [
|
|
60
58
|
{
|
|
61
59
|
"at": "l02-0.15",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"generator": "golden_frames.py
|
|
4
|
-
"comment": "金帧基线:字体指纹+环境指纹一致时,每一帧像素 sha256 必须相同;换机/字体/Pillow 版本不同判 skipped(不误报),同环境像素变了才是 fail
|
|
3
|
+
"generator": "golden_frames.py v1.0.4",
|
|
4
|
+
"comment": "金帧基线:字体指纹+环境指纹一致时,每一帧像素 sha256 必须相同;换机/字体/Pillow 版本不同判 skipped(不误报),同环境像素变了才是 fail。改动夹具(film.json / scenes.py / 设计)后请 --write 重录并提交本文件。",
|
|
5
5
|
"canvas": [
|
|
6
6
|
1920,
|
|
7
7
|
1080
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"fps": 30,
|
|
10
10
|
"script": "skills/video-studio/assets/film.json",
|
|
11
11
|
"scenes": "skills/video-studio/assets/scenes.py",
|
|
12
|
+
"design": "skills/video-studio/assets/design_fixture.json",
|
|
12
13
|
"font_fp": "05c853d0eb28bcca3125d81c1ce27c9436f66a55",
|
|
13
14
|
"fonts": [
|
|
14
15
|
{
|
|
@@ -33,11 +34,13 @@
|
|
|
33
34
|
"platform": "Windows 11"
|
|
34
35
|
},
|
|
35
36
|
"inputs": {
|
|
36
|
-
"script_sha1": "
|
|
37
|
-
"scenes_sha1": "
|
|
38
|
-
"filmkit_sha1": "
|
|
39
|
-
"stylekit_sha1": "
|
|
40
|
-
"
|
|
37
|
+
"script_sha1": "6f70eabff4a90ba4c488aada0af81e806c3cce92",
|
|
38
|
+
"scenes_sha1": "13902dfa5a23edec00eb5306dbc5297e6403ac1c",
|
|
39
|
+
"filmkit_sha1": "752eeda4b47a739925b60d4d6b98fee99261d905",
|
|
40
|
+
"stylekit_sha1": "bff8f89bf09ff64e12a96d7a1dbb241319553db4",
|
|
41
|
+
"design_sha1": "f87cc13e4c55040d9de5675933c01aa8220134d1",
|
|
42
|
+
"designkit_sha1": "785d699349bfef56b9acfbb94dd4f2b92ab3444e",
|
|
43
|
+
"golden_tool_sha1": "1807499ab7d15d466aeba3aef361c95fe4e5c387"
|
|
41
44
|
},
|
|
42
45
|
"frames": [
|
|
43
46
|
{
|
|
@@ -45,21 +48,21 @@
|
|
|
45
48
|
"scene": "l01",
|
|
46
49
|
"t_local": 1.2,
|
|
47
50
|
"label": "hook",
|
|
48
|
-
"sha256": "
|
|
51
|
+
"sha256": "af738e3252e22abd3034c794b515ba0fde0d25eb41c6fa6ae8e1efdbff055a21"
|
|
49
52
|
},
|
|
50
53
|
{
|
|
51
54
|
"frame": 270,
|
|
52
55
|
"scene": "l03",
|
|
53
56
|
"t_local": 1.6,
|
|
54
57
|
"label": "body",
|
|
55
|
-
"sha256": "
|
|
58
|
+
"sha256": "b86f97ab6a9120446700bc2c790944ebe7db6fbc24ed20871f79426a94a0980e"
|
|
56
59
|
},
|
|
57
60
|
{
|
|
58
61
|
"frame": 369,
|
|
59
62
|
"scene": "l04",
|
|
60
63
|
"t_local": 1.5,
|
|
61
64
|
"label": "outro",
|
|
62
|
-
"sha256": "
|
|
65
|
+
"sha256": "1850ff2313378c1fe0e7579aeb433c1d1fce072478f5924b913244095d2991f0"
|
|
63
66
|
}
|
|
64
67
|
]
|
|
65
68
|
}
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""scenes.py —
|
|
2
|
+
"""scenes.py — 技能内置的场景渲染器(默认渲染路径 + 自定义场景的范例)。
|
|
3
|
+
|
|
4
|
+
⚠ 这**不是**给用户拷走改的模板:render.py 不写 --scenes 时会自动回落到本文件,
|
|
5
|
+
用户工程目录里只需要一个 film.json。想改画面时,在剧本同目录放一个自己的 scenes.py
|
|
6
|
+
(或 --scenes 显式指定),它会被优先发现;本文件就是那份自写场景的**参考实现**:
|
|
7
|
+
10 类构图 / 8 种母题 / 6 种转场 / 35 字段主题 / 版式轮换 / 字幕参数全部从计划里读。
|
|
3
8
|
|
|
4
9
|
唯一约定:提供 draw_frame(scene, t_local, dur, t_global, script, frame) -> PIL.Image
|
|
5
|
-
scene : timeline.json 里的一段(含 id/text/sub/start/end/dur
|
|
10
|
+
scene : timeline.json 里的一段(含 id/text/sub/start/end/dur 与 grammar 注入的
|
|
11
|
+
scene_type / scene_variant / scene_decor / scene_layout / scene_reason)
|
|
6
12
|
t_local : 本段内已经过秒数
|
|
7
13
|
dur : 本段总秒数
|
|
8
14
|
t_global: 全片秒数
|
|
15
|
+
script : 剧本 dict(含 _style 计划、_total 总时长)
|
|
9
16
|
frame : 全片帧号(确定性:同一帧号必须画出同一张图)
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
走下面原封不动的 v0.4 绘制函数,逐字节可复现(基线三帧 sha256 由 selfcheck 比对)。
|
|
18
|
+
画风从哪来
|
|
19
|
+
----------
|
|
20
|
+
render.py 把 designkit.design_to_plan() 的计划塞进 script["_style"](主题 / 视觉语言 /
|
|
21
|
+
版式轮换 / 配乐 / 声线 / 节奏 / 字幕样式)。本文件从这份计划里取配色、材质、字体族、
|
|
22
|
+
版式、缓动、HUD、镜头缓推——**没有任何内置风格表**:
|
|
23
|
+
同一份代码画出来是什么样,完全由现场采样并确认过的 design.json 决定。
|
|
18
24
|
|
|
19
25
|
铁律(决定画质与可复现性):
|
|
20
26
|
1. 不许用 random / time.time();要随机用 K.hash2 / K.hash3 / vnoise(seed)
|
|
@@ -56,10 +62,10 @@ import filmkit as K
|
|
|
56
62
|
from filmkit import W, H, clamp, smooth, ease_out, rng
|
|
57
63
|
|
|
58
64
|
# ============================================================================
|
|
59
|
-
# 零、
|
|
65
|
+
# 零、legacy 绘制器(逐字节基线,别动这里的任何一行)
|
|
60
66
|
# ============================================================================
|
|
61
67
|
#
|
|
62
|
-
# legacy = 老项目(film.json 没有 style
|
|
68
|
+
# legacy = 老项目(film.json 没有 style 字段)。这组函数是逐字节基线,
|
|
63
69
|
# 一个字符都不许改:Lead 的基线三帧 sha256 直接对拍这里的输出。
|
|
64
70
|
# 新主题化代码只在 _legacy_* 之外生长,互不影响。
|
|
65
71
|
|
|
@@ -237,7 +243,7 @@ _LEGACY_DRAWERS = {
|
|
|
237
243
|
|
|
238
244
|
|
|
239
245
|
# ============================================================================
|
|
240
|
-
# 一、风格计划 /
|
|
246
|
+
# 一、风格计划 / 主题
|
|
241
247
|
# ============================================================================
|
|
242
248
|
|
|
243
249
|
_PLAN_CACHE = {}
|
|
@@ -271,11 +277,31 @@ def _plan(script):
|
|
|
271
277
|
return got
|
|
272
278
|
|
|
273
279
|
|
|
280
|
+
# 主题里这些键的值是 RGB,统一要 (r,g,b) 元组。JSON 往返会把它变成 list,
|
|
281
|
+
# 而 list 不能当缓存键 —— _soft_dot / _bg 之类的 key 里都带着颜色,一旦错位就是
|
|
282
|
+
# "unhashable type: 'list'"。实测触发路径:--stage frames --jobs N 把 script 写成
|
|
283
|
+
# _script_snapshot.json,子进程读回来重建,于是 theme.bg 是 [8,8,10]。
|
|
284
|
+
# 修在**入口**(_scene_theme)而不是每个缓存点:缓存点会越加越多,入口只有一个。
|
|
285
|
+
_THEME_RGB_KEYS = ("bg", "bg2", "surface", "ink", "muted", "dim",
|
|
286
|
+
"accent", "accent2", "good", "bad", "rule", "vignette_tint")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _freeze_theme(th):
|
|
290
|
+
"""把主题里的颜色值统一成元组(就地不重复建字面量的前提下返回新 dict)。"""
|
|
291
|
+
out = dict(th)
|
|
292
|
+
for k in _THEME_RGB_KEYS:
|
|
293
|
+
v = out.get(k)
|
|
294
|
+
if isinstance(v, (list, tuple)) and len(v) >= 3:
|
|
295
|
+
out[k] = (int(v[0]), int(v[1]), int(v[2]))
|
|
296
|
+
# scale / card / qa 是嵌套 dict,外面只读不建缓存键,原样保留
|
|
297
|
+
return out
|
|
298
|
+
|
|
299
|
+
|
|
274
300
|
def _scene_theme(plan, scene):
|
|
275
301
|
"""本段用哪套主题:全片主题 + 可选的 scene["theme"] 覆盖(只许同明暗族)。"""
|
|
276
302
|
base = plan.get("theme")
|
|
277
303
|
if isinstance(base, dict):
|
|
278
|
-
th =
|
|
304
|
+
th = _freeze_theme(base)
|
|
279
305
|
else:
|
|
280
306
|
th = dict(ST.THEMES[plan["theme_id"]])
|
|
281
307
|
sid = scene.get("theme")
|
|
@@ -293,7 +319,7 @@ def _scene_theme(plan, scene):
|
|
|
293
319
|
"场景 %r 想从主题 %s 换到 %s,两者明暗族不同(同片换族会触发闪烁门禁),已忽略"
|
|
294
320
|
% (scene.get("id"), th.get("id"), sid))
|
|
295
321
|
return th
|
|
296
|
-
return
|
|
322
|
+
return _freeze_theme(alt)
|
|
297
323
|
|
|
298
324
|
|
|
299
325
|
# ---------------- 包内小工具 ----------------
|
|
@@ -332,14 +358,14 @@ def _ease(name):
|
|
|
332
358
|
_EASES = {}
|
|
333
359
|
|
|
334
360
|
|
|
335
|
-
# ---------------- 字体:family
|
|
361
|
+
# ---------------- 字体:family 参数(旧 filmkit 没有就自动回退) ----------------
|
|
336
362
|
|
|
337
363
|
_FONT_CACHE = {}
|
|
338
364
|
_FAMILY_OK = None
|
|
339
365
|
|
|
340
366
|
|
|
341
367
|
def _family_ok():
|
|
342
|
-
"""filmkit.font 是否支持 family
|
|
368
|
+
"""filmkit.font 是否支持 family=...(老版本没有就回退旧签名)。"""
|
|
343
369
|
global _FAMILY_OK
|
|
344
370
|
if _FAMILY_OK is None:
|
|
345
371
|
try:
|
|
@@ -427,7 +453,7 @@ class _View(object):
|
|
|
427
453
|
|
|
428
454
|
为什么不是整帧重采样:实测 1080p 双线性整帧变换 29ms/帧(预算 15ms),
|
|
429
455
|
而整帧按 push 放大又会把 150px 边距的内容推出 SAFE。所以分两层:
|
|
430
|
-
|
|
456
|
+
背景层做**真缩放**(端点预烘 + blend,约 4.4ms,见 _plate_zoom/_bg_plate);
|
|
431
457
|
前景层做同向**视差**(0.35x,位置与卡片尺寸随镜头收放,字形不重采样保清晰)。
|
|
432
458
|
"""
|
|
433
459
|
__slots__ = ("s", "cx", "cy")
|
|
@@ -573,15 +599,63 @@ def _bg(th):
|
|
|
573
599
|
return im
|
|
574
600
|
|
|
575
601
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
602
|
+
_ZOOM_ORDER = []
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _zoom_tag(th, extra=None):
|
|
606
|
+
"""取景端点的缓存标签:主题决定底图,extra 用来区分装饰母题档位。"""
|
|
607
|
+
return _hashable(("zoom", th.get("id"), th["bg"], th["bg2"], th["texture"],
|
|
608
|
+
th["grain"], th["grain_freq"], th["vignette"],
|
|
609
|
+
th["vignette_tint"], th["texture_alpha"], extra))
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _plate_zoom(src, s, th, tag):
|
|
613
|
+
"""按镜头缓推取景:端点预烘 + 逐帧 blend。
|
|
614
|
+
|
|
615
|
+
为什么不再用 NEAREST 直采:底图把颗粒(`_grain_layer`)一起烘了进去,颗粒是
|
|
616
|
+
逐像素的高频图案;NEAREST 在缓慢缩放时采样网格会整像素跳变,颗粒图案随之整体
|
|
617
|
+
错位 1px,观感就是"满屏噪点在沸腾"——用户实测反馈是"明显的抖动感",而背景-only
|
|
618
|
+
的帧差只有 4~6 灰阶,单帧闪烁门禁根本抓不到。
|
|
619
|
+
|
|
620
|
+
做法:s=1.0 与 s=1+push 两个端点各烘一张 BILINEAR 底版(每张约 60ms,只做一次),
|
|
621
|
+
逐帧用 `Image.blend` 在两端点间线性插值(实测 4.4ms/帧)。端点内部是**平滑重采样**,
|
|
622
|
+
没有 NEAREST 的网格跳变,帧差降到 0~1 灰阶;同时保留颗粒质感(不是把 grain 抹掉)。
|
|
623
|
+
|
|
624
|
+
上限取 1+push:`_push_scale` 恒 <= 1+push,超出的 s 一律钳到端点上,不会外推。
|
|
625
|
+
"""
|
|
579
626
|
if s <= 1.0008:
|
|
580
|
-
return
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
627
|
+
return src.copy()
|
|
628
|
+
cap = 1.0 + max(0.0, float(th.get("push") or _DEFAULT_PUSH))
|
|
629
|
+
if cap <= 1.0008:
|
|
630
|
+
return src.copy()
|
|
631
|
+
s = min(float(s), cap)
|
|
632
|
+
key = _hashable(("zpts", tag, round(cap, 6)))
|
|
633
|
+
pair = _CACHE.get(key)
|
|
634
|
+
if pair is None:
|
|
635
|
+
lo = src.copy()
|
|
636
|
+
bw, bh = W / cap, H / cap
|
|
637
|
+
x0 = (W - bw) * 0.5
|
|
638
|
+
y0 = (H - bh) * 0.5
|
|
639
|
+
hi = src.transform((W, H), Image.EXTENT, (x0, y0, x0 + bw, y0 + bh), Image.BILINEAR)
|
|
640
|
+
pair = (lo, hi)
|
|
641
|
+
_CACHE[key] = pair
|
|
642
|
+
_ZOOM_ORDER.append(key)
|
|
643
|
+
while len(_ZOOM_ORDER) > 4: # 一对端点约 12MB,限量防多片同进程膨胀
|
|
644
|
+
old = _ZOOM_ORDER.pop(0)
|
|
645
|
+
if old != key:
|
|
646
|
+
_CACHE.pop(old, None)
|
|
647
|
+
lo, hi = pair
|
|
648
|
+
w = (s - 1.0) / (cap - 1.0)
|
|
649
|
+
if w <= 0.0:
|
|
650
|
+
return lo.copy()
|
|
651
|
+
if w >= 1.0:
|
|
652
|
+
return hi.copy()
|
|
653
|
+
return Image.blend(lo, hi, w)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _bg_plate(th, s):
|
|
657
|
+
"""逐帧背景:按镜头缩放取景(端点预烘 + blend,约 4.4ms/帧;见 _plate_zoom)。"""
|
|
658
|
+
return _plate_zoom(_bg(th), s, th, _zoom_tag(th, "bg"))
|
|
585
659
|
|
|
586
660
|
|
|
587
661
|
# ---------------- 版式几何(全部从 ST.LAYOUTS 取) ----------------
|
|
@@ -604,7 +678,7 @@ def _panel_x(lay):
|
|
|
604
678
|
def _panel_boxes(lay, th, n, y_floor, v):
|
|
605
679
|
"""条目卡片布局:选列数 + 压缩卡片高度,保证①不压标题②不越字幕带(856)。
|
|
606
680
|
|
|
607
|
-
panel_gap 在 stylekit 里是**行距(pitch
|
|
681
|
+
panel_gap 在 stylekit 里是**行距(pitch)**不是间隙:按 240 + i*118 排卡片时,
|
|
608
682
|
所以 pitch 至少得比卡片高度多 6px,否则卡片会叠在一起。条目多时先加列,再压高度,
|
|
609
683
|
最后才截断——静默重叠或画到字幕带下面比少画两条更糟。
|
|
610
684
|
"""
|
|
@@ -748,14 +822,14 @@ def _hud(img, th, chapter, progress, title):
|
|
|
748
822
|
|
|
749
823
|
|
|
750
824
|
# ============================================================================
|
|
751
|
-
# 三、场景语法(
|
|
825
|
+
# 三、场景语法(10 类构图 × 8 套视觉语言)
|
|
752
826
|
# ============================================================================
|
|
753
827
|
#
|
|
754
828
|
# 为什么重写这一节
|
|
755
829
|
# ---------------
|
|
756
|
-
#
|
|
830
|
+
# 旧的 _draw_hook / _draw_body / _draw_outro 是"一套骨架 + 换色":背景板 → 眉标 →
|
|
757
831
|
# 大字 → 副标题 → 强调线 → HUD,三种 kind 只差字号与位置。于是 8 套风格换完只剩配色不同。
|
|
758
|
-
#
|
|
832
|
+
# 现在把"画什么"(scene type,由 grammar.py 按内容选)与"怎么画"(视觉语言,由 stylekit
|
|
759
833
|
# 的 VISUAL_LANGUAGES 给)拆开:
|
|
760
834
|
# · 10 类构图各自有独立的视觉重心与留白(hero 偏左上 / statement 正中 / compare 强制对称 …)
|
|
761
835
|
# · 每套风格有自己的装饰母题(括角 / 手绘内框 / letterbox / 信号条 …)与转场签名
|
|
@@ -889,9 +963,9 @@ def _motif_layer(th, lang):
|
|
|
889
963
|
"""把该风格独有的装饰图形烘焙成一张全屏 RGBA 图层(不含动态)。
|
|
890
964
|
|
|
891
965
|
为什么烘焙:这些图形是静态的(括角、页眉线、letterbox、信号条),
|
|
892
|
-
逐帧重画一次就是 3–8ms
|
|
893
|
-
|
|
894
|
-
|
|
966
|
+
逐帧重画一次就是 3–8ms。注意:全屏 RGBA paste 实测是 **5.6ms**
|
|
967
|
+
(Pillow 13 / 1080p),所以装饰直接烘焙进底版(见 _plate),
|
|
968
|
+
逐帧只 copy 底版,不再单独 paste。
|
|
895
969
|
"""
|
|
896
970
|
mf = lang.get("motif") or _DEFAULT_LANG["motif"]
|
|
897
971
|
key = _motif_key(th, lang)
|
|
@@ -1018,10 +1092,10 @@ _PLATE_ORDER = []
|
|
|
1018
1092
|
|
|
1019
1093
|
|
|
1020
1094
|
def _plate(th, lang, scene, s_bg):
|
|
1021
|
-
"""背景板 +
|
|
1095
|
+
"""背景板 + 装饰母题合成后的整帧底版。
|
|
1022
1096
|
|
|
1023
1097
|
这是单帧预算里最大的一处优化:装饰是静态的,逐帧单独 paste 一张全屏 RGBA 要 5.6ms,
|
|
1024
|
-
合成进底版后逐帧只剩 copy(1.3ms
|
|
1098
|
+
合成进底版后逐帧只剩 copy(1.3ms)或端点 blend 取景(约 4.4ms,见 _plate_zoom)。
|
|
1025
1099
|
顺带一个好处:镜头缓推现在会把装饰一起推——装饰本来就该跟着镜头走,
|
|
1026
1100
|
以前"背景推、装饰不动"在强 push 的风格(cinema 0.12 / epic 0.14)里其实是穿帮。
|
|
1027
1101
|
|
|
@@ -1046,12 +1120,7 @@ def _plate(th, lang, scene, s_bg):
|
|
|
1046
1120
|
old = _PLATE_ORDER.pop(0)
|
|
1047
1121
|
if old != key:
|
|
1048
1122
|
_CACHE.pop(old, None)
|
|
1049
|
-
|
|
1050
|
-
return got.copy()
|
|
1051
|
-
bw, bh = W / s_bg, H / s_bg
|
|
1052
|
-
x0 = (W - bw) * 0.5
|
|
1053
|
-
y0 = (H - bh) * 0.5
|
|
1054
|
-
return got.transform((W, H), Image.EXTENT, (x0, y0, x0 + bw, y0 + bh), Image.NEAREST)
|
|
1123
|
+
return _plate_zoom(got, s_bg, th, _zoom_tag(th, key))
|
|
1055
1124
|
|
|
1056
1125
|
|
|
1057
1126
|
def _apply_motif(img, th, lang, scene):
|
|
@@ -1069,7 +1138,7 @@ def _apply_veil(img, th, lang, scene, t_local, s_bg):
|
|
|
1069
1138
|
"""段首从背景"长出来"的过场。不做跨场景 cross-dissolve(draw_frame 拿不到上一段帧),
|
|
1070
1139
|
只做"从本段背景板渐显 / 擦入",观感是 dip 而不是 dissolve。
|
|
1071
1140
|
|
|
1072
|
-
|
|
1141
|
+
为什么必须做:相邻两段可能是完全不同的构图(数字卡 → 左右对比),
|
|
1073
1142
|
整屏瞬切会精确触发 qa_frames 的单帧闪烁门禁(8x8 分块灰度差 ≥6.0),
|
|
1074
1143
|
观众看着也像"换了一部片"。
|
|
1075
1144
|
"""
|
|
@@ -1194,13 +1263,19 @@ def _draw_hero(scene, t, dur, tg, script, frame, plan, th):
|
|
|
1194
1263
|
lh = int(size * gap)
|
|
1195
1264
|
ksize = int(th["scale"]["kicker"][0])
|
|
1196
1265
|
kicker = scene.get("kicker") or "00 · 开场"
|
|
1266
|
+
y = float(lay["title_y"]) + bdy
|
|
1197
1267
|
|
|
1268
|
+
# kicker 的纵向位置必须**跟着标题字号走**,不能直接用布局表里的固定 y。
|
|
1269
|
+
# 为什么:布局表给的是 left-stack 的 title_y=300 / kicker_y=250,只差 50px;
|
|
1270
|
+
# 而 hero 的标题字号是 96px(display 档),字形往上顶穿 kicker —— 实测封面上
|
|
1271
|
+
# "00 · 开场"直接压在"价值与代价来自同一个选择"中间。
|
|
1272
|
+
# 按字号算间距后,换字体/换字号档(visual 语言包会改 ratio)都不会再压。
|
|
1273
|
+
_ky = min(float(lay["kicker_y"]), y - size * 1.05 - ksize * 0.35)
|
|
1198
1274
|
p0 = ease(rng(t, 0.05, 0.85))
|
|
1199
1275
|
_draw_text(img, th, v, kicker, ax + _entrance_x(th, p0, align),
|
|
1200
|
-
|
|
1276
|
+
_ky + _entrance_y(th, p0), ksize, "bold",
|
|
1201
1277
|
th["accent"], align, p0, trk * 0.5)
|
|
1202
1278
|
|
|
1203
|
-
y = float(lay["title_y"]) + bdy
|
|
1204
1279
|
for i, ln in enumerate(lines):
|
|
1205
1280
|
p = ease(rng(t, 0.12 + 0.13 * i, 0.88 + 0.13 * i))
|
|
1206
1281
|
_draw_text(img, th, v, ln, ax + _entrance_x(th, p, align),
|
|
@@ -1209,7 +1284,7 @@ def _draw_hero(scene, t, dur, tg, script, frame, plan, th):
|
|
|
1209
1284
|
|
|
1210
1285
|
sub = scene.get("lead") or scene.get("sub") or ""
|
|
1211
1286
|
# 副标题与大字重复时不要画第二遍:模板片里 big 与 sub 都是"零素材,全代码生成",
|
|
1212
|
-
#
|
|
1287
|
+
# 照抄会让画面出现两行一样的字(实测踩到过)。
|
|
1213
1288
|
if sub and (str(sub).strip() == str(big).strip()
|
|
1214
1289
|
or (len(str(sub)) >= 6 and str(sub).strip() in str(big).strip())):
|
|
1215
1290
|
sub = ""
|
|
@@ -1386,7 +1461,7 @@ def _draw_list(scene, t, dur, tg, script, frame, plan, th):
|
|
|
1386
1461
|
body = max(22, int(th["scale"]["body"][0]) - 1)
|
|
1387
1462
|
# 行高:优先"每条 96–118px",条目多时往下压,硬下限 56px(再低中文就糊成一片)。
|
|
1388
1463
|
# 为什么不是固定行高:模板片 5 条要点在 9.3s 的段里,固定 118px 会溢出 856 硬边界,
|
|
1389
|
-
# 于是第 5
|
|
1464
|
+
# 于是第 5 条被静默截断(实测踩到过),观众看到"五步"只列出四步。
|
|
1390
1465
|
avail = 856.0 - top
|
|
1391
1466
|
row_h = avail / max(1, len(items)) if items else 118.0
|
|
1392
1467
|
row_h = min(118.0, max(56.0, row_h - 6.0))
|
|
@@ -1513,10 +1588,23 @@ def _draw_stat(scene, t, dur, tg, script, frame, plan, th):
|
|
|
1513
1588
|
d.rectangle([v.x(bx + i * (bw + gapb)), v.y(760), v.x(bx + i * (bw + gapb) + bw),
|
|
1514
1589
|
v.y(768)], fill=_rgba(th["accent"] if on else th["rule"],
|
|
1515
1590
|
int((150 if on else 34))))
|
|
1591
|
+
# 次要统计:**数值 + 单位 + 标签**都要画出来。
|
|
1592
|
+
# 这里必须把数值也画出来:只画 s["label"] 时 stats[] 里的第 2、3 项(例如
|
|
1593
|
+
# "338207 次 / 工具调用"、"22.62 B / Token 重放")在画面上只剩两个光秃秃的词,
|
|
1594
|
+
# 并且行距 30px < 字号,两行直接叠在一起 —— 观众看到的是"工具调用/Token 重放"两行字
|
|
1595
|
+
# 糊在一块、数字完全消失。数字卡的价值就在数字,丢数字等于这段白做。
|
|
1596
|
+
nsize = int(th["scale"]["body"][0] - 6)
|
|
1597
|
+
step = int(nsize * 1.75) # 行距必须大于字号,否则相邻两行会压在一起
|
|
1516
1598
|
for j, s in enumerate(notes):
|
|
1517
1599
|
pn = ease(rng(t, 0.6 + 0.15 * j, 1.3 + 0.15 * j))
|
|
1518
|
-
|
|
1519
|
-
|
|
1600
|
+
val = str(s.get("value") if s.get("value") is not None else "").strip()
|
|
1601
|
+
unit = str(s.get("unit") or "").strip()
|
|
1602
|
+
lbl = str(s.get("label") or "").strip()
|
|
1603
|
+
head = val + ((" " + unit) if unit else "")
|
|
1604
|
+
txt = (head + " " + lbl) if head else lbl
|
|
1605
|
+
if not txt:
|
|
1606
|
+
continue
|
|
1607
|
+
_draw_text(img, th, v, txt, W * 0.5, 796.0 + j * step, nsize,
|
|
1520
1608
|
"reg", th["muted"], "center", pn)
|
|
1521
1609
|
|
|
1522
1610
|
img = _apply_veil(img, th, lang, scene, t, s_bg)
|
|
@@ -1570,7 +1658,7 @@ def _draw_compare(scene, t, dur, tg, script, frame, plan, th):
|
|
|
1570
1658
|
|
|
1571
1659
|
# 两栏内容垂直居中(上界 360,下界 800):只有一两条时不会全挤在顶部留一大片空白。
|
|
1572
1660
|
# 栏头:优先 sides 里作者写的 head;没有就**不要**写"左/右"这种零信息标签
|
|
1573
|
-
#
|
|
1661
|
+
# (实测:观众看到"左 / 右"会以为画面坏了),改画一个 VS 徽标在中缝上方。
|
|
1574
1662
|
cols = [("left", 150.0, 700.0, -1), ("right", W - 850.0, 700.0, 1)]
|
|
1575
1663
|
max_rows = max(len((sides.get("left") or {}).get("items") or []),
|
|
1576
1664
|
len((sides.get("right") or {}).get("items") or []), 1)
|
|
@@ -1717,7 +1805,7 @@ def _draw_quote(scene, t, dur, tg, script, frame, plan, th):
|
|
|
1717
1805
|
by = str(by or "")
|
|
1718
1806
|
|
|
1719
1807
|
# 巨型引号:**用字形**,不用矢量线。
|
|
1720
|
-
#
|
|
1808
|
+
# 用两条短线拼「《」实测看起来像播放器的"快退"图标,
|
|
1721
1809
|
# 观众会以为点错了什么。雅黑粗体的 U+201C 是有字形的(preflight 查过不缺口),
|
|
1722
1810
|
# 直接放大 150px 就是最标准的引语符号。
|
|
1723
1811
|
pq = ease(rng(t, 0.0, 0.45))
|
|
@@ -1924,7 +2012,7 @@ def _draw_outro(scene, t, dur, tg, script, frame, plan, th):
|
|
|
1924
2012
|
gap = _gap(th, lang)
|
|
1925
2013
|
ph = _phase(scene)
|
|
1926
2014
|
|
|
1927
|
-
# 光点环:数量随视觉语言母题走(epic 18 / cinema 12 / mono 0
|
|
2015
|
+
# 光点环:数量随视觉语言母题走(epic 18 / cinema 12 / mono 0)
|
|
1928
2016
|
mf = str((lang.get("motif") or {}).get("kind") or "minimal")
|
|
1929
2017
|
ring_n = {"rays": 18, "letterbox": 12, "brackets": 10, "signal": 8,
|
|
1930
2018
|
"handdrawn": 6, "slabs": 12, "hairlines": 6, "minimal": 0}.get(mf, 8)
|
|
@@ -2003,18 +2091,18 @@ _KIND_TO_TYPE = {
|
|
|
2003
2091
|
def draw_frame(scene, t_local, dur, t_global, script, frame):
|
|
2004
2092
|
"""按帧画一张 1920x1080 RGB 图。
|
|
2005
2093
|
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2094
|
+
只有一条路:按 scene_type(grammar.py 选好、render.py 写进 timeline)
|
|
2095
|
+
派发到 10 类构图之一;没有 scene_type 的旧 timeline 按 kind 映射到兼容类型。
|
|
2096
|
+
|
|
2097
|
+
原来的第 1 条分支(legacy —— film.json 不写 style 就走三个老绘制器)**已删除**:
|
|
2098
|
+
stylekit 不再产出 legacy 计划(不写设计会直接报错),那条分支永远走不到。
|
|
2099
|
+
留着死分支比删掉更危险——它会让"legacy 还能用"的错觉一直存在。
|
|
2100
|
+
|
|
2010
2101
|
未命中的类型回落到 list("有条目就画条目"是最不会丢信息的兜底),
|
|
2011
2102
|
并 warn 一次——静默画错构图比报错更糟。
|
|
2012
2103
|
"""
|
|
2013
2104
|
plan = _plan(script)
|
|
2014
2105
|
kind = scene.get("kind") or "body"
|
|
2015
|
-
if plan.get("legacy"):
|
|
2016
|
-
fn = _LEGACY_DRAWERS.get(kind) or _legacy_scene_body
|
|
2017
|
-
return fn(scene, t_local, dur, t_global, script, frame)
|
|
2018
2106
|
th = _scene_theme(plan, scene)
|
|
2019
2107
|
stype = str(scene.get("scene_type") or "").strip()
|
|
2020
2108
|
if not stype:
|
|
@@ -2028,7 +2116,7 @@ def draw_frame(scene, t_local, dur, t_global, script, frame):
|
|
|
2028
2116
|
return fn(scene, t_local, dur, t_global, script, frame, plan, th)
|
|
2029
2117
|
|
|
2030
2118
|
|
|
2031
|
-
# ----------------
|
|
2119
|
+
# ---------------- 提示点(按 kind 与内容生成) ----------------
|
|
2032
2120
|
def _sha1_int(*parts):
|
|
2033
2121
|
"""把若干文本片段拼起来取 sha1 前 8 位(提示点"内容派生"的稳定来源)。"""
|
|
2034
2122
|
raw = "|".join(str(p) for p in parts).encode("utf-8")
|
|
@@ -2038,8 +2126,8 @@ def _sha1_int(*parts):
|
|
|
2038
2126
|
def _item_pitch(text, base=1760.0):
|
|
2039
2127
|
"""条目文本 -> ui_blip 音高(sha1 派生,不用序号)。
|
|
2040
2128
|
|
|
2041
|
-
|
|
2042
|
-
|
|
2129
|
+
契约 §3.4:body 条目的 pitch 必须由**条目文本**派生,不再按序号
|
|
2130
|
+
(1760+110*i 每片都一样)——同一句文案换到别的片子里仍是同一个音高,
|
|
2043
2131
|
不同文案落到 -5~+7 半音的不同音级;万一两条碰撞,_dedupe_segment 会再升半音。
|
|
2044
2132
|
"""
|
|
2045
2133
|
off = _sha1_int("pitch", text) % 13 - 5
|
|
@@ -2049,7 +2137,7 @@ def _item_pitch(text, base=1760.0):
|
|
|
2049
2137
|
def _cue_gain_decay(gain, repeat):
|
|
2050
2138
|
"""同段内同类型 cue 第 repeat 次重复(0 起)时的增益:每次再低 2 dB(-2/-4/-6/…)。
|
|
2051
2139
|
|
|
2052
|
-
|
|
2140
|
+
契约 §3.4:不许出现"每段都一模一样的三连击"——同类型重复必须**严格递减**
|
|
2053
2141
|
(第 2 次 -2、第 3 次 -4、第 4 次 -6……),且 pitch 必须不同。
|
|
2054
2142
|
"""
|
|
2055
2143
|
return round(float(gain) - 2.0 * int(repeat), 2)
|
|
@@ -2085,11 +2173,11 @@ def cues(tl, script):
|
|
|
2085
2173
|
好处是"声音跟着画面的像素走":这里算的是卡片落位、数字滚完的时刻,
|
|
2086
2174
|
画面一改,声音自动跟着改,不用回来手工对时间。
|
|
2087
2175
|
|
|
2088
|
-
|
|
2089
|
-
|
|
2176
|
+
条目落位步长与场景绘制共用 plan["pacing"]["item_stagger"],
|
|
2177
|
+
两边读同一个数,否则音效会错位(写死 0.32 时踩过这个坑)。
|
|
2090
2178
|
|
|
2091
|
-
|
|
2092
|
-
|
|
2179
|
+
见 references/audio-variation.md §3.3:提示点按**场景 kind 与内容**生成,
|
|
2180
|
+
不是"每段 ui_blip 三连 + 结尾 answer_chime"的同质化固定套路(跨片音效雷同的
|
|
2093
2181
|
来源之一):
|
|
2094
2182
|
hook : 段首 impact(size 按字数 0.9~1.3)+ 落定 riser
|
|
2095
2183
|
body : 条目落位 ui_blip(pitch 由条目文本 sha1 派生)+ items>=3 补 answer_chime
|