@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1109 -0
- package/README.md +76 -10
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/docs/styles/sheet-8-styles.png +0 -0
- package/docs/styles/sheet-before-after.png +0 -0
- package/index.js +4 -1
- package/package.json +7 -3
- package/schemas/film.schema.json +329 -2
- package/skills/video-studio/SKILL.md +270 -13
- package/skills/video-studio/assets/golden_frames.json +65 -0
- package/skills/video-studio/assets/scenes.py +2162 -1190
- package/skills/video-studio/assets/web/engine.js +63 -2
- package/skills/video-studio/assets/web/film.js +224 -101
- package/skills/video-studio/assets/web/index.html +4 -2
- package/skills/video-studio/assets/web/qa_text.js +36 -8
- package/skills/video-studio/references/audio-variation.md +4 -3
- package/skills/video-studio/references/audio.md +2 -2
- package/skills/video-studio/references/change-impact.md +1 -1
- package/skills/video-studio/references/quality-gates.md +25 -3
- package/skills/video-studio/references/scene-grammar.md +306 -0
- package/skills/video-studio/references/style-packs.md +108 -3
- package/skills/video-studio/references/v08-quality.md +256 -0
- package/skills/video-studio/references/web-engine.md +19 -2
- package/skills/video-studio/references/word-timeline.md +147 -0
- package/skills/video-studio/requirements.txt +14 -0
- package/skills/video-studio/scripts/filmcheck.py +218 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- package/skills/video-studio/scripts/grammar.py +559 -0
- package/skills/video-studio/scripts/preflight.py +774 -0
- package/skills/video-studio/scripts/qa_frames.py +49 -8
- package/skills/video-studio/scripts/qaasr.py +1563 -0
- package/skills/video-studio/scripts/qavmaf.py +1442 -0
- package/skills/video-studio/scripts/render.py +764 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +483 -0
- package/skills/video-studio/scripts/sfxkit.py +130 -14
- package/skills/video-studio/scripts/stagewatch.py +19 -6
- package/skills/video-studio/scripts/stylekit.py +242 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +173 -31
- package/tools/estimate.py +130 -19
- package/tools/golden_frames.py +664 -0
- package/tools/silhouette_qa.py +910 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""golden_frames.py — 金帧像素回归(v0.8.0)。
|
|
3
|
+
|
|
4
|
+
解决什么问题
|
|
5
|
+
------------
|
|
6
|
+
v0.5 起 scenes.py 的注释里就写着「基线三帧 sha256 由 selfcheck 比对」,但一直没人真的
|
|
7
|
+
实现:改一行绘制代码、换一套主题、动字幕排版,肉眼看不出来、qa_frames 的统计指标也
|
|
8
|
+
未必报——只有"同一帧的像素哈希"能当场抓住。本工具就是那条基线。
|
|
9
|
+
|
|
10
|
+
做什么
|
|
11
|
+
------
|
|
12
|
+
渲染指定帧号 -> 每帧算 SHA-256 -> 与 skills/video-studio/assets/golden_frames.json 比对。
|
|
13
|
+
* --write 生成/更新基线(模板有意改动后重录,并把 JSON 一起提交)
|
|
14
|
+
* --check 校验(默认):同环境像素变了 = fail,并列出哪几帧变了
|
|
15
|
+
|
|
16
|
+
三档语义("没查"绝不能写成"通过")
|
|
17
|
+
--------------------------------
|
|
18
|
+
pass 字体指纹 + 版本指纹一致,且每一帧 sha256 与基线相同
|
|
19
|
+
fail 同环境(字体/版本一致)但至少一帧像素变了;或渲染本身炸了
|
|
20
|
+
skipped 没法比:基线不存在 / 换了机器(字体指纹不同)/ Pillow·numpy·Python 版本不同 /
|
|
21
|
+
基线帧号超出当前时长。**skipped != pass**,需要人来 --write 重录或核对。
|
|
22
|
+
|
|
23
|
+
为什么字体不同要 skipped 而不是 fail
|
|
24
|
+
------------------------------------
|
|
25
|
+
像素哈希对字体二进制是敏感的:msyh.ttc 打了补丁、换台 Win10/Win11 机器字体版本不同,
|
|
26
|
+
逐像素就会变,但这不是"渲染器回归"。所以基线里存字体指纹(实际参与渲染的字体文件的
|
|
27
|
+
sha1),指纹不同直接 skipped(跨机不误报);指纹相同还变,才是真回归 -> fail。
|
|
28
|
+
版本(Pillow/numpy/Python)同理:跨版本抗锯齿可能变,判 skipped 而不是冤枉渲染代码。
|
|
29
|
+
|
|
30
|
+
基线怎么来的(确定性)
|
|
31
|
+
----------------------
|
|
32
|
+
不联网、不依赖 TTS 产物:用固定的假语音时长(FAKE_VOICE_DUR=2.0s)排一条合成时间轴,
|
|
33
|
+
取模板片的 3 个时刻(hook / body / outro)作为默认帧号。这样同一份 film.json + scenes.py
|
|
34
|
+
在任何机器上都能立刻重算基线;真实旁白时长只影响"哪一帧是哪一句",不影响"这一帧画成
|
|
35
|
+
什么样",后者才是本工具要守的东西。帧号与场景 id、段内时刻都记进基线,便于人工复核。
|
|
36
|
+
|
|
37
|
+
渲染路径与 render.py 对齐:scenes.draw_frame(...) + filmkit.draw_subtitle(...),
|
|
38
|
+
**不含**片头/片尾的淡入淡出(默认帧号刻意避开 <0.5s 与 >total-0.8s 的区间),
|
|
39
|
+
所以它回归的是"每个场景画出来的像素",不是 ffmpeg 编码后的画面。
|
|
40
|
+
|
|
41
|
+
CLI
|
|
42
|
+
---
|
|
43
|
+
python tools/golden_frames.py [--check | --write] [--json] [--strict]
|
|
44
|
+
[--script PATH] [--scenes PATH] [--golden PATH]
|
|
45
|
+
[--frames 56,168,380]
|
|
46
|
+
退出码:0 = pass 或 skipped;1 = fail;skipped 加 --strict 时为 2。
|
|
47
|
+
--json 输出 {status, frames:[{frame, sha256, expect}], font_fp, ...},可直接给
|
|
48
|
+
selfcheck.py / CI 消费;给人看的文字一律走 stdout(--json 时只有 JSON)。
|
|
49
|
+
|
|
50
|
+
依赖:stdlib + numpy + Pillow + 同目录的 filmkit / stylekit / scenes(项目已有依赖,无新增)。
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
__version__ = "0.8.0"
|
|
55
|
+
|
|
56
|
+
import argparse
|
|
57
|
+
import hashlib
|
|
58
|
+
import importlib.util
|
|
59
|
+
import json
|
|
60
|
+
import os
|
|
61
|
+
import platform
|
|
62
|
+
import sys
|
|
63
|
+
import traceback
|
|
64
|
+
|
|
65
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
66
|
+
ROOT = os.path.dirname(HERE)
|
|
67
|
+
SKILL = os.path.join(ROOT, "skills", "video-studio")
|
|
68
|
+
SCRIPTS = os.path.join(SKILL, "scripts")
|
|
69
|
+
ASSETS = os.path.join(SKILL, "assets")
|
|
70
|
+
|
|
71
|
+
DEFAULT_SCRIPT = os.path.join(ASSETS, "film.json")
|
|
72
|
+
DEFAULT_SCENES = os.path.join(ASSETS, "scenes.py")
|
|
73
|
+
DEFAULT_GOLDEN = os.path.join(ASSETS, "golden_frames.json")
|
|
74
|
+
|
|
75
|
+
GOLDEN_VERSION = 1
|
|
76
|
+
# 合成时间轴用的假语音时长(秒):不联网、不读 TTS 产物也能复现
|
|
77
|
+
FAKE_VOICE_DUR = 2.0
|
|
78
|
+
# 默认三个时刻的段内偏移(秒):hook 标题已入场 / body 条目已落位 / outro 标题已入场
|
|
79
|
+
HOOK_LOCAL = 1.2
|
|
80
|
+
BODY_LOCAL = 1.6
|
|
81
|
+
OUTRO_LOCAL = 1.5
|
|
82
|
+
# 参与字体指纹的字重(模板的绘制函数只用到 bold / reg / light)
|
|
83
|
+
FONT_WEIGHTS = ("bold", "reg", "light")
|
|
84
|
+
|
|
85
|
+
RESULT_KEYS = ("status", "frames", "font_fp")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------- 基础工具
|
|
89
|
+
def _setup_stdio():
|
|
90
|
+
"""输出被重定向时切 UTF-8(Windows 控制台默认 GBK,中文会乱码)。与 render.py 同口径。"""
|
|
91
|
+
for stream in (sys.stdout, sys.stderr):
|
|
92
|
+
try:
|
|
93
|
+
if stream is not None and not stream.isatty():
|
|
94
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
95
|
+
except Exception: # noqa: BLE001
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _sha1_file(path):
|
|
100
|
+
"""文件内容 sha1(读不了返回 "missing",不抛异常)。"""
|
|
101
|
+
try:
|
|
102
|
+
h = hashlib.sha1()
|
|
103
|
+
with open(str(path), "rb") as fh:
|
|
104
|
+
for blk in iter(lambda: fh.read(1 << 20), b""):
|
|
105
|
+
h.update(blk)
|
|
106
|
+
return h.hexdigest()
|
|
107
|
+
except OSError:
|
|
108
|
+
return "missing"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _sha256_image(img):
|
|
112
|
+
"""一帧像素的 sha256:先写画布尺寸,再写 RGB 字节(尺寸变了哈希也一定变)。"""
|
|
113
|
+
if img.mode != "RGB":
|
|
114
|
+
img = img.convert("RGB")
|
|
115
|
+
h = hashlib.sha256()
|
|
116
|
+
h.update(("RGB:%dx%d:" % (img.size[0], img.size[1])).encode("ascii"))
|
|
117
|
+
h.update(img.tobytes())
|
|
118
|
+
return h.hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _rel(path):
|
|
122
|
+
"""尽量把路径写成相对仓库根的 posix 形式(基线文件要跨机器可读)。"""
|
|
123
|
+
try:
|
|
124
|
+
rel = os.path.relpath(os.path.abspath(str(path)), ROOT)
|
|
125
|
+
except ValueError: # 跨盘符
|
|
126
|
+
return os.path.basename(str(path))
|
|
127
|
+
return rel.replace(os.sep, "/")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _json_bytes(data):
|
|
131
|
+
"""确定性 JSON 字节:固定缩进、不排序键(键顺序由构造代码固定)、不带时间戳。"""
|
|
132
|
+
return (json.dumps(data, ensure_ascii=False, indent=1) + "\n").encode("utf-8")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------- 依赖装载
|
|
136
|
+
def _modules():
|
|
137
|
+
"""惰性装载 filmkit / stylekit(tools/ 与 scripts/ 不同目录,必须先补 sys.path)。"""
|
|
138
|
+
if SCRIPTS not in sys.path:
|
|
139
|
+
sys.path.insert(0, SCRIPTS)
|
|
140
|
+
try:
|
|
141
|
+
import filmkit as K
|
|
142
|
+
import stylekit as ST
|
|
143
|
+
return K, ST
|
|
144
|
+
except Exception as e: # noqa: BLE001
|
|
145
|
+
raise RuntimeError("装载 filmkit/stylekit 失败(%s):%s" % (SCRIPTS, e)) from e
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _load_scenes(path):
|
|
149
|
+
"""按路径装载 scenes.py(独立模块名,避免与站点包里的同名模块串档)。"""
|
|
150
|
+
path = os.path.abspath(str(path))
|
|
151
|
+
if not os.path.isfile(path):
|
|
152
|
+
raise RuntimeError("scenes.py 不存在:%s" % path)
|
|
153
|
+
spec = importlib.util.spec_from_file_location("golden_scenes", path)
|
|
154
|
+
if spec is None or spec.loader is None:
|
|
155
|
+
raise RuntimeError("scenes.py 无法作为模块加载:%s" % path)
|
|
156
|
+
mod = importlib.util.module_from_spec(spec)
|
|
157
|
+
sys.modules["golden_scenes"] = mod
|
|
158
|
+
spec.loader.exec_module(mod)
|
|
159
|
+
if not callable(getattr(mod, "draw_frame", None)):
|
|
160
|
+
raise RuntimeError("scenes.py 缺少 draw_frame(scene, t_local, dur, t_global, "
|
|
161
|
+
"script, frame) 契约:%s" % path)
|
|
162
|
+
return mod
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _load_script(path):
|
|
166
|
+
"""读剧本并补上渲染期需要的私有字段(_dir / _path / _total 由调用方补)。"""
|
|
167
|
+
path = os.path.abspath(str(path))
|
|
168
|
+
if not os.path.isfile(path):
|
|
169
|
+
raise RuntimeError("剧本不存在:%s" % path)
|
|
170
|
+
try:
|
|
171
|
+
with open(path, encoding="utf-8") as fh:
|
|
172
|
+
script = json.load(fh)
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
raise RuntimeError("剧本不是合法 JSON:%s(%s)" % (path, e)) from e
|
|
175
|
+
if not isinstance(script, dict) or not isinstance(script.get("lines"), list):
|
|
176
|
+
raise RuntimeError("剧本结构不对(需要 dict 且 lines 是数组):%s" % path)
|
|
177
|
+
script["_path"] = path
|
|
178
|
+
script["_dir"] = os.path.dirname(path)
|
|
179
|
+
return script
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------- 时间轴 / 风格
|
|
183
|
+
def build_timeline(K, script):
|
|
184
|
+
"""由固定假语音时长排一条确定性时间轴(不联网、不读 TTS 产物)。
|
|
185
|
+
|
|
186
|
+
与 render.st_layout 的差别只在 dur 的来源:这里每句固定 FAKE_VOICE_DUR 秒,
|
|
187
|
+
其余(lead/tail/min_dur/hold/bar)照常走 filmkit.build_timeline,结果可复现。
|
|
188
|
+
|
|
189
|
+
但 **fps 必须跟 render.py 一样取自 filmkit.video_plan**(v0.8 起 fps 可配置):
|
|
190
|
+
build_timeline 的默认 fps=30 会让 24/25/50/60 fps 的片子按 30 fps 编号,
|
|
191
|
+
于是基线里的 frame 号既不是成片的真实帧号,t=frame/fps 也不是真实时刻——
|
|
192
|
+
那份基线守的是"另一部片子"的画面,回放/比对都错位(且不会报错)。
|
|
193
|
+
"""
|
|
194
|
+
lines = []
|
|
195
|
+
for i, ln in enumerate(script.get("lines") or []):
|
|
196
|
+
if not isinstance(ln, dict):
|
|
197
|
+
continue
|
|
198
|
+
row = dict(ln)
|
|
199
|
+
row.setdefault("id", "l%02d" % (i + 1))
|
|
200
|
+
row["dur"] = float(FAKE_VOICE_DUR)
|
|
201
|
+
lines.append(row)
|
|
202
|
+
if not lines:
|
|
203
|
+
raise RuntimeError("剧本 lines 为空,无法排时间轴")
|
|
204
|
+
tl = K.build_timeline(
|
|
205
|
+
lines,
|
|
206
|
+
lead=float(script.get("lead", 0.55)),
|
|
207
|
+
tail=float(script.get("tail", 0.85)),
|
|
208
|
+
min_dur=float(script.get("min_dur", 3.0)),
|
|
209
|
+
tail_map=script.get("tail_map"),
|
|
210
|
+
lead_map=script.get("lead_map"),
|
|
211
|
+
hold=float(script.get("hold", 0.0)),
|
|
212
|
+
max_dur=script.get("max_dur"),
|
|
213
|
+
fps=K.video_plan(script)["fps"],
|
|
214
|
+
)
|
|
215
|
+
tl["title"] = script.get("title", "")
|
|
216
|
+
return tl
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def resolve_plan(ST, script):
|
|
220
|
+
"""解析风格计划并挂到 script["_style"](与 render.resolve_style 同口径,含 theme_overrides)。
|
|
221
|
+
|
|
222
|
+
主题体检失败在这里就抛错(不要等渲到第 3 帧才炸)。
|
|
223
|
+
"""
|
|
224
|
+
plan = ST.resolve_script_style(script)
|
|
225
|
+
bad = ST.theme_lint(plan.get("theme") or ST.THEMES[plan["theme_id"]])
|
|
226
|
+
if bad:
|
|
227
|
+
raise RuntimeError("主题体检失败(%s):%s" % (plan["theme_id"], ";".join(bad)))
|
|
228
|
+
script["_style"] = plan
|
|
229
|
+
return plan
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------- 字体指纹
|
|
233
|
+
def font_fingerprint(K, plan):
|
|
234
|
+
"""按"实际会被用到的字重 + 主题字体族"解析字体,返回 (font_fp, fonts 列表)。
|
|
235
|
+
|
|
236
|
+
font_fp = sha1("字体名 sha1 \\n 字体名 sha1 ..."):只依赖字体内容与文件名,
|
|
237
|
+
不依赖绝对路径与 mtime——跨机器只要字体文件相同就得到同一个指纹。
|
|
238
|
+
"""
|
|
239
|
+
fam = "sans"
|
|
240
|
+
if not plan.get("legacy"):
|
|
241
|
+
th = plan.get("theme") or {}
|
|
242
|
+
fam = str(th.get("family") or "sans")
|
|
243
|
+
fonts, seen = [], set()
|
|
244
|
+
for wgt in FONT_WEIGHTS:
|
|
245
|
+
try:
|
|
246
|
+
f = K.font(32, wgt, fam)
|
|
247
|
+
except Exception as e: # noqa: BLE001
|
|
248
|
+
raise RuntimeError("解析字体失败(weight=%s family=%s):%s" % (wgt, fam, e)) from e
|
|
249
|
+
path = str(getattr(f, "path", "") or "")
|
|
250
|
+
name = os.path.basename(path) or "?"
|
|
251
|
+
if name in seen:
|
|
252
|
+
continue
|
|
253
|
+
seen.add(name)
|
|
254
|
+
fonts.append({"name": name, "sha1": _sha1_file(path)})
|
|
255
|
+
fonts.sort(key=lambda x: x["name"])
|
|
256
|
+
payload = "\n".join("%s %s" % (x["name"], x["sha1"]) for x in fonts)
|
|
257
|
+
return hashlib.sha1(payload.encode("utf-8")).hexdigest(), fonts
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def env_fingerprint():
|
|
261
|
+
"""运行环境指纹:Pillow / numpy / Python 版本(跨版本像素不保证一致 -> skipped)。
|
|
262
|
+
|
|
263
|
+
只把会参与绘制计算的版本算进指纹;平台信息单独记录,仅作诊断(Win10/Win11 同一套
|
|
264
|
+
Pillow + 同字体通常逐像素一致,不必因操作系统不同而 skipped)。
|
|
265
|
+
|
|
266
|
+
口径细节:**Python 只按 major.minor 参与指纹**,补丁号只记录不参与比较。
|
|
267
|
+
理由是真正决定抗锯齿像素的是 Pillow(FreeType)与 numpy,不是 CPython 的补丁版本;
|
|
268
|
+
而按补丁号比较会让"作者机器 3.12.16 / 用户机器 3.12.14"这种正常差异一律 skipped——
|
|
269
|
+
实测踩过:selfcheck 第 51 条因此显示"0 帧像素一致"却记 PASS,等于这条门禁从没真的比过。
|
|
270
|
+
真要跨 Pillow/numpy 版本比,仍然会 skipped,那才是像素不可比的情形。
|
|
271
|
+
"""
|
|
272
|
+
import numpy
|
|
273
|
+
from PIL import __version__ as pillow_ver
|
|
274
|
+
py_full = "%d.%d.%d" % sys.version_info[:3]
|
|
275
|
+
osname = platform.system()
|
|
276
|
+
env = dict(python=py_full, pillow=pillow_ver, numpy=numpy.__version__, os=osname,
|
|
277
|
+
platform="%s %s" % (osname, platform.release()))
|
|
278
|
+
return _env_fp_from(env), env
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _env_fp_from(env):
|
|
282
|
+
"""按"参与比较的口径"给 env 字典算指纹(Python 只取 major.minor)。
|
|
283
|
+
|
|
284
|
+
单独抽出来是为了**兼容老基线**:v0.8.0 的 golden_frames.json 里记的是完整补丁号
|
|
285
|
+
(如 3.12.16),如果直接拿新口径算出的指纹去比,老基线会永远 skipped——那等于把
|
|
286
|
+
"补丁号差异"永久固化成"这条门禁不用跑"。这里对**存的 env** 和**当前 env** 走同一个
|
|
287
|
+
归一化函数,所以 3.12.14 与 3.12.16 视为同一环境,而 Pillow/numpy 的差异照旧拦住。
|
|
288
|
+
"""
|
|
289
|
+
env = dict(env or {})
|
|
290
|
+
py = str(env.get("python") or "")
|
|
291
|
+
py_mm = ".".join(py.split(".")[:2]) if py else ""
|
|
292
|
+
payload = "python=%s;pillow=%s;numpy=%s;os=%s" % (
|
|
293
|
+
py_mm, env.get("pillow") or "", env.get("numpy") or "", env.get("os") or "")
|
|
294
|
+
return hashlib.sha1(payload.encode("utf-8")).hexdigest()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------- 渲染
|
|
298
|
+
def _scene_at(tl, t):
|
|
299
|
+
"""全片时刻 -> (scene, 段内时刻),与 render._render_range 的查找方式一致。"""
|
|
300
|
+
scenes = tl["scenes"]
|
|
301
|
+
idx = 0
|
|
302
|
+
while idx + 1 < len(scenes) and t >= scenes[idx + 1]["start"]:
|
|
303
|
+
idx += 1
|
|
304
|
+
s = scenes[idx]
|
|
305
|
+
return s, float(t) - float(s["start"])
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def render_frame(K, scenes_mod, script, plan, tl, frame):
|
|
309
|
+
"""渲染一帧:draw_frame + draw_subtitle(不含片头片尾 fade,理由见模块文档)。"""
|
|
310
|
+
frame = int(frame)
|
|
311
|
+
if frame < 0 or frame >= int(tl["frames"]):
|
|
312
|
+
raise RuntimeError("帧号 %d 越界(当前时间轴共 %d 帧)" % (frame, tl["frames"]))
|
|
313
|
+
fps = float(tl["fps"])
|
|
314
|
+
t = frame / fps
|
|
315
|
+
scene, lt = _scene_at(tl, t)
|
|
316
|
+
img = scenes_mod.draw_frame(scene, lt, float(scene["dur"]), t, script, frame)
|
|
317
|
+
if img is None:
|
|
318
|
+
raise RuntimeError("场景 %s 在 t=%.3fs 返回 None(draw_frame 必须返回 PIL.Image)"
|
|
319
|
+
% (scene.get("id"), t))
|
|
320
|
+
if tuple(img.size) != (int(K.W), int(K.H)):
|
|
321
|
+
raise RuntimeError("场景 %s 在 t=%.3fs 返回 %dx%d,期望 %dx%d"
|
|
322
|
+
% (scene.get("id"), t, img.size[0], img.size[1], K.W, K.H))
|
|
323
|
+
theme = None if plan.get("legacy") else plan.get("theme")
|
|
324
|
+
img = K.draw_subtitle(img, K.sub_at(tl["scenes"], t, script.get("sub_max_hold")),
|
|
325
|
+
theme=theme)
|
|
326
|
+
return img, scene
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def render_frames(script, scenes_mod, plan, tl, frames):
|
|
330
|
+
"""渲染一批帧,返回 [(frame, sha256, scene_id)]。
|
|
331
|
+
|
|
332
|
+
清空 filmkit 的字体缓存再渲染:保证 font_fingerprint() 看到的是"这些帧真的用了哪些
|
|
333
|
+
字体",而不是上一次调用留在全局缓存里的残留(同一进程里多次 check 时尤其重要)。
|
|
334
|
+
"""
|
|
335
|
+
K, _ST = _modules()
|
|
336
|
+
for cache in (getattr(K, "_font_cache", None),
|
|
337
|
+
getattr(scenes_mod, "_FONT_CACHE", None)):
|
|
338
|
+
try:
|
|
339
|
+
cache.clear()
|
|
340
|
+
except Exception: # noqa: BLE001
|
|
341
|
+
pass
|
|
342
|
+
script["_total"] = tl["total"]
|
|
343
|
+
out = []
|
|
344
|
+
for frame in frames:
|
|
345
|
+
img, scene = render_frame(K, scenes_mod, script, plan, tl, frame)
|
|
346
|
+
out.append((int(frame), _sha256_image(img), str(scene.get("id"))))
|
|
347
|
+
return out
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------------------------------------------------------------- 帧号选择
|
|
351
|
+
def default_frame_specs(tl):
|
|
352
|
+
"""默认三个时刻(hook / body / outro);段数很少时退化为同一段取三个时刻。"""
|
|
353
|
+
scenes = tl["scenes"]
|
|
354
|
+
n = len(scenes)
|
|
355
|
+
fps = float(tl["fps"])
|
|
356
|
+
specs = []
|
|
357
|
+
|
|
358
|
+
def add(idx, local, label):
|
|
359
|
+
s = scenes[idx]
|
|
360
|
+
dur = float(s["dur"])
|
|
361
|
+
lt = max(0.55, min(float(local), max(0.56, dur - 0.8)))
|
|
362
|
+
frame = int(round((float(s["start"]) + lt) * fps))
|
|
363
|
+
frame = max(1, min(int(tl["frames"]) - 1, frame))
|
|
364
|
+
specs.append(dict(frame=frame, scene=str(s.get("id") or "s%02d" % idx),
|
|
365
|
+
t_local=round(lt, 3), label=label))
|
|
366
|
+
|
|
367
|
+
if n == 1:
|
|
368
|
+
add(0, 0.9, "hook")
|
|
369
|
+
add(0, 1.7, "body")
|
|
370
|
+
add(0, 2.5, "outro")
|
|
371
|
+
else:
|
|
372
|
+
add(0, HOOK_LOCAL, "hook")
|
|
373
|
+
add(max(1, n // 2), BODY_LOCAL, "body")
|
|
374
|
+
add(n - 1, OUTRO_LOCAL, "outro")
|
|
375
|
+
# 去重(超短片 / 单场景时不同时刻可能落到同一帧):保留第一次出现的标签顺序
|
|
376
|
+
uniq, seen = [], set()
|
|
377
|
+
for sp in specs:
|
|
378
|
+
if sp["frame"] in seen:
|
|
379
|
+
continue
|
|
380
|
+
seen.add(sp["frame"])
|
|
381
|
+
uniq.append(sp)
|
|
382
|
+
return uniq if uniq else [dict(frame=1, scene=str(scenes[0].get("id") or "s00"),
|
|
383
|
+
t_local=0.6, label="hook")]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ---------------------------------------------------------------- 基线读写
|
|
387
|
+
def _golden_payload(script_path, scenes_path, K, plan, tl, rendered):
|
|
388
|
+
font_fp, fonts = font_fingerprint(K, plan)
|
|
389
|
+
env_fp, env = env_fingerprint()
|
|
390
|
+
return {
|
|
391
|
+
"version": GOLDEN_VERSION,
|
|
392
|
+
"generator": "golden_frames.py v%s" % __version__,
|
|
393
|
+
"comment": "金帧基线:字体指纹+环境指纹一致时,每一帧像素 sha256 必须相同;"
|
|
394
|
+
"换机/字体/Pillow 版本不同判 skipped(不误报),同环境像素变了才是 fail。"
|
|
395
|
+
"改动模板(film.json / scenes.py / stylekit 主题)后请 --write 重录并提交本文件。",
|
|
396
|
+
"canvas": [int(K.W), int(K.H)],
|
|
397
|
+
# 记时间轴自己的 fps(不是写死的 K.FPS):v0.8 起 film.json 可以设 24/25/50/60
|
|
398
|
+
"fps": int(tl["fps"]),
|
|
399
|
+
"script": _rel(script_path),
|
|
400
|
+
"scenes": _rel(scenes_path),
|
|
401
|
+
"font_fp": font_fp,
|
|
402
|
+
"fonts": fonts,
|
|
403
|
+
"env_fp": env_fp,
|
|
404
|
+
"env": env,
|
|
405
|
+
"inputs": {
|
|
406
|
+
"script_sha1": _sha1_file(script_path),
|
|
407
|
+
"scenes_sha1": _sha1_file(scenes_path),
|
|
408
|
+
"filmkit_sha1": _sha1_file(os.path.join(SCRIPTS, "filmkit.py")),
|
|
409
|
+
"stylekit_sha1": _sha1_file(os.path.join(SCRIPTS, "stylekit.py")),
|
|
410
|
+
"golden_tool_sha1": _sha1_file(os.path.abspath(__file__)),
|
|
411
|
+
},
|
|
412
|
+
"frames": [dict(sp, sha256=sha, scene=scene_id, frame=int(f))
|
|
413
|
+
for (f, sha, scene_id), sp in zip(rendered, plan["specs"])],
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def write_golden(script_path, scenes_path, golden_path, frames=None):
|
|
418
|
+
"""--write:渲染金帧并写基线。返回 result(status=pass,written=True)。"""
|
|
419
|
+
K, ST = _modules()
|
|
420
|
+
script = _load_script(script_path)
|
|
421
|
+
scenes_mod = _load_scenes(scenes_path)
|
|
422
|
+
tl = build_timeline(K, script)
|
|
423
|
+
plan = resolve_plan(ST, script)
|
|
424
|
+
if frames:
|
|
425
|
+
specs = [dict(frame=int(f), scene=str(_scene_at(tl, int(f) / float(tl["fps"]))[0]
|
|
426
|
+
.get("id") or ""),
|
|
427
|
+
t_local=round(int(f) / float(tl["fps"]) -
|
|
428
|
+
float(_scene_at(tl, int(f) / float(tl["fps"]))[0]["start"]), 3),
|
|
429
|
+
label="custom%d" % i)
|
|
430
|
+
for i, f in enumerate(frames)]
|
|
431
|
+
else:
|
|
432
|
+
specs = default_frame_specs(tl)
|
|
433
|
+
rendered = render_frames(script, scenes_mod, plan, tl, [sp["frame"] for sp in specs])
|
|
434
|
+
plan = dict(plan, specs=specs)
|
|
435
|
+
data = _golden_payload(script_path, scenes_path, K, plan, tl, rendered)
|
|
436
|
+
golden_path = os.path.abspath(str(golden_path))
|
|
437
|
+
d = os.path.dirname(golden_path)
|
|
438
|
+
if d:
|
|
439
|
+
os.makedirs(d, exist_ok=True)
|
|
440
|
+
payload = _json_bytes(data)
|
|
441
|
+
with open(golden_path, "wb") as fh:
|
|
442
|
+
fh.write(payload)
|
|
443
|
+
return {
|
|
444
|
+
"status": "pass",
|
|
445
|
+
"frames": [dict(frame=f, sha256=sha, expect=None, scene=scene_id)
|
|
446
|
+
for (f, sha, scene_id) in rendered],
|
|
447
|
+
"font_fp": data["font_fp"],
|
|
448
|
+
"font_fp_expect": None,
|
|
449
|
+
"env_fp": data["env_fp"],
|
|
450
|
+
"written": True,
|
|
451
|
+
"golden": golden_path,
|
|
452
|
+
"golden_sha256": hashlib.sha256(payload).hexdigest(),
|
|
453
|
+
"reason": "",
|
|
454
|
+
"notes": [],
|
|
455
|
+
"changed": [],
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _load_golden(golden_path):
|
|
460
|
+
"""读基线;不存在/不合法 -> (None, 原因)。"""
|
|
461
|
+
path = os.path.abspath(str(golden_path))
|
|
462
|
+
if not os.path.isfile(path):
|
|
463
|
+
return None, "基线不存在:%s(先跑 --write 生成)" % path
|
|
464
|
+
try:
|
|
465
|
+
with open(path, encoding="utf-8") as fh:
|
|
466
|
+
data = json.load(fh)
|
|
467
|
+
except (OSError, ValueError) as e:
|
|
468
|
+
return None, "基线读不了(%s):%s" % (path, e)
|
|
469
|
+
if not isinstance(data, dict) or not isinstance(data.get("frames"), list):
|
|
470
|
+
return None, "基线结构不对(缺 frames 数组):%s" % path
|
|
471
|
+
return data, ""
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _fmt_fp_pair(actual, expect):
|
|
475
|
+
return "%s… vs %s…" % (str(actual)[:12], str(expect)[:12])
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def check_golden(script_path, scenes_path, golden_path):
|
|
479
|
+
"""--check:渲染基线里的帧号并与基线比对(默认命令)。"""
|
|
480
|
+
golden, why = _load_golden(golden_path)
|
|
481
|
+
if golden is None:
|
|
482
|
+
return dict(status="skipped", frames=[], font_fp="", reason=why)
|
|
483
|
+
if int(golden.get("version") or 0) != GOLDEN_VERSION:
|
|
484
|
+
return dict(status="skipped", frames=[], font_fp="",
|
|
485
|
+
reason="基线版本 %r != 当前 %d(需 --write 重录)"
|
|
486
|
+
% (golden.get("version"), GOLDEN_VERSION))
|
|
487
|
+
expects = []
|
|
488
|
+
for row in golden["frames"]:
|
|
489
|
+
if not isinstance(row, dict) or not isinstance(row.get("frame"), int):
|
|
490
|
+
return dict(status="skipped", frames=[], font_fp="",
|
|
491
|
+
reason="基线 frames 里有一条没有整数 frame:%r" % (row,))
|
|
492
|
+
expects.append(row)
|
|
493
|
+
if not expects:
|
|
494
|
+
return dict(status="skipped", frames=[], font_fp="",
|
|
495
|
+
reason="基线里没有帧(frames 为空):先 --write 生成")
|
|
496
|
+
try:
|
|
497
|
+
K, ST = _modules()
|
|
498
|
+
script = _load_script(script_path)
|
|
499
|
+
scenes_mod = _load_scenes(scenes_path)
|
|
500
|
+
tl = build_timeline(K, script)
|
|
501
|
+
plan = resolve_plan(ST, script)
|
|
502
|
+
font_fp, fonts = font_fingerprint(K, plan)
|
|
503
|
+
env_fp, env = env_fingerprint()
|
|
504
|
+
# 先做"能不能比"的判定,再渲(省得换机时白渲三帧)
|
|
505
|
+
if str(golden.get("font_fp") or "") != font_fp:
|
|
506
|
+
return dict(
|
|
507
|
+
status="skipped", frames=[], font_fp=font_fp,
|
|
508
|
+
font_fp_expect=golden.get("font_fp"),
|
|
509
|
+
fonts=fonts, fonts_expect=golden.get("fonts"),
|
|
510
|
+
reason="skipped: font differs(字体指纹 %s)——换机/字体版本不同,"
|
|
511
|
+
"像素比对不成立;确认画面后在当前机器 --write 重录"
|
|
512
|
+
% _fmt_fp_pair(font_fp, golden.get("font_fp")))
|
|
513
|
+
# 比指纹时对**存的 env** 走同一个归一化(见 _env_fp_from):这样 v0.8.0 那些
|
|
514
|
+
# 记着完整补丁号的老基线不会因为 3.12.14 vs 3.12.16 永久 skipped。
|
|
515
|
+
golden_env_fp = _env_fp_from(golden.get("env") or {})
|
|
516
|
+
if golden_env_fp != env_fp:
|
|
517
|
+
return dict(
|
|
518
|
+
status="skipped", frames=[], font_fp=font_fp,
|
|
519
|
+
font_fp_expect=golden.get("font_fp"),
|
|
520
|
+
env=env, env_expect=golden.get("env"),
|
|
521
|
+
reason="skipped: env differs(Pillow/numpy/Python 版本不同:%r vs %r)"
|
|
522
|
+
"——跨版本抗锯齿不保证逐像素一致;在目标环境 --write 重录"
|
|
523
|
+
% (env, golden.get("env")))
|
|
524
|
+
frames = [int(r["frame"]) for r in expects]
|
|
525
|
+
over = [f for f in frames if f < 0 or f >= int(tl["frames"])]
|
|
526
|
+
if over:
|
|
527
|
+
# 剧本/参数改短了 -> 基线帧号在当前时间轴里不存在。这是"基准失效",不是画面回归,
|
|
528
|
+
# 按三档语义只能 skipped(拿"没比"当 fail 会天天误报,当 pass 更不行)。
|
|
529
|
+
return dict(status="skipped", frames=[], font_fp=font_fp,
|
|
530
|
+
font_fp_expect=golden.get("font_fp"),
|
|
531
|
+
reason="skipped: 基线帧号超出当前时长(%s >= %d 帧)——"
|
|
532
|
+
"剧本/节奏改过导致时间轴变短,先 --write 重录"
|
|
533
|
+
% (", ".join(str(f) for f in over[:6]), int(tl["frames"])))
|
|
534
|
+
rendered = render_frames(script, scenes_mod, plan, tl, frames)
|
|
535
|
+
except Exception as e: # noqa: BLE001 —— 渲染炸了是 fail
|
|
536
|
+
return dict(status="fail", frames=[], font_fp="",
|
|
537
|
+
reason="渲染金帧失败:%s: %s" % (type(e).__name__, e),
|
|
538
|
+
traceback=traceback.format_exc()[-2000:])
|
|
539
|
+
# 逐帧比对
|
|
540
|
+
rows, changed = [], []
|
|
541
|
+
for (frame, sha, scene_id), exp in zip(rendered, expects):
|
|
542
|
+
expect = str(exp.get("sha256") or "")
|
|
543
|
+
rows.append(dict(frame=frame, sha256=sha, expect=expect or None,
|
|
544
|
+
scene=scene_id, label=exp.get("label")))
|
|
545
|
+
if not expect or sha != expect:
|
|
546
|
+
changed.append(frame)
|
|
547
|
+
notes = []
|
|
548
|
+
inputs = golden.get("inputs") or {}
|
|
549
|
+
# golden_tool_sha1 只记录不比对:工具自身(打印/JSON 字段)改动不影响像素,
|
|
550
|
+
# 拿它算 stale 会让每次改工具都产出误导性的"输入变过"提示。
|
|
551
|
+
cur_inputs = dict(script_sha1=_sha1_file(script_path),
|
|
552
|
+
scenes_sha1=_sha1_file(scenes_path),
|
|
553
|
+
filmkit_sha1=_sha1_file(os.path.join(SCRIPTS, "filmkit.py")),
|
|
554
|
+
stylekit_sha1=_sha1_file(os.path.join(SCRIPTS, "stylekit.py")))
|
|
555
|
+
stale = [k for k, v in cur_inputs.items()
|
|
556
|
+
if inputs.get(k) and inputs.get(k) != v]
|
|
557
|
+
if changed:
|
|
558
|
+
if stale:
|
|
559
|
+
notes.append("生成基线之后这些输入改过:%s(有意改动请 --write 重录并复核)"
|
|
560
|
+
% " / ".join(stale))
|
|
561
|
+
return dict(status="fail", frames=rows, font_fp=font_fp,
|
|
562
|
+
font_fp_expect=golden.get("font_fp"),
|
|
563
|
+
env=env, reason="像素变了:%d/%d 帧(%s)"
|
|
564
|
+
% (len(changed), len(rows), ", ".join(str(f) for f in changed[:12])),
|
|
565
|
+
changed=changed, notes=notes)
|
|
566
|
+
if stale:
|
|
567
|
+
notes.append("输入指纹与基线不同但像素一致(可能是注释/无关字段改动):%s"
|
|
568
|
+
% " / ".join(stale))
|
|
569
|
+
return dict(status="pass", frames=rows, font_fp=font_fp,
|
|
570
|
+
font_fp_expect=golden.get("font_fp"), env=env,
|
|
571
|
+
reason="", changed=[], notes=notes)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ---------------------------------------------------------------- CLI
|
|
575
|
+
def _print_human(res):
|
|
576
|
+
st = str(res.get("status") or "").upper()
|
|
577
|
+
if res.get("written"):
|
|
578
|
+
print("== golden_frames(写入基线)")
|
|
579
|
+
else:
|
|
580
|
+
print("== golden_frames(%s)" % {"PASS": "像素一致", "FAIL": "像素变化",
|
|
581
|
+
"SKIPPED": "跳过(没比)"}.get(st, st))
|
|
582
|
+
for row in res.get("frames") or []:
|
|
583
|
+
exp = row.get("expect")
|
|
584
|
+
got = row.get("sha256") or ""
|
|
585
|
+
if res.get("written"):
|
|
586
|
+
mark = "REC "
|
|
587
|
+
else:
|
|
588
|
+
mark = "OK " if exp and got == exp else ("DIFF" if exp else "????")
|
|
589
|
+
print(" %s frame=%-6s %s %s %s"
|
|
590
|
+
% (mark, row.get("frame"), (exp or "(无基线)")[:12], got[:12],
|
|
591
|
+
row.get("label") or row.get("scene") or ""))
|
|
592
|
+
if res.get("reason"):
|
|
593
|
+
print(" %s" % res["reason"])
|
|
594
|
+
for n in res.get("notes") or []:
|
|
595
|
+
print(" [NOTE] %s" % n)
|
|
596
|
+
if res.get("written"):
|
|
597
|
+
print(" 已写入基线:%s(%d 帧,font_fp=%s…)"
|
|
598
|
+
% (res.get("golden"), len(res.get("frames") or []),
|
|
599
|
+
str(res.get("font_fp"))[:12]))
|
|
600
|
+
if res.get("status") == "skipped":
|
|
601
|
+
print(" 注意:skipped != 通过——本次**没有**完成像素比对")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def main(argv=None):
|
|
605
|
+
_setup_stdio()
|
|
606
|
+
ap = argparse.ArgumentParser(description="金帧像素回归(渲染固定帧号 -> sha256 -> 比对基线)")
|
|
607
|
+
g = ap.add_mutually_exclusive_group()
|
|
608
|
+
g.add_argument("--check", action="store_true", help="校验(默认)")
|
|
609
|
+
g.add_argument("--write", action="store_true", help="生成/更新基线")
|
|
610
|
+
ap.add_argument("--script", default=DEFAULT_SCRIPT, help="film.json(默认模板片)")
|
|
611
|
+
ap.add_argument("--scenes", default=DEFAULT_SCENES, help="scenes.py(默认模板场景)")
|
|
612
|
+
ap.add_argument("--golden", default=DEFAULT_GOLDEN,
|
|
613
|
+
help="基线 JSON(默认 skills/video-studio/assets/golden_frames.json)")
|
|
614
|
+
ap.add_argument("--frames", default="",
|
|
615
|
+
help="--write 时指定帧号(逗号分隔);缺省取模板片 3 个时刻")
|
|
616
|
+
ap.add_argument("--json", action="store_true", help="stdout 只输出 JSON(机读)")
|
|
617
|
+
ap.add_argument("--strict", action="store_true",
|
|
618
|
+
help="skipped 用退出码 2 表示(默认 skipped 退出码 0,但绝不写 pass)")
|
|
619
|
+
a = ap.parse_args(argv)
|
|
620
|
+
try:
|
|
621
|
+
if a.write:
|
|
622
|
+
frames = None
|
|
623
|
+
if a.frames:
|
|
624
|
+
try:
|
|
625
|
+
frames = [int(x) for x in a.frames.replace(" ", "").split(",") if x]
|
|
626
|
+
except ValueError:
|
|
627
|
+
raise SystemExit("--frames 必须是逗号分隔的整数帧号,收到 %r" % a.frames)
|
|
628
|
+
if not frames:
|
|
629
|
+
raise SystemExit("--frames 里没有有效帧号")
|
|
630
|
+
res = write_golden(a.script, a.scenes, a.golden, frames)
|
|
631
|
+
else:
|
|
632
|
+
if a.frames:
|
|
633
|
+
raise SystemExit("--frames 只用于 --write;--check 的帧号由基线决定"
|
|
634
|
+
"(要换帧号请 --write --frames ... 重录)")
|
|
635
|
+
res = check_golden(a.script, a.scenes, a.golden)
|
|
636
|
+
except SystemExit:
|
|
637
|
+
raise
|
|
638
|
+
except Exception as e: # noqa: BLE001
|
|
639
|
+
res = dict(status="fail", frames=[], font_fp="",
|
|
640
|
+
reason="%s: %s" % (type(e).__name__, e))
|
|
641
|
+
if a.json:
|
|
642
|
+
out = {k: res.get(k) for k in RESULT_KEYS}
|
|
643
|
+
for k in ("status", "frames", "font_fp"):
|
|
644
|
+
if k not in out:
|
|
645
|
+
out[k] = None if k != "frames" else []
|
|
646
|
+
for k in ("font_fp_expect", "env", "env_expect", "reason", "notes",
|
|
647
|
+
"changed", "written", "golden", "golden_sha256", "fonts",
|
|
648
|
+
"fonts_expect", "traceback"):
|
|
649
|
+
if k in res:
|
|
650
|
+
out[k] = res[k]
|
|
651
|
+
json.dump(out, sys.stdout, ensure_ascii=False, indent=1)
|
|
652
|
+
sys.stdout.write("\n")
|
|
653
|
+
else:
|
|
654
|
+
_print_human(res)
|
|
655
|
+
status = str(res.get("status") or "fail")
|
|
656
|
+
if status == "pass":
|
|
657
|
+
return 0
|
|
658
|
+
if status == "skipped":
|
|
659
|
+
return 2 if a.strict else 0
|
|
660
|
+
return 1
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
if __name__ == "__main__":
|
|
664
|
+
raise SystemExit(main())
|