@dsh-gis/dsh-video-studio-skill 0.7.2 → 0.8.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 +951 -0
- package/README.md +51 -8
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/index.js +4 -1
- package/package.json +5 -3
- package/schemas/film.schema.json +162 -2
- package/skills/video-studio/SKILL.md +211 -11
- package/skills/video-studio/assets/golden_frames.json +65 -0
- 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/style-packs.md +7 -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 +121 -0
- package/skills/video-studio/scripts/filmkit.py +521 -38
- package/skills/video-studio/scripts/gates.py +88 -4
- 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 +719 -32
- package/skills/video-studio/scripts/scorekit.py +16 -30
- package/skills/video-studio/scripts/selfcheck.py +274 -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 +43 -12
- package/skills/video-studio/scripts/voicekit.py +15 -7
- package/skills/video-studio/scripts/webengine.py +1119 -72
- package/tools/ci_check.py +171 -31
- package/tools/estimate.py +130 -19
- package/tools/golden_frames.py +664 -0
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
|
-
"""webengine.py — 浏览器逐帧渲染引擎(v0.
|
|
2
|
+
"""webengine.py — 浏览器逐帧渲染引擎(v0.8.0)。
|
|
3
3
|
|
|
4
4
|
用无头浏览器(优先 Microsoft Edge,失败回落 Playwright Chromium)打开 Canvas 页面,
|
|
5
5
|
把时间精确跳到「帧号 / FPS」后经 CDP 截 PNG,喂给 ffmpeg 的 image2pipe 编成视频。
|
|
6
6
|
分块并行 + 断点续跑 + 显式缺口检测 + 崩溃重开;同一帧号渲染出的画面完全一致。
|
|
7
7
|
|
|
8
|
+
v0.8.0:**跟随风格包**。公开入口都多了一组可选参数(style / width / height / fps):
|
|
9
|
+
style stylekit 的 style plan(dict,含 "theme")或裸主题 dict;None = 不注入
|
|
10
|
+
width/height 画布尺寸,只接受 16:9 横屏偶数尺寸(竖屏 / 4:3 / 奇数直接 raise)
|
|
11
|
+
fps 写进注入 payload 的帧率;None = 不约束(页面自己的 window.FPS 为准)
|
|
12
|
+
注入方式:add_init_script 在**页面加载前**写 window.__STYLE__(主题色板 / 版式 / 字体栈 /
|
|
13
|
+
画布 w·h·fps)。assets/web/engine.js 与 film.js 优先读它,取不到就退回 v0.7 深蓝橙常量。
|
|
14
|
+
|
|
15
|
+
三条硬约束(改这块必须先读):
|
|
16
|
+
1. **style=None 时一个字节都不注入**:open_page 连 add_init_script 都不调,页面里不存在
|
|
17
|
+
window.__STYLE__,渲染像素与 v0.7 逐字节一致(自检里有「无 style 页面无 __STYLE__」断言,
|
|
18
|
+
外部还有拿 git HEAD 老版本渲同一帧比 SHA-256 的证据)。
|
|
19
|
+
2. 注入内容只来自入参:不读环境变量/时间/随机数,JSON 用 sort_keys 序列化,同输入同输出。
|
|
20
|
+
3. 尺寸与帧率 fail-closed:不符合 16:9 横屏偶数就 raise,绝不静默缩放或改帧率
|
|
21
|
+
(静默改帧率会让分块帧号整体错位,成片音画不同步且门禁看不出来)。
|
|
22
|
+
|
|
8
23
|
算法改编自 ai-history-film(MIT)video/render.py(逐帧 seek / 分块并行 / 崩溃重开 / 断点续跑),
|
|
9
24
|
代码按本项目 v0.3.0 契约重写;拼接复用 filmkit.concat_segments(绝对路径,v0.2 修过的坑)。
|
|
10
25
|
|
|
@@ -18,14 +33,18 @@
|
|
|
18
33
|
|
|
19
34
|
CLI:
|
|
20
35
|
python webengine.py snap <html> --times 3,9.5 ... # 截静帧 + 联系表
|
|
36
|
+
python webengine.py frames <html> --frames 45,270 ... # 指定帧号截 PNG,打印 SHA-256
|
|
21
37
|
python webengine.py cues <html> [--out x.json] # 导出 cue / 视觉事件(§4.2 schema)
|
|
22
38
|
python webengine.py full <html> --out x.mp4 [--workers 2] [--resume]
|
|
23
39
|
python webengine.py textqa <html> [--from 0 --to 12 --step 0.2]
|
|
24
40
|
python webengine.py selftest # 等价于 --selftest
|
|
41
|
+
# 以上命令都可加风格参数(缺省 = 旧行为):
|
|
42
|
+
# --style magazine | --style work/style.json | --style '{"theme":{...}}'
|
|
43
|
+
# --width 1920 --height 1080 [--fps 30]
|
|
25
44
|
"""
|
|
26
45
|
from __future__ import annotations
|
|
27
46
|
|
|
28
|
-
__version__ = "0.
|
|
47
|
+
__version__ = "0.8.0"
|
|
29
48
|
|
|
30
49
|
import argparse
|
|
31
50
|
import asyncio
|
|
@@ -113,6 +132,258 @@ _K = _load_filmkit()
|
|
|
113
132
|
FFMPEG = _K.FFMPEG
|
|
114
133
|
|
|
115
134
|
|
|
135
|
+
# ---------------------------------------------------------------- 风格注入(v0.8.0 契约)
|
|
136
|
+
# style 的两种合法写法:
|
|
137
|
+
# ① stylekit 的 style plan(dict,至少含 "theme" 字典;render.py --stage style 落的 work/style.json 就是它)
|
|
138
|
+
# ② 裸主题字典(stylekit.THEMES[...] / stylekit.theme_of(id),扁平字段,颜色是 (r,g,b) 元组)
|
|
139
|
+
# 归一化后是**纯数据** payload,经 add_init_script 写成 window.__STYLE__。
|
|
140
|
+
# 这里不做任何"猜":缺键 / 颜色非法 / 字体族未知一律 raise,不做静默回落(回落等于换个画风还不告诉人)。
|
|
141
|
+
STYLE_VERSION = 1
|
|
142
|
+
|
|
143
|
+
# family → 浏览器字体栈。键必须与 filmkit.FONT_FAMILIES 的四个族一致,否则网页与 PIL 两条
|
|
144
|
+
# 路线的画风会对不上(同一套 style 在 --engine pil / web 下字体要看起来是一家人)。
|
|
145
|
+
FONT_STACKS = {
|
|
146
|
+
"sans": '"Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif',
|
|
147
|
+
"serif": '"Noto Serif SC", "Source Han Serif SC", SimSun, "Songti SC", serif',
|
|
148
|
+
"kai": 'KaiTi, "Kaiti SC", STKaiti, "Microsoft YaHei", serif',
|
|
149
|
+
"round": 'DengXian, "Microsoft YaHei", sans-serif',
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# 主题里必须齐全的色板键(stylekit 的 8 套主题全都有;少一个就说明传错东西了)
|
|
153
|
+
THEME_COLOR_KEYS = ("bg", "bg2", "surface", "ink", "muted", "dim",
|
|
154
|
+
"accent", "accent2", "good", "bad", "rule")
|
|
155
|
+
|
|
156
|
+
# 画布只收 16:9 横屏:宽高都必须偶数(H.264 yuv420p 要求),宽高比误差 ≤ 1e-3。
|
|
157
|
+
_ASPECT_TOL = 1e-3
|
|
158
|
+
_MIN_CANVAS = (320, 180)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_canvas(width, height):
|
|
162
|
+
"""校验画布尺寸(16:9 横屏 + 偶数),返回 (int, int);不合格 raise ValueError。
|
|
163
|
+
|
|
164
|
+
为什么 fail-closed:竖屏 9:16 / 4:3 在网页模板里没有对应的安全区与版式,静默接受会渲出
|
|
165
|
+
"文字贴边、构图塌掉"的片子;而 render.py 的 --engine pil 路线也不支持非 16:9,
|
|
166
|
+
两边不一致时最容易出现"预览好好的、出片是竖的"。所以上游拒绝之外,这里再拦一道。
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
w = int(width)
|
|
170
|
+
h = int(height)
|
|
171
|
+
except (TypeError, ValueError):
|
|
172
|
+
raise ValueError("画布尺寸必须是整数,收到 %r x %r" % (width, height))
|
|
173
|
+
if w <= 0 or h <= 0:
|
|
174
|
+
raise ValueError("画布尺寸必须是正数,收到 %dx%d" % (w, h))
|
|
175
|
+
if w < _MIN_CANVAS[0] or h < _MIN_CANVAS[1]:
|
|
176
|
+
raise ValueError("画布太小:%dx%d(下限 %dx%d)" % (w, h, _MIN_CANVAS[0], _MIN_CANVAS[1]))
|
|
177
|
+
if w < h:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"只支持 16:9 横屏,收到 %dx%d 是竖屏(9:16)。竖屏请在剧本/上游拒绝,"
|
|
180
|
+
"web 引擎这里不做静默裁切或拉伸。" % (w, h))
|
|
181
|
+
if w % 2 or h % 2:
|
|
182
|
+
raise ValueError(
|
|
183
|
+
"画布宽高必须是偶数(H.264 yuv420p 的子采样要求),收到 %dx%d。"
|
|
184
|
+
"改成 %dx%d 即可。" % (w, h, w + (w % 2), h + (h % 2)))
|
|
185
|
+
ratio = w / float(h)
|
|
186
|
+
if abs(ratio - 16.0 / 9.0) > _ASPECT_TOL:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
"只支持 16:9 横屏;%dx%d 的宽高比是 %.4f:1,不是 %.4f:1。"
|
|
189
|
+
"常见合法尺寸:1920x1080 / 1280x720 / 2560x1440 / 3840x2160。" % (w, h, ratio, 16.0 / 9.0))
|
|
190
|
+
return w, h
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _hex_color(value, where):
|
|
194
|
+
"""把颜色统一成 "#rrggbb"(小程序/网页都认)。支持 (r,g,b) 元组与 "#rrggbb"/"#rgb"。
|
|
195
|
+
|
|
196
|
+
非法值直接 raise:颜色写错在画布上表现为"看不见字",靠肉眼发现的成本远高于这里报错。
|
|
197
|
+
"""
|
|
198
|
+
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
|
199
|
+
try:
|
|
200
|
+
rgb = [int(value[0]), int(value[1]), int(value[2])]
|
|
201
|
+
except (TypeError, ValueError):
|
|
202
|
+
raise ValueError("%s 的颜色不是三个整数:%r" % (where, value))
|
|
203
|
+
if min(rgb) < 0 or max(rgb) > 255:
|
|
204
|
+
raise ValueError("%s 的颜色分量必须在 0–255:%r" % (where, value))
|
|
205
|
+
return "#%02x%02x%02x" % (rgb[0], rgb[1], rgb[2])
|
|
206
|
+
if isinstance(value, str):
|
|
207
|
+
s = value.strip()
|
|
208
|
+
if re.fullmatch(r"#[0-9a-fA-F]{6}", s):
|
|
209
|
+
return s.lower()
|
|
210
|
+
if re.fullmatch(r"#[0-9a-fA-F]{3}", s):
|
|
211
|
+
return "#" + "".join(ch * 2 for ch in s[1:].lower())
|
|
212
|
+
raise ValueError("%s 必须是 (r,g,b) 或 \"#rrggbb\",收到 %r" % (where, value))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _luma(color_hex):
|
|
216
|
+
"""相对亮度(0–1,sRGB 线性化前的近似即可):只用来判断"深色/浅色主题"。"""
|
|
217
|
+
s = str(color_hex).lstrip("#")
|
|
218
|
+
r, g, b = (int(s[i:i + 2], 16) / 255.0 for i in (0, 2, 4))
|
|
219
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _style_theme_dict(style):
|
|
223
|
+
"""从两种合法写法里取出「主题 dict」,并返回 (theme, plan_extra)。
|
|
224
|
+
|
|
225
|
+
plan_extra 只在写法①里非空(style / style_name / legacy / layout_default / chapter_layout)。
|
|
226
|
+
"""
|
|
227
|
+
if not isinstance(style, dict):
|
|
228
|
+
raise TypeError("style 必须是 dict(stylekit 的 style plan 或裸主题 dict)或 None,收到 %r"
|
|
229
|
+
% (type(style).__name__,))
|
|
230
|
+
theme = style.get("theme")
|
|
231
|
+
if isinstance(theme, dict):
|
|
232
|
+
extra = {
|
|
233
|
+
"style": style.get("style"),
|
|
234
|
+
"style_name": style.get("style_name"),
|
|
235
|
+
"legacy": style.get("legacy"),
|
|
236
|
+
"layout_default": style.get("layout_default"),
|
|
237
|
+
"chapter_layout": style.get("chapter_layout"),
|
|
238
|
+
}
|
|
239
|
+
return theme, extra
|
|
240
|
+
# 写法②:裸主题 dict(必须有 id/bg 之类的字段)
|
|
241
|
+
if "bg" in style or "ink" in style:
|
|
242
|
+
return style, {"style": style.get("id"), "style_name": style.get("label"),
|
|
243
|
+
"legacy": None, "layout_default": None, "chapter_layout": None}
|
|
244
|
+
raise ValueError(
|
|
245
|
+
"style 既不是 style plan(没有 \"theme\" 字典)也不是裸主题 dict(没有 bg / ink)。"
|
|
246
|
+
"可用写法:stylekit 的 plan(work/style.json)或 stylekit.THEMES[...]。收到键:%s"
|
|
247
|
+
% (sorted(style)[:12],))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def normalize_style(style, *, width=W, height=H, fps=None):
|
|
251
|
+
"""把 style 归一成注入用 payload;style=None 返回 None(= 完全不注入)。
|
|
252
|
+
|
|
253
|
+
payload 结构(window.__STYLE__,字段名即契约;改动要同步 assets/web/*.js):
|
|
254
|
+
version / style / style_name / theme_id / theme_label / legacy / dark
|
|
255
|
+
canvas {w, h, aspect, fps} fps=None 时写 null(页面保留自己的 window.FPS)
|
|
256
|
+
theme {bg,bg2,surface,ink,muted,dim,accent,accent2,good,bad,rule} # "#rrggbb"
|
|
257
|
+
typography {family, font_stack, scale, tracking, line_gap}
|
|
258
|
+
layout {default, chapter}
|
|
259
|
+
card {radius, fill_a, outline_a, pad, h, gap}
|
|
260
|
+
material {vignette, grain, grain_freq, texture, texture_alpha, vignette_tint}
|
|
261
|
+
motion {ease, entrance_dx, entrance_dy, push, item_stagger, transition}
|
|
262
|
+
hud "chip-left" | "corner-line" | "none"
|
|
263
|
+
safe {bias}
|
|
264
|
+
"""
|
|
265
|
+
if style is None:
|
|
266
|
+
return None
|
|
267
|
+
w, h = _validate_canvas(width, height)
|
|
268
|
+
theme, extra = _style_theme_dict(style)
|
|
269
|
+
|
|
270
|
+
colors = {}
|
|
271
|
+
for key in THEME_COLOR_KEYS:
|
|
272
|
+
if key not in theme:
|
|
273
|
+
raise ValueError("style 的主题缺少颜色 %r(需要齐全:%s)"
|
|
274
|
+
% (key, ", ".join(THEME_COLOR_KEYS)))
|
|
275
|
+
colors[key] = _hex_color(theme[key], "theme.%s" % key)
|
|
276
|
+
|
|
277
|
+
family = str(theme.get("family") or "sans")
|
|
278
|
+
if family not in FONT_STACKS:
|
|
279
|
+
raise ValueError("style 的字体族 %r 不认识;可用:%s(与 filmkit.FONT_FAMILIES 对齐)"
|
|
280
|
+
% (family, " / ".join(sorted(FONT_STACKS))))
|
|
281
|
+
|
|
282
|
+
layout_default = extra.get("layout_default") or theme.get("layout") or "left-stack"
|
|
283
|
+
chapter_layout = extra.get("chapter_layout") if isinstance(extra.get("chapter_layout"), dict) else {}
|
|
284
|
+
|
|
285
|
+
def _num(v, default):
|
|
286
|
+
try:
|
|
287
|
+
return float(v) if v is not None else float(default)
|
|
288
|
+
except (TypeError, ValueError):
|
|
289
|
+
raise ValueError("style 字段 %r 不是数字:%r" % (v, v))
|
|
290
|
+
|
|
291
|
+
scale = {}
|
|
292
|
+
raw_scale = theme.get("scale") if isinstance(theme.get("scale"), dict) else {}
|
|
293
|
+
for k, v in raw_scale.items():
|
|
294
|
+
if isinstance(v, (list, tuple)) and len(v) >= 2:
|
|
295
|
+
scale[str(k)] = [int(v[0]), str(v[1])]
|
|
296
|
+
elif isinstance(v, (int, float)):
|
|
297
|
+
scale[str(k)] = [int(v), "reg"]
|
|
298
|
+
else:
|
|
299
|
+
raise ValueError("style.typography.scale.%s 必须是 (字号, 字重),收到 %r" % (k, v))
|
|
300
|
+
|
|
301
|
+
card = {}
|
|
302
|
+
raw_card = theme.get("card") if isinstance(theme.get("card"), dict) else {}
|
|
303
|
+
for k, v in raw_card.items():
|
|
304
|
+
if isinstance(v, (int, float)):
|
|
305
|
+
card[str(k)] = float(v) if isinstance(v, float) and not float(v).is_integer() else int(v)
|
|
306
|
+
else:
|
|
307
|
+
raise ValueError("style.layout.card.%s 必须是数字,收到 %r" % (k, v))
|
|
308
|
+
|
|
309
|
+
qa = theme.get("qa") if isinstance(theme.get("qa"), dict) else {}
|
|
310
|
+
if "dark" in qa:
|
|
311
|
+
dark = bool(qa["dark"])
|
|
312
|
+
else:
|
|
313
|
+
dark = _luma(colors["bg"]) < 0.5
|
|
314
|
+
|
|
315
|
+
fps_val = None
|
|
316
|
+
if fps is not None:
|
|
317
|
+
try:
|
|
318
|
+
fps_val = float(fps)
|
|
319
|
+
except (TypeError, ValueError):
|
|
320
|
+
raise ValueError("fps 必须是数字,收到 %r" % (fps,))
|
|
321
|
+
if not math.isfinite(fps_val) or fps_val <= 0:
|
|
322
|
+
raise ValueError("fps 必须是正数,收到 %r" % (fps,))
|
|
323
|
+
|
|
324
|
+
hud = str(theme.get("hud") or "none")
|
|
325
|
+
if hud not in ("chip-left", "corner-line", "none"):
|
|
326
|
+
raise ValueError("style 的 hud 装帧 %r 不认识;可用:chip-left / corner-line / none" % (hud,))
|
|
327
|
+
|
|
328
|
+
payload = {
|
|
329
|
+
"version": STYLE_VERSION,
|
|
330
|
+
"style": extra.get("style") or theme.get("id"),
|
|
331
|
+
"style_name": extra.get("style_name") or theme.get("label") or "",
|
|
332
|
+
"theme_id": theme.get("id") or extra.get("style") or "",
|
|
333
|
+
"theme_label": theme.get("label") or extra.get("style_name") or "",
|
|
334
|
+
"legacy": bool(extra.get("legacy")) if extra.get("legacy") is not None else False,
|
|
335
|
+
"dark": bool(dark),
|
|
336
|
+
"canvas": {"w": w, "h": h, "aspect": "16:9", "fps": fps_val},
|
|
337
|
+
"theme": colors,
|
|
338
|
+
"typography": {
|
|
339
|
+
"family": family,
|
|
340
|
+
"font_stack": FONT_STACKS[family],
|
|
341
|
+
"scale": scale,
|
|
342
|
+
"tracking": _num(theme.get("tracking"), 0.0),
|
|
343
|
+
"line_gap": _num(theme.get("line_gap"), 1.30),
|
|
344
|
+
},
|
|
345
|
+
"layout": {"default": str(layout_default),
|
|
346
|
+
"chapter": {str(k): str(v) for k, v in chapter_layout.items()}},
|
|
347
|
+
"card": card,
|
|
348
|
+
"material": {
|
|
349
|
+
"vignette": _num(theme.get("vignette"), 0.0),
|
|
350
|
+
"grain": _num(theme.get("grain"), 0.0),
|
|
351
|
+
"grain_freq": _num(theme.get("grain_freq"), 0.0),
|
|
352
|
+
"texture": str(theme.get("texture") or "none"),
|
|
353
|
+
"texture_alpha": _num(theme.get("texture_alpha"), 0.0),
|
|
354
|
+
"vignette_tint": _hex_color(theme.get("vignette_tint", (0, 0, 0)), "material.vignette_tint")
|
|
355
|
+
if "vignette_tint" in theme else "#000000",
|
|
356
|
+
},
|
|
357
|
+
"motion": {
|
|
358
|
+
"ease": str(theme.get("ease") or "out3"),
|
|
359
|
+
"entrance_dx": int(_num(theme.get("entrance_dx"), 0)),
|
|
360
|
+
"entrance_dy": int(_num(theme.get("entrance_dy"), 0)),
|
|
361
|
+
"push": _num(theme.get("push"), 0.0),
|
|
362
|
+
"item_stagger": _num(theme.get("item_stagger"), 0.0),
|
|
363
|
+
"transition": str(theme.get("transition") or "black"),
|
|
364
|
+
},
|
|
365
|
+
"hud": hud,
|
|
366
|
+
"safe": {"bias": int(_num(theme.get("safe_bias"), 0))},
|
|
367
|
+
}
|
|
368
|
+
return payload
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def style_sha256(payload) -> str:
|
|
372
|
+
"""注入 payload 的稳定指纹(sort_keys + 紧凑分隔符):同 style 同尺寸 → 同指纹。
|
|
373
|
+
|
|
374
|
+
用途:① 日志里一眼看出"这次渲的是不是同一套画风";② 分块目录名后缀——换了 style
|
|
375
|
+
就不能复用旧块(旧块是旧画风,混进成片就是花片)。
|
|
376
|
+
"""
|
|
377
|
+
blob = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
|
378
|
+
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _style_js(payload) -> str:
|
|
382
|
+
"""生成 add_init_script 的脚本体:**纯 JSON 赋值**,页面里不做任何派生计算。"""
|
|
383
|
+
blob = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
384
|
+
return "window.__STYLE__ = %s;" % blob
|
|
385
|
+
|
|
386
|
+
|
|
116
387
|
# ---------------------------------------------------------------- 页面 / URL
|
|
117
388
|
def _to_url(html_path) -> str:
|
|
118
389
|
"""把本地 HTML 路径转成 file:// URI;已经是 URL 的原样返回。"""
|
|
@@ -147,7 +418,13 @@ def _console_log(msg) -> None:
|
|
|
147
418
|
|
|
148
419
|
|
|
149
420
|
def _use_deterministic(flag=None) -> bool:
|
|
150
|
-
"""确定性开关:显式参数优先,其次环境变量 WEBENGINE_DETERMINISTIC(1/true/yes/on)。
|
|
421
|
+
"""确定性开关:显式参数优先,其次环境变量 WEBENGINE_DETERMINISTIC(1/true/yes/on)。
|
|
422
|
+
|
|
423
|
+
注意 flag 的三态语义:None = 没表态(去读环境变量),True/False = 调用方明确要求。
|
|
424
|
+
CLI 的 --deterministic 必须用 default=None(store_const)而不是 store_true:后者在
|
|
425
|
+
"没传" 时给出 False,会把 WEBENGINE_DETERMINISTIC=1 当成"调用方明确要求关掉",
|
|
426
|
+
环境变量整条失效(命令行实测过,库调用路径却生效——两条路径行为不一致)。
|
|
427
|
+
"""
|
|
151
428
|
if flag is not None:
|
|
152
429
|
return bool(flag)
|
|
153
430
|
return str(os.environ.get("WEBENGINE_DETERMINISTIC") or "").strip().lower() in (
|
|
@@ -212,12 +489,12 @@ async def page_number(page, key: str, *, default=None) -> float:
|
|
|
212
489
|
return val
|
|
213
490
|
|
|
214
491
|
|
|
215
|
-
async def open_page(pw, url, width=W, height=
|
|
216
|
-
ready_timeout=300_000):
|
|
492
|
+
async def open_page(pw, url, width=W, height=H, log=False, deterministic=None,
|
|
493
|
+
ready_timeout=300_000, style=None, fps=None):
|
|
217
494
|
"""打开页面并等到 window.ready === true,返回 (browser, page, cdp)。
|
|
218
495
|
|
|
219
496
|
浏览器优先 Microsoft Edge(channel="msedge"),启动失败回落 Playwright 自带的 Chromium;
|
|
220
|
-
|
|
497
|
+
视口按 width x height(devicePixelRatio=1),截图与画布 1:1 像素。
|
|
221
498
|
|
|
222
499
|
v0.4.0 起多了两道**可操作**的契约检查(以前缺 window.ready / renderFrame 时只会
|
|
223
500
|
抛 Playwright 的裸超时异常,看不出是页面脚本挂了还是没调 FK.boot()):
|
|
@@ -225,8 +502,15 @@ async def open_page(pw, url, width=W, height=1080, log=False, deterministic=None
|
|
|
225
502
|
2. window.renderFrame 不是函数 → 直接报错(不再逐帧重试 5 次才发现)。
|
|
226
503
|
确定性:deterministic=True(或环境变量 WEBENGINE_DETERMINISTIC=1)走软件光栅,
|
|
227
504
|
跨浏览器会话 byte 级可复现;缺省走 GPU,快约 22%。
|
|
505
|
+
|
|
506
|
+
v0.8.0 风格:style 非 None 时在 page.goto **之前** add_init_script 注入 window.__STYLE__;
|
|
507
|
+
style=None 时**不调用 add_init_script**,页面里不存在 __STYLE__(旧行为逐字节不变)。
|
|
508
|
+
width/height 一律先过 _validate_canvas(16:9 横屏偶数,否则 raise);fps 只用于写进
|
|
509
|
+
payload(None = 不让 payload 约束帧率,页面 window.FPS 才是真值)。
|
|
228
510
|
"""
|
|
229
511
|
global _BROWSER_KIND
|
|
512
|
+
width, height = _validate_canvas(width, height)
|
|
513
|
+
payload = normalize_style(style, width=width, height=height, fps=fps) if style is not None else None
|
|
230
514
|
browser = None
|
|
231
515
|
args = browser_args(deterministic)
|
|
232
516
|
try:
|
|
@@ -241,7 +525,19 @@ async def open_page(pw, url, width=W, height=1080, log=False, deterministic=None
|
|
|
241
525
|
_BROWSER_KIND = "chromium"
|
|
242
526
|
if log:
|
|
243
527
|
print("[浏览器] Playwright Chromium", flush=True)
|
|
244
|
-
|
|
528
|
+
try:
|
|
529
|
+
page = await browser.new_page(viewport={"width": int(width), "height": int(height)},
|
|
530
|
+
device_scale_factor=1)
|
|
531
|
+
except BaseException:
|
|
532
|
+
# 浏览器已经启动成功、new_page 才失败(内存不足 / 浏览器刚崩 / 任务被取消)时,
|
|
533
|
+
# 不关就会留下一个没有页面、也没人引用的 chromium 进程。render_full 的单帧重试最多
|
|
534
|
+
# 5 次 × N 个 worker,密集失败时这些进程会一直堆着(任务管理器里一排 chromium),
|
|
535
|
+
# 所以这里和下面的 goto/等 ready 一样,失败路径统一关浏览器。
|
|
536
|
+
try:
|
|
537
|
+
await browser.close()
|
|
538
|
+
except Exception:
|
|
539
|
+
pass
|
|
540
|
+
raise
|
|
245
541
|
page_errors = []
|
|
246
542
|
|
|
247
543
|
def _on_pageerror(exc):
|
|
@@ -260,6 +556,16 @@ async def open_page(pw, url, width=W, height=1080, log=False, deterministic=None
|
|
|
260
556
|
page.on("console", _on_console)
|
|
261
557
|
page.on("pageerror", _on_pageerror)
|
|
262
558
|
try:
|
|
559
|
+
if payload is not None:
|
|
560
|
+
# 必须在 goto 之前:engine.js/film.js 是同步 IIFE,加载时就要读到 __STYLE__
|
|
561
|
+
await page.add_init_script(script=_style_js(payload))
|
|
562
|
+
if log:
|
|
563
|
+
print("[样式] %s / %s / %dx%d / 字体族=%s / sha=%s"
|
|
564
|
+
% (payload.get("style") or payload.get("theme_id"),
|
|
565
|
+
payload.get("style_name") or payload.get("theme_label"),
|
|
566
|
+
payload["canvas"]["w"], payload["canvas"]["h"],
|
|
567
|
+
payload["typography"]["family"], style_sha256(payload)[:12]),
|
|
568
|
+
flush=True)
|
|
263
569
|
await page.goto(url, wait_until="domcontentloaded", timeout=180_000)
|
|
264
570
|
try:
|
|
265
571
|
await page.wait_for_function("window.ready === true", timeout=int(ready_timeout),
|
|
@@ -283,13 +589,79 @@ async def open_page(pw, url, width=W, height=1080, log=False, deterministic=None
|
|
|
283
589
|
"页面脚本顺序为 engine.js → film.js → qa_text.js。"
|
|
284
590
|
% (url, _format_contract(contract))
|
|
285
591
|
)
|
|
286
|
-
|
|
592
|
+
if payload is not None:
|
|
593
|
+
# 注入的契约自检:payload 说 16:9 + w/h,页面就必须真的按它建画布;
|
|
594
|
+
# 页面没照做(老页面不认 __STYLE__)要当场报错,不能拿"截图能出图"当通过。
|
|
595
|
+
got = await page.evaluate(
|
|
596
|
+
"() => ({hasStyle: typeof window.__STYLE__ === 'object' && window.__STYLE__ !== null,"
|
|
597
|
+
" applied: !!(window.FK && window.FK.style),"
|
|
598
|
+
" w: window.FK ? window.FK.W : null, h: window.FK ? window.FK.H : null,"
|
|
599
|
+
" fps: window.FPS})")
|
|
600
|
+
want_w, want_h = payload["canvas"]["w"], payload["canvas"]["h"]
|
|
601
|
+
# 两个条件缺一不可:__STYLE__ 在(注入成功)+ engine.js 真的读了它(FK.style 非空)。
|
|
602
|
+
# 只查前者会漏掉"老 engine.js 静默忽略 __STYLE__、照样渲成深蓝橙"这种假通过。
|
|
603
|
+
if not got.get("hasStyle") or not got.get("applied"):
|
|
604
|
+
raise RuntimeError(
|
|
605
|
+
"页面 %s 的 engine.js 不认 window.__STYLE__(FK.style 为空;注入后画风不会变)。"
|
|
606
|
+
"风格注入要 engine.js v0.8.0 起才认;把新版 assets/web/ 一起拷过去,"
|
|
607
|
+
"或确认页面脚本顺序是 engine.js → film.js → qa_text.js。" % url)
|
|
608
|
+
if got.get("w") != want_w or got.get("h") != want_h:
|
|
609
|
+
raise RuntimeError(
|
|
610
|
+
"页面画布 %sx%s 与注入的 %dx%d 不一致:页面没按 __STYLE__.canvas 建画布"
|
|
611
|
+
"(见 assets/web/engine.js 的 ST_CANVAS 段)。" % (got.get("w"), got.get("h"), want_w, want_h))
|
|
612
|
+
if payload["canvas"]["fps"] is not None:
|
|
613
|
+
page_fps = float(got.get("fps") or 0)
|
|
614
|
+
if abs(page_fps - float(payload["canvas"]["fps"])) > 1e-6:
|
|
615
|
+
raise RuntimeError(
|
|
616
|
+
"页面 window.FPS=%s 与注入的 __STYLE__.canvas.fps=%s 不一致:"
|
|
617
|
+
"帧率必须来自同一份 timeline,不能靠 style 猜。"
|
|
618
|
+
% (page_fps, payload["canvas"]["fps"]))
|
|
619
|
+
# 截图 = 视口(device_scale_factor=1),所以页面画布的可视区域必须正好铺满视口。
|
|
620
|
+
# 以前不查这一条,于是 style=None + --width 1280 --height 720 会"成功"输出 1280x720:
|
|
621
|
+
# 页面画布仍是 1920x1080(没有 __STYLE__ 就没人改它),截出来的是原画左上角一块
|
|
622
|
+
# ——实测与 1920 帧左上角的 1280x720 裁切逐像素完全相同(422 KB PNG,0 像素差异),
|
|
623
|
+
# 标题 / HUD / 时间码全被裁掉,而 CLI 照旧 exit 0。画布比视口小则是四边留黑边,
|
|
624
|
+
# 同样"出片成功但画面是错的",一并拦。
|
|
625
|
+
box = await page.evaluate(
|
|
626
|
+
"() => { var cs = document.querySelectorAll('canvas');"
|
|
627
|
+
" if (!cs.length) return null;"
|
|
628
|
+
" var l = 1e9, t = 1e9, r = -1e9, b = -1e9;"
|
|
629
|
+
" for (var i = 0; i < cs.length; i++) {"
|
|
630
|
+
" var q = cs[i].getBoundingClientRect();"
|
|
631
|
+
" if (!(q.width > 0) || !(q.height > 0)) continue;"
|
|
632
|
+
" l = Math.min(l, q.left); t = Math.min(t, q.top);"
|
|
633
|
+
" r = Math.max(r, q.right); b = Math.max(b, q.bottom); }"
|
|
634
|
+
" return (r > l) ? [l, t, r - l, b - t] : null; }")
|
|
635
|
+
if box is None:
|
|
636
|
+
raise ValueError(
|
|
637
|
+
"页面 %s 里没有可截图的 <canvas>(页面契约要求 <canvas id='stage'>)。" % url)
|
|
638
|
+
left, top, cw, ch = (float(v) for v in box)
|
|
639
|
+
if (abs(left) > 1.5 or abs(top) > 1.5
|
|
640
|
+
or abs(cw - float(width)) > 1.5 or abs(ch - float(height)) > 1.5):
|
|
641
|
+
raise ValueError(
|
|
642
|
+
"页面画布的可视区域是 x=%g y=%g w=%g h=%g(CSS 像素),与截图视口 %dx%d 不一致。"
|
|
643
|
+
"截图尺寸就是视口(devicePixelRatio=1):画布比视口大会被裁掉、比视口小会留黑边,"
|
|
644
|
+
"两种都是「出片成功但画面是错的」。模板页面不注入 style 时画布固定 1920x1080 —— "
|
|
645
|
+
"要换输出尺寸请用 --style 注入 __STYLE__.canvas(engine.js 会跟着改画布),"
|
|
646
|
+
"或把页面画布与 CSS 尺寸一起改成目标尺寸;legacy 项目(不注入 style)"
|
|
647
|
+
"请按 1920x1080 渲染,再由上游下采样到交付规格。"
|
|
648
|
+
% (left, top, cw, ch, int(width), int(height)))
|
|
649
|
+
except BaseException:
|
|
650
|
+
# 用 BaseException 而不是 Exception:worker 被取消(task.cancel)抛的是
|
|
651
|
+
# CancelledError,它不继承 Exception —— 以前这条路径会把刚启动的浏览器丢下不管。
|
|
652
|
+
try:
|
|
653
|
+
await browser.close()
|
|
654
|
+
except Exception:
|
|
655
|
+
pass
|
|
656
|
+
raise
|
|
657
|
+
try:
|
|
658
|
+
cdp = await page.context.new_cdp_session(page)
|
|
659
|
+
except BaseException:
|
|
287
660
|
try:
|
|
288
661
|
await browser.close()
|
|
289
662
|
except Exception:
|
|
290
663
|
pass
|
|
291
664
|
raise
|
|
292
|
-
cdp = await page.context.new_cdp_session(page)
|
|
293
665
|
return browser, page, cdp
|
|
294
666
|
|
|
295
667
|
|
|
@@ -300,14 +672,15 @@ async def _grab(page, cdp, f: int) -> bytes:
|
|
|
300
672
|
return base64.b64decode(reply["data"])
|
|
301
673
|
|
|
302
674
|
|
|
303
|
-
async def _page_meta_async(html_path, deterministic=None):
|
|
675
|
+
async def _page_meta_async(html_path, deterministic=None, style=None, width=W, height=H, fps=None):
|
|
304
676
|
url = _to_url(html_path)
|
|
305
677
|
async with _playwright()() as pw:
|
|
306
|
-
browser, page, _cdp = await open_page(pw, url, deterministic=deterministic
|
|
678
|
+
browser, page, _cdp = await open_page(pw, url, width, height, deterministic=deterministic,
|
|
679
|
+
style=style, fps=fps)
|
|
307
680
|
try:
|
|
308
681
|
duration = await page_number(page, "DURATION")
|
|
309
|
-
|
|
310
|
-
return duration,
|
|
682
|
+
got_fps = await page_number(page, "FPS", default=FPS)
|
|
683
|
+
return duration, got_fps
|
|
311
684
|
finally:
|
|
312
685
|
try:
|
|
313
686
|
await browser.close()
|
|
@@ -315,15 +688,23 @@ async def _page_meta_async(html_path, deterministic=None):
|
|
|
315
688
|
pass
|
|
316
689
|
|
|
317
690
|
|
|
318
|
-
def page_meta(html_path):
|
|
319
|
-
"""打开一次页面,读回 (DURATION, FPS)。
|
|
691
|
+
def page_meta(html_path, *, style=None, width=W, height=H, fps=None):
|
|
692
|
+
"""打开一次页面,读回 (DURATION, FPS)。
|
|
693
|
+
|
|
694
|
+
v0.8.0:style 非 None 时按注入后的页面读(注入可能改画布尺寸,帧率仍以页面为准,
|
|
695
|
+
除非调用方显式传 fps 并在 payload 里断言)。
|
|
696
|
+
"""
|
|
320
697
|
_setup_stdout()
|
|
321
|
-
return asyncio.run(_page_meta_async(html_path))
|
|
698
|
+
return asyncio.run(_page_meta_async(html_path, style=style, width=width, height=height, fps=fps))
|
|
322
699
|
|
|
323
700
|
|
|
324
701
|
# ---------------------------------------------------------------- snap
|
|
325
|
-
def snap(html_path, times, out_dir, tag="", deterministic=None
|
|
326
|
-
|
|
702
|
+
def snap(html_path, times, out_dir, tag="", deterministic=None, *, style=None,
|
|
703
|
+
width=W, height=H, fps=None) -> list:
|
|
704
|
+
"""截取指定时刻的静帧(PNG);多帧时额外拼一张联系表。返回帧 PNG 路径列表。
|
|
705
|
+
|
|
706
|
+
v0.8.0:style/width/height/fps 透传给 open_page(缺省 = 旧行为:1920x1080、不注入)。
|
|
707
|
+
"""
|
|
327
708
|
_setup_stdout()
|
|
328
709
|
tlist = [float(t) for t in times]
|
|
329
710
|
if not tlist:
|
|
@@ -332,14 +713,17 @@ def snap(html_path, times, out_dir, tag="", deterministic=None) -> list:
|
|
|
332
713
|
if not out.is_absolute():
|
|
333
714
|
out = (Path.cwd() / out).resolve()
|
|
334
715
|
out.mkdir(parents=True, exist_ok=True)
|
|
335
|
-
return asyncio.run(_snap_async(html_path, tlist, out, str(tag), deterministic
|
|
716
|
+
return asyncio.run(_snap_async(html_path, tlist, out, str(tag), deterministic,
|
|
717
|
+
style=style, width=width, height=height, fps=fps))
|
|
336
718
|
|
|
337
719
|
|
|
338
|
-
async def _snap_async(html_path, times, out: Path, tag: str, deterministic=None
|
|
720
|
+
async def _snap_async(html_path, times, out: Path, tag: str, deterministic=None, *,
|
|
721
|
+
style=None, width=W, height=H, fps=None) -> list:
|
|
339
722
|
url = _to_url(html_path)
|
|
340
723
|
paths = []
|
|
341
724
|
async with _playwright()() as pw:
|
|
342
|
-
browser, page, cdp = await open_page(pw, url,
|
|
725
|
+
browser, page, cdp = await open_page(pw, url, width, height, deterministic=deterministic,
|
|
726
|
+
style=style, fps=fps)
|
|
343
727
|
try:
|
|
344
728
|
fps = await page_number(page, "FPS", default=FPS)
|
|
345
729
|
for t in times:
|
|
@@ -361,6 +745,24 @@ async def _snap_async(html_path, times, out: Path, tag: str, deterministic=None)
|
|
|
361
745
|
|
|
362
746
|
|
|
363
747
|
# ---------------------------------------------------------------- cues / events 导出
|
|
748
|
+
def _finite_t(value, kind: str, label) -> float:
|
|
749
|
+
"""cue / event 的时间必须是有限秒数,返回 round(x, 3)。
|
|
750
|
+
|
|
751
|
+
为什么单拎出来:NaN / Infinity 在 Python 的 json.dumps 里默认被写成字面量 NaN /
|
|
752
|
+
Infinity,那不是合法 JSON(RFC 8259),而 Python 侧 json.load 又会照单全收 —— 于是
|
|
753
|
+
坏时间一路流到配乐 / 音效 / 同步门禁,表现成"某条提示点没响""采样索引对不上"这种
|
|
754
|
+
和根因隔了三层的现象。导出时就报错,带上 kind / type 便于定位是哪条。
|
|
755
|
+
"""
|
|
756
|
+
try:
|
|
757
|
+
t = float(value)
|
|
758
|
+
except (TypeError, ValueError):
|
|
759
|
+
raise ValueError("%s 的 t 不是数字:%r(%r)" % (kind, value, label))
|
|
760
|
+
if not math.isfinite(t):
|
|
761
|
+
raise ValueError("%s 的 t 不是有限秒数:%r(%r)—— NaN / Infinity 不是合法 JSON,"
|
|
762
|
+
"写进 work/cues.json 后下游时间轴会静默错位" % (kind, value, label))
|
|
763
|
+
return round(t, 3)
|
|
764
|
+
|
|
765
|
+
|
|
364
766
|
def _norm_cue_list(raw, src: str) -> list:
|
|
365
767
|
out = []
|
|
366
768
|
for item in raw or []:
|
|
@@ -371,7 +773,7 @@ def _norm_cue_list(raw, src: str) -> list:
|
|
|
371
773
|
raise ValueError("cue 缺 t 字段: %r" % (item,))
|
|
372
774
|
if not cue.get("type"):
|
|
373
775
|
raise ValueError("cue 缺 type 字段: %r" % (item,))
|
|
374
|
-
cue["t"] =
|
|
776
|
+
cue["t"] = _finite_t(cue["t"], "cue", cue.get("type"))
|
|
375
777
|
cue.setdefault("src", src)
|
|
376
778
|
out.append(cue)
|
|
377
779
|
out.sort(key=lambda c: c["t"])
|
|
@@ -393,7 +795,7 @@ def _norm_events(raw) -> list:
|
|
|
393
795
|
ev = dict(item)
|
|
394
796
|
if "t" not in ev:
|
|
395
797
|
continue
|
|
396
|
-
ev["t"] =
|
|
798
|
+
ev["t"] = _finite_t(ev["t"], "event", ev.get("kind"))
|
|
397
799
|
ev.setdefault("kind", "misc")
|
|
398
800
|
ev.setdefault("src", "web")
|
|
399
801
|
out.append(ev)
|
|
@@ -401,21 +803,50 @@ def _norm_events(raw) -> list:
|
|
|
401
803
|
return out
|
|
402
804
|
|
|
403
805
|
|
|
404
|
-
def export_cues(html_path, deterministic=None) -> dict:
|
|
405
|
-
"""导出页面登记的 cue / 视觉事件,返回可直接写 work/cues.json 的字典(契约 §4.2)。
|
|
806
|
+
def export_cues(html_path, deterministic=None, *, style=None, width=W, height=H, fps=None) -> dict:
|
|
807
|
+
"""导出页面登记的 cue / 视觉事件,返回可直接写 work/cues.json 的字典(契约 §4.2)。
|
|
808
|
+
|
|
809
|
+
v0.8.0:width/height 写进返回头(打完 style 注入后画布可能不是 1080p),其余照旧。
|
|
810
|
+
"""
|
|
406
811
|
_setup_stdout()
|
|
407
|
-
return asyncio.run(_export_cues_async(html_path, deterministic
|
|
812
|
+
return asyncio.run(_export_cues_async(html_path, deterministic,
|
|
813
|
+
style=style, width=width, height=height, fps=fps))
|
|
408
814
|
|
|
409
815
|
|
|
410
|
-
async def _export_cues_async(html_path, deterministic=None
|
|
816
|
+
async def _export_cues_async(html_path, deterministic=None, *, style=None,
|
|
817
|
+
width=W, height=H, fps=None) -> dict:
|
|
411
818
|
url = _to_url(html_path)
|
|
819
|
+
width, height = _validate_canvas(width, height)
|
|
412
820
|
async with _playwright()() as pw:
|
|
413
|
-
browser, page, _cdp = await open_page(pw, url,
|
|
821
|
+
browser, page, _cdp = await open_page(pw, url, width, height, deterministic=deterministic,
|
|
822
|
+
style=style, fps=fps)
|
|
414
823
|
try:
|
|
415
824
|
duration = await page_number(page, "DURATION")
|
|
416
825
|
fps = await page_number(page, "FPS", default=FPS)
|
|
417
|
-
|
|
418
|
-
|
|
826
|
+
# 页面没导出 getCues/getEvents 时,以前 `? : []` 会静默返回空数组:cues.json 里
|
|
827
|
+
# 0 条提示点,配乐/音效阶段照样跑完,只是整片悄悄变哑、音画同步也没有可配的事件
|
|
828
|
+
# ——「静默丢一整个约定」比报错难查得多。engine.js 本身就导出这两个函数(没有提示
|
|
829
|
+
# 点时返回空数组也算导出),所以 fail-closed 不会误伤正常页面。
|
|
830
|
+
contract = await _page_contract(page)
|
|
831
|
+
missing = [k for k in ("getCues", "getEvents") if contract.get(k) != "function"]
|
|
832
|
+
if missing:
|
|
833
|
+
raise RuntimeError(
|
|
834
|
+
"页面 %s 没有导出 window.%s(导出 cue / 视觉事件要求页面契约见 "
|
|
835
|
+
"references/web-engine.md §3)。assets/web/engine.js 自带这两个函数;"
|
|
836
|
+
"自定义页面请补上(本片确实没有提示点时也要显式导出空数组):"
|
|
837
|
+
"window.getCues = function () { return []; }; "
|
|
838
|
+
"window.getEvents = function () { return []; }; 契约快照:%s"
|
|
839
|
+
% (url, " / ".join(missing), _format_contract(contract)))
|
|
840
|
+
cues = await page.evaluate("window.getCues()")
|
|
841
|
+
events = await page.evaluate("window.getEvents()")
|
|
842
|
+
# 返回的不是数组时 _norm_cue_list/_norm_events 会把它当"没有条目"逐项跳过 →
|
|
843
|
+
# 依旧是静默 0 条,所以数组类型也要在这里拦下。
|
|
844
|
+
for _name, _val in (("getCues", cues), ("getEvents", events)):
|
|
845
|
+
if not isinstance(_val, list):
|
|
846
|
+
raise RuntimeError(
|
|
847
|
+
"页面 window.%s() 返回的不是数组(收到 %s):cue / 事件必须是数组,"
|
|
848
|
+
"契约见 references/web-engine.md §3。"
|
|
849
|
+
% (_name, type(_val).__name__))
|
|
419
850
|
finally:
|
|
420
851
|
try:
|
|
421
852
|
await browser.close()
|
|
@@ -425,8 +856,8 @@ async def _export_cues_async(html_path, deterministic=None) -> dict:
|
|
|
425
856
|
"version": 1,
|
|
426
857
|
"duration": round(duration, 3),
|
|
427
858
|
"fps": fps,
|
|
428
|
-
"width":
|
|
429
|
-
"height":
|
|
859
|
+
"width": width,
|
|
860
|
+
"height": height,
|
|
430
861
|
"cues": _norm_cue_list(cues, "web"),
|
|
431
862
|
"events": _norm_events(events),
|
|
432
863
|
}
|
|
@@ -447,9 +878,27 @@ def _encoder_cmd(dst, fps: float, crf: int = 17, preset: str = "slow") -> list:
|
|
|
447
878
|
]
|
|
448
879
|
|
|
449
880
|
|
|
450
|
-
def _chunk_dir(out_mp4, from_s: float, to_s: float) -> Path:
|
|
881
|
+
def _chunk_dir(out_mp4, from_s: float, to_s: float, token: str = "") -> Path:
|
|
882
|
+
"""块目录:<stem>_<from>-<to>[_<token>]。
|
|
883
|
+
|
|
884
|
+
token 只在"画布尺寸不是 1920x1080 或注入了 style"时非空——不同画风/尺寸的块不能混用,
|
|
885
|
+
否则断点续跑会把旧画风的块拼进新片(花片)。token 为空时目录名与 v0.7 完全一致。
|
|
886
|
+
"""
|
|
451
887
|
out = Path(out_mp4)
|
|
452
|
-
|
|
888
|
+
name = "%s_%g-%g" % (out.stem, float(from_s), float(to_s))
|
|
889
|
+
if token:
|
|
890
|
+
name += "_" + token
|
|
891
|
+
return out.parent / "chunks" / name
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _style_token(payload, width: int, height: int) -> str:
|
|
895
|
+
"""分块目录的风格后缀;默认尺寸 + 无 style 时返回空串(保持 v0.7 的目录名)。"""
|
|
896
|
+
if payload is None and (int(width), int(height)) == (W, H):
|
|
897
|
+
return ""
|
|
898
|
+
parts = ["w%dh%d" % (int(width), int(height))]
|
|
899
|
+
if payload is not None:
|
|
900
|
+
parts.append("s" + style_sha256(payload)[:12])
|
|
901
|
+
return "_".join(parts)
|
|
453
902
|
|
|
454
903
|
|
|
455
904
|
def _plan_chunks(chunk_dir, f0: int, f1: int, step: int) -> list:
|
|
@@ -512,7 +961,8 @@ def _check_chunk_durations(chunks, fps: float) -> None:
|
|
|
512
961
|
class _Browser:
|
|
513
962
|
"""一个无头浏览器会话;卡住/崩溃时自动关掉重开(逐帧无状态,从同一帧继续即可)。"""
|
|
514
963
|
|
|
515
|
-
def __init__(self, pw, url, width=W, height=1080, k=0, log=False, deterministic=None
|
|
964
|
+
def __init__(self, pw, url, width=W, height=1080, k=0, log=False, deterministic=None,
|
|
965
|
+
style=None, fps=None):
|
|
516
966
|
self.pw = pw
|
|
517
967
|
self.url = url
|
|
518
968
|
self.width = width
|
|
@@ -520,6 +970,8 @@ class _Browser:
|
|
|
520
970
|
self.k = k
|
|
521
971
|
self.log = log
|
|
522
972
|
self.deterministic = deterministic
|
|
973
|
+
self.style = style
|
|
974
|
+
self.fps = fps
|
|
523
975
|
self.browser = None
|
|
524
976
|
self.page = None
|
|
525
977
|
self.cdp = None
|
|
@@ -528,7 +980,7 @@ class _Browser:
|
|
|
528
980
|
await self.close()
|
|
529
981
|
self.browser, self.page, self.cdp = await open_page(
|
|
530
982
|
self.pw, self.url, self.width, self.height, log=self.log,
|
|
531
|
-
deterministic=self.deterministic,
|
|
983
|
+
deterministic=self.deterministic, style=self.style, fps=self.fps,
|
|
532
984
|
)
|
|
533
985
|
|
|
534
986
|
async def close(self):
|
|
@@ -622,9 +1074,10 @@ async def _write_chunk(br: _Browser, f0: int, f1: int, dst: Path, fps: float,
|
|
|
622
1074
|
|
|
623
1075
|
|
|
624
1076
|
async def _worker(k: int, queue, state: dict, url: str, fps: float, crf: int, preset: str,
|
|
625
|
-
deterministic=None) -> None:
|
|
1077
|
+
deterministic=None, style=None, width=W, height=H) -> None:
|
|
626
1078
|
async with _playwright()() as pw:
|
|
627
|
-
br = _Browser(pw, url,
|
|
1079
|
+
br = _Browser(pw, url, width, height, k=k, deterministic=deterministic,
|
|
1080
|
+
style=style, fps=fps)
|
|
628
1081
|
try:
|
|
629
1082
|
while True:
|
|
630
1083
|
try:
|
|
@@ -639,47 +1092,71 @@ async def _worker(k: int, queue, state: dict, url: str, fps: float, crf: int, pr
|
|
|
639
1092
|
# ---------------------------------------------------------------- render_full
|
|
640
1093
|
def render_full(html_path, out_mp4, duration, *, workers=2, chunk_s=30.0,
|
|
641
1094
|
resume=True, from_s=0.0, to_s=None, crf=17, preset="slow",
|
|
642
|
-
deterministic=None) -> Path:
|
|
1095
|
+
deterministic=None, style=None, width=W, height=H, fps=None) -> Path:
|
|
643
1096
|
"""并行渲染整片(或一段)→ mp4。返回成片绝对路径。
|
|
644
1097
|
|
|
645
1098
|
duration 传 None 时读页面 window.DURATION;传数字时与页面对不上会直接 raise。
|
|
646
1099
|
deterministic=True(或 WEBENGINE_DETERMINISTIC=1)→ 软件光栅,跨会话 byte 可复现。
|
|
1100
|
+
|
|
1101
|
+
v0.8.0:style 非 None 时整片跟随风格包(每个 worker 都在页面加载前注入同一份
|
|
1102
|
+
__STYLE__),块目录名会带尺寸 + style 指纹后缀,**换风格不会复用旧块**;
|
|
1103
|
+
width/height 只收 16:9 横屏偶数(_validate_canvas),fps 非 None 时要求页面帧率一致。
|
|
647
1104
|
"""
|
|
648
1105
|
_setup_stdout()
|
|
649
1106
|
return asyncio.run(_render_full_async(
|
|
650
1107
|
html_path, out_mp4, duration, workers=workers, chunk_s=chunk_s, resume=resume,
|
|
651
1108
|
from_s=from_s, to_s=to_s, crf=crf, preset=preset, deterministic=deterministic,
|
|
1109
|
+
style=style, width=width, height=height, fps=fps,
|
|
652
1110
|
))
|
|
653
1111
|
|
|
654
1112
|
|
|
655
1113
|
async def _render_full_async(html_path, out_mp4, duration, *, workers=2, chunk_s=30.0,
|
|
656
1114
|
resume=True, from_s=0.0, to_s=None, crf=17, preset="slow",
|
|
657
|
-
deterministic=None) -> Path:
|
|
1115
|
+
deterministic=None, style=None, width=W, height=H, fps=None) -> Path:
|
|
658
1116
|
url = _to_url(html_path)
|
|
659
1117
|
out = Path(out_mp4).expanduser()
|
|
660
1118
|
if not out.is_absolute():
|
|
661
1119
|
out = (Path.cwd() / out).resolve()
|
|
662
1120
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1121
|
+
width, height = _validate_canvas(width, height)
|
|
1122
|
+
payload = normalize_style(style, width=width, height=height, fps=fps) if style is not None else None
|
|
1123
|
+
|
|
1124
|
+
# chunk_s <= 0 以前会被后面的 max(1, ...) 静默夹成「1 帧/块」:360 帧的 12 s 片会拆出
|
|
1125
|
+
# 360 个块,每块单独起一个 ffmpeg 进程再拼接,慢到没法用,而且日志里只看得出"块很多",
|
|
1126
|
+
# 看不出是自己传错了参数。放在开浏览器之前校验,参数错了不用白等一次页面加载。
|
|
1127
|
+
chunk_s = float(chunk_s)
|
|
1128
|
+
if not math.isfinite(chunk_s) or chunk_s <= 0:
|
|
1129
|
+
raise ValueError("chunk_s 必须是正数秒(收到 %r);以前 <=0 会被静默当成 1 帧/块"
|
|
1130
|
+
% (chunk_s,))
|
|
663
1131
|
|
|
664
1132
|
# 先问页面要长度与帧率:这是块切分与成片校验的唯一真值来源
|
|
665
|
-
page_dur,
|
|
1133
|
+
page_dur, fps_page = await _page_meta_async(url, deterministic=deterministic,
|
|
1134
|
+
style=style, width=width, height=height, fps=fps)
|
|
666
1135
|
if duration is None:
|
|
667
1136
|
duration = page_dur
|
|
668
1137
|
duration = float(duration)
|
|
1138
|
+
if not math.isfinite(duration) or duration <= 0:
|
|
1139
|
+
# NaN 会穿过下面的 abs(diff) > 0.05(NaN 比较恒为 False),最后在 int(round(nan))
|
|
1140
|
+
# 处炸成一句 "cannot convert float NaN to integer",看不出是参数问题。
|
|
1141
|
+
raise ValueError("duration 必须是正数秒(收到 %r);传 None 表示读页面 window.DURATION"
|
|
1142
|
+
% (duration,))
|
|
669
1143
|
if abs(duration - page_dur) > 0.05:
|
|
670
1144
|
raise ValueError(
|
|
671
1145
|
"页面 window.DURATION=%.3f 与传入 duration=%.3f 不一致(先对齐剧本时长)"
|
|
672
1146
|
% (page_dur, duration)
|
|
673
1147
|
)
|
|
1148
|
+
fps = fps_page
|
|
674
1149
|
from_s = float(from_s)
|
|
675
1150
|
to_s = duration if to_s is None else float(to_s)
|
|
1151
|
+
if not (math.isfinite(from_s) and math.isfinite(to_s)):
|
|
1152
|
+
raise ValueError("渲染区间必须是有限秒数:from_s=%r to_s=%r" % (from_s, to_s))
|
|
676
1153
|
if from_s < 0 or to_s <= from_s or to_s > duration + 1e-6:
|
|
677
1154
|
raise ValueError("渲染区间非法:from_s=%.3f to_s=%.3f duration=%.3f" % (from_s, to_s, duration))
|
|
678
1155
|
|
|
679
1156
|
f0 = int(round(from_s * fps))
|
|
680
1157
|
f1 = int(round(to_s * fps))
|
|
681
|
-
step = max(1, int(round(
|
|
682
|
-
chunk_dir = _chunk_dir(out, from_s, to_s)
|
|
1158
|
+
step = max(1, int(round(chunk_s * fps)))
|
|
1159
|
+
chunk_dir = _chunk_dir(out, from_s, to_s, _style_token(payload, width, height))
|
|
683
1160
|
chunk_dir.mkdir(parents=True, exist_ok=True)
|
|
684
1161
|
chunks = _plan_chunks(chunk_dir, f0, f1, step)
|
|
685
1162
|
if not resume:
|
|
@@ -689,9 +1166,13 @@ async def _render_full_async(html_path, out_mp4, duration, *, workers=2, chunk_s
|
|
|
689
1166
|
victim.unlink()
|
|
690
1167
|
|
|
691
1168
|
todo = [c for c in chunks if not Path(c[2]).is_file()]
|
|
1169
|
+
style_note = ""
|
|
1170
|
+
if payload is not None:
|
|
1171
|
+
style_note = ",style=%s(sha=%s)" % (payload.get("style") or payload.get("theme_id"),
|
|
1172
|
+
style_sha256(payload)[:12])
|
|
692
1173
|
print(
|
|
693
|
-
"[full] %s\n[full] %s fps,渲染 %.2f–%.2f s(%d 帧),块 %.1fs → %d
|
|
694
|
-
% (url, ("%g" % fps), from_s, to_s, f1 - f0, float(chunk_s), len(chunks), chunk_dir),
|
|
1174
|
+
"[full] %s\n[full] %s fps,渲染 %.2f–%.2f s(%d 帧),块 %.1fs → %d 块%s,work=%s"
|
|
1175
|
+
% (url, ("%g" % fps), from_s, to_s, f1 - f0, float(chunk_s), len(chunks), style_note, chunk_dir),
|
|
695
1176
|
flush=True,
|
|
696
1177
|
)
|
|
697
1178
|
if todo:
|
|
@@ -703,7 +1184,8 @@ async def _render_full_async(html_path, out_mp4, duration, *, workers=2, chunk_s
|
|
|
703
1184
|
nw = max(1, min(int(workers), len(todo)))
|
|
704
1185
|
tasks = [
|
|
705
1186
|
asyncio.create_task(_worker(k, queue, state, url, fps, int(crf), str(preset),
|
|
706
|
-
deterministic=deterministic
|
|
1187
|
+
deterministic=deterministic, style=style,
|
|
1188
|
+
width=width, height=height))
|
|
707
1189
|
for k in range(nw)
|
|
708
1190
|
]
|
|
709
1191
|
try:
|
|
@@ -733,16 +1215,96 @@ async def _render_full_async(html_path, out_mp4, duration, *, workers=2, chunk_s
|
|
|
733
1215
|
|
|
734
1216
|
|
|
735
1217
|
# ---------------------------------------------------------------- 浏览器端文字质检
|
|
736
|
-
|
|
737
|
-
|
|
1218
|
+
# 「一闪而过」的判据是可见时长 < 0.6 s(qa_text.js 与 references/web-engine.md §9 同口径)。
|
|
1219
|
+
# 采样步长必须小于它:步长 >= 0.6 s 时任何一段文字最多被采到 1 个点,寿命估计必然 >= 0.6 s,
|
|
1220
|
+
# 这条判据就**永远不可能触发**——门禁是绿的,其实没在查。所以这里把常量拿出来,参数校验时
|
|
1221
|
+
# 直接拒绝,而不是让一个"看起来跑过"的扫描去放行。
|
|
1222
|
+
QA_BRIEF_S = 0.6
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _check_qa_window(t0, t1, step):
|
|
1226
|
+
"""文字质检扫描参数校验;非法 raise ValueError(以前非法参数会被静默吞成空结果 + exit 0)。
|
|
1227
|
+
|
|
1228
|
+
历史坑:qa_text.js 用 `if (end < start) return empty` 与 `dt = max(step, 1/fps)` 兜底,
|
|
1229
|
+
于是 --from 2 --to 1 打印"重叠 0、出画 0…"并 exit 0;--step 1 因为采不到 0.5 s 的闪
|
|
1230
|
+
字而永远报 brief=0。空扫描 / 扫不到的扫描都不能算"没有缺陷"。
|
|
1231
|
+
"""
|
|
1232
|
+
try:
|
|
1233
|
+
a = float(t0)
|
|
1234
|
+
b = None if t1 is None else float(t1)
|
|
1235
|
+
s = float(step)
|
|
1236
|
+
except (TypeError, ValueError):
|
|
1237
|
+
raise ValueError("文字质检参数必须是数字:t0=%r t1=%r step=%r" % (t0, t1, step))
|
|
1238
|
+
if not math.isfinite(a) or (b is not None and not math.isfinite(b)):
|
|
1239
|
+
raise ValueError("文字质检时间必须是有限秒数:t0=%r t1=%r" % (t0, t1))
|
|
1240
|
+
if not math.isfinite(s) or s <= 0:
|
|
1241
|
+
raise ValueError(
|
|
1242
|
+
"文字质检 step 必须是正数秒(收到 %r)。以前 <=0 会被静默替换成默认 0.2,"
|
|
1243
|
+
"扫描窗口和调用方以为的对不上。" % (step,))
|
|
1244
|
+
if s >= QA_BRIEF_S:
|
|
1245
|
+
raise ValueError(
|
|
1246
|
+
"文字质检 step=%g s 必须小于 %g s:「一闪而过」判据是可见时长 < %g s,步长 >= %g s "
|
|
1247
|
+
"时每段文字最多被采到 1 个点,该判据永远不可能触发(等于没检查)。"
|
|
1248
|
+
"整片扫描请用 step<=0.5;长片可分段多次调用。"
|
|
1249
|
+
% (s, QA_BRIEF_S, QA_BRIEF_S, QA_BRIEF_S))
|
|
1250
|
+
if b is not None and b < a:
|
|
1251
|
+
raise ValueError(
|
|
1252
|
+
"文字质检区间非法:t1=%.3f < t0=%.3f。以前这种空扫描会返回全 0 并让 CLI exit 0,"
|
|
1253
|
+
"看起来像「没有任何文字缺陷」。" % (b, a))
|
|
1254
|
+
return a, b, s
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
_QA_LIST_KEYS = ("overlaps", "offscreen", "brief", "never")
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _check_qa_result(result):
|
|
1261
|
+
"""校验页面 qaText 的返回值形状;形状不对 raise RuntimeError(不能让缺字段=全 0=PASS)。
|
|
1262
|
+
|
|
1263
|
+
历史坑:以前只判 `isinstance(result, dict)`,页面 `qaText=()=>({})` 时 CLI 把四类缺陷
|
|
1264
|
+
都打印成 0 并 exit 0——**假通过**。契约要求四个数组 + frames(实际采样点数),
|
|
1265
|
+
这里连 frames<=0 也拦(扫描 0 帧等于没查)。
|
|
1266
|
+
"""
|
|
1267
|
+
if not isinstance(result, dict):
|
|
1268
|
+
raise RuntimeError("页面 qaText 返回值不是对象:%r" % (result,))
|
|
1269
|
+
bad = [k for k in _QA_LIST_KEYS if not isinstance(result.get(k), list)]
|
|
1270
|
+
if bad:
|
|
1271
|
+
raise RuntimeError(
|
|
1272
|
+
"页面 qaText 返回值缺字段 / 类型不对:%s 必须是数组(页面契约见 "
|
|
1273
|
+
"references/web-engine.md §9;assets/web/qa_text.js 已按契约返回)。收到键:%s"
|
|
1274
|
+
% ("、".join(bad), sorted(result)[:12]))
|
|
1275
|
+
try:
|
|
1276
|
+
frames = int(result.get("frames"))
|
|
1277
|
+
except (TypeError, ValueError):
|
|
1278
|
+
frames = 0
|
|
1279
|
+
if frames <= 0:
|
|
1280
|
+
raise RuntimeError(
|
|
1281
|
+
"页面 qaText 返回 frames=%r(没有扫描任何采样点)。空扫描不能当「没有缺陷」通过;"
|
|
1282
|
+
"检查 qaText 的 t0/t1/step 参数与页面 DURATION 是否一致。" % (result.get("frames"),))
|
|
1283
|
+
return result
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def qa_text(html_path, t0=0.0, t1=None, step=0.2, deterministic=None, *,
|
|
1287
|
+
style=None, width=W, height=H, fps=None) -> dict:
|
|
1288
|
+
"""浏览器端文字质检:重叠 / 出画 / 一闪而过 / 从没露面(渲染期间会临时改页面状态)。
|
|
1289
|
+
|
|
1290
|
+
v0.8.0:跟着 style 一起检查——换了浅色主题/非 1080p 画布后,出画与重叠的判据必须用
|
|
1291
|
+
同一份画布尺寸(qa_text.js 读 FK.W / FK.H),否则"检查通过"只是没在检查新尺寸。
|
|
1292
|
+
|
|
1293
|
+
深审修复:参数与返回值 fail-closed(之前空扫描 / 扫不到的扫描 / 页面返回 {} 都是
|
|
1294
|
+
exit 0,门禁看着是绿的,其实什么都没查)。见 _check_qa_window / _check_qa_result。
|
|
1295
|
+
"""
|
|
738
1296
|
_setup_stdout()
|
|
739
|
-
|
|
1297
|
+
t0, t1, step = _check_qa_window(t0, t1, step)
|
|
1298
|
+
return asyncio.run(_qa_text_async(html_path, t0, t1, step, deterministic,
|
|
1299
|
+
style=style, width=width, height=height, fps=fps))
|
|
740
1300
|
|
|
741
1301
|
|
|
742
|
-
async def _qa_text_async(html_path, t0, t1, step, deterministic=None
|
|
1302
|
+
async def _qa_text_async(html_path, t0, t1, step, deterministic=None, *,
|
|
1303
|
+
style=None, width=W, height=H, fps=None) -> dict:
|
|
743
1304
|
url = _to_url(html_path)
|
|
744
1305
|
async with _playwright()() as pw:
|
|
745
|
-
browser, page, _cdp = await open_page(pw, url,
|
|
1306
|
+
browser, page, _cdp = await open_page(pw, url, width, height, deterministic=deterministic,
|
|
1307
|
+
style=style, fps=fps)
|
|
746
1308
|
try:
|
|
747
1309
|
if t1 is None:
|
|
748
1310
|
t1 = await page_number(page, "DURATION")
|
|
@@ -754,9 +1316,60 @@ async def _qa_text_async(html_path, t0, t1, step, deterministic=None) -> dict:
|
|
|
754
1316
|
await browser.close()
|
|
755
1317
|
except Exception:
|
|
756
1318
|
pass
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
1319
|
+
return _check_qa_result(result)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
# ---------------------------------------------------------------- 指定帧号截 PNG(v0.8.0)
|
|
1323
|
+
def render_frames(html_path, frames, out_dir=None, *, tag="", style=None, width=W, height=H,
|
|
1324
|
+
fps=None, deterministic=None) -> list:
|
|
1325
|
+
"""把**指定帧号**渲成 PNG,返回 [{"f","path","sha256","bytes","fps"}](不编码视频)。
|
|
1326
|
+
|
|
1327
|
+
这是 v0.8.0 给 render.py / 自检 / 人工验收用的最小确定性入口:
|
|
1328
|
+
帧号 → 时间 = f / FPS(与 render_full 同一口径),不存在"按播放时间截屏"。
|
|
1329
|
+
style=None 时不注入 __STYLE__(像素与 v0.7 一致);传 style 时整页跟随风格包。
|
|
1330
|
+
out_dir 缺省 ./frames;文件名 <tag>f<帧号:05d>.png。
|
|
1331
|
+
"""
|
|
1332
|
+
_setup_stdout()
|
|
1333
|
+
flist = [int(f) for f in frames]
|
|
1334
|
+
if not flist:
|
|
1335
|
+
raise ValueError("render_frames 至少需要 1 个帧号(frames 为空)")
|
|
1336
|
+
if any(f < 0 for f in flist):
|
|
1337
|
+
raise ValueError("帧号不能为负:%r" % (flist,))
|
|
1338
|
+
out = Path(out_dir).expanduser() if out_dir else (Path.cwd() / "frames")
|
|
1339
|
+
if not out.is_absolute():
|
|
1340
|
+
out = (Path.cwd() / out).resolve()
|
|
1341
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1342
|
+
return asyncio.run(_render_frames_async(html_path, flist, out, str(tag), style,
|
|
1343
|
+
width, height, fps, deterministic))
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
async def _render_frames_async(html_path, frames, out: Path, tag: str, style=None,
|
|
1347
|
+
width=W, height=H, fps=None, deterministic=None) -> list:
|
|
1348
|
+
url = _to_url(html_path)
|
|
1349
|
+
width, height = _validate_canvas(width, height)
|
|
1350
|
+
results = []
|
|
1351
|
+
async with _playwright()() as pw:
|
|
1352
|
+
browser, page, cdp = await open_page(pw, url, width, height, deterministic=deterministic,
|
|
1353
|
+
style=style, fps=fps)
|
|
1354
|
+
try:
|
|
1355
|
+
page_fps = await page_number(page, "FPS", default=FPS)
|
|
1356
|
+
for f in frames:
|
|
1357
|
+
png = await _grab(page, cdp, f)
|
|
1358
|
+
path = out / ("%sf%05d.png" % (tag, int(f)))
|
|
1359
|
+
path.write_bytes(png)
|
|
1360
|
+
results.append({
|
|
1361
|
+
"f": int(f),
|
|
1362
|
+
"path": str(path),
|
|
1363
|
+
"sha256": hashlib.sha256(png).hexdigest(),
|
|
1364
|
+
"bytes": len(png),
|
|
1365
|
+
"fps": page_fps,
|
|
1366
|
+
})
|
|
1367
|
+
finally:
|
|
1368
|
+
try:
|
|
1369
|
+
await browser.close()
|
|
1370
|
+
except Exception:
|
|
1371
|
+
pass
|
|
1372
|
+
return results
|
|
760
1373
|
|
|
761
1374
|
|
|
762
1375
|
# ---------------------------------------------------------------- 自检辅助
|
|
@@ -776,6 +1389,17 @@ def _png_stats(data: bytes) -> dict:
|
|
|
776
1389
|
return {"mean": round(mean, 2), "max": top, "w": im.width, "h": im.height}
|
|
777
1390
|
|
|
778
1391
|
|
|
1392
|
+
def _png_size(data):
|
|
1393
|
+
"""PNG 的像素尺寸 (w, h);不是 PNG / 空数据返回 None(自检断言用,不抛异常)。"""
|
|
1394
|
+
if not data:
|
|
1395
|
+
return None
|
|
1396
|
+
try:
|
|
1397
|
+
st = _png_stats(data)
|
|
1398
|
+
return (st["w"], st["h"])
|
|
1399
|
+
except Exception: # noqa: BLE001
|
|
1400
|
+
return None
|
|
1401
|
+
|
|
1402
|
+
|
|
779
1403
|
def _encode_pngs(pngs, dst, fps: float, crf: int = 17, preset: str = "slow") -> Path:
|
|
780
1404
|
"""把一组 PNG 字节直接喂 ffmpeg(自检里验证 image2pipe 链路)。"""
|
|
781
1405
|
proc = subprocess.Popen(
|
|
@@ -889,6 +1513,85 @@ async def _open_page_expect_error(html_path, ready_timeout=3000) -> str:
|
|
|
889
1513
|
raise RuntimeError("坏页面居然没有报错:%s" % html_path)
|
|
890
1514
|
|
|
891
1515
|
|
|
1516
|
+
# ---------------------------------------------------------------- 自检:风格注入(v0.8.0)
|
|
1517
|
+
# 自检用的主题故意选"浅色 + 衬线 + 右对齐 + 四角线框":与模板默认的深蓝橙 + 居中 + 无框
|
|
1518
|
+
# 在每一次可观测维度上都不同,任何一处没跟着 __STYLE__ 走都会暴露成"两张图一样"。
|
|
1519
|
+
_SELFTEST_STYLE = {
|
|
1520
|
+
"id": "selftest-light", "label": "自检浅色",
|
|
1521
|
+
"bg": (250, 246, 238), "bg2": (236, 228, 214), "surface": (255, 252, 246),
|
|
1522
|
+
"ink": (28, 26, 24), "muted": (88, 82, 74), "dim": (112, 104, 94),
|
|
1523
|
+
"accent": (176, 48, 40), "accent2": (38, 86, 128),
|
|
1524
|
+
"good": (30, 110, 72), "bad": (160, 36, 32), "rule": (28, 26, 24),
|
|
1525
|
+
"vignette": 0.10, "vignette_tint": (214, 204, 186),
|
|
1526
|
+
"grain": 3.0, "grain_freq": 0.27, "texture": "paper", "texture_alpha": 0.03,
|
|
1527
|
+
"family": "serif",
|
|
1528
|
+
"scale": {"display": (92, "bold"), "heading": (60, "bold")},
|
|
1529
|
+
"line_gap": 1.34, "tracking": 2.0,
|
|
1530
|
+
"layout": "split-right",
|
|
1531
|
+
"card": {"radius": 8, "fill_a": 26, "outline_a": 40, "pad": 30, "h": 92, "gap": 112},
|
|
1532
|
+
"safe_bias": -8,
|
|
1533
|
+
"ease": "out5", "entrance_dx": 18, "entrance_dy": 20,
|
|
1534
|
+
"push": 0.06, "item_stagger": 0.38, "transition": "wash",
|
|
1535
|
+
"hud": "corner-line",
|
|
1536
|
+
"qa": {"luma_max": 0.045, "flicker_thresh": 6.0, "dark": False},
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
async def _selftest_style_probe() -> dict:
|
|
1541
|
+
"""采集风格注入的三组证据(每个浏览器会话都独立,避免状态串味)。
|
|
1542
|
+
|
|
1543
|
+
① 无 style:页面里不应有 __STYLE__、画布 1920x1080、同帧两次 byte 相同;
|
|
1544
|
+
② 有 style:__STYLE__ / 字体栈 / 画布生效、同帧两次 byte 相同、与 ① 同帧不同图;
|
|
1545
|
+
③ 1280x720 + style:画布与截图像素尺寸跟着变(证明确实不是"写死 1080p 假装支持")。
|
|
1546
|
+
"""
|
|
1547
|
+
url = _to_url(DEFAULT_HTML)
|
|
1548
|
+
out = {}
|
|
1549
|
+
async with _playwright()() as pw:
|
|
1550
|
+
browser, page, cdp = await open_page(pw, url, W, H)
|
|
1551
|
+
try:
|
|
1552
|
+
out["plain_has_style"] = await page.evaluate(
|
|
1553
|
+
"() => typeof window.__STYLE__ !== 'undefined'")
|
|
1554
|
+
out["plain_canvas"] = await page.evaluate(
|
|
1555
|
+
"() => [document.getElementById('stage').width, document.getElementById('stage').height]")
|
|
1556
|
+
out["plain_a"] = await _grab(page, cdp, 45)
|
|
1557
|
+
out["plain_b"] = await _grab(page, cdp, 45)
|
|
1558
|
+
finally:
|
|
1559
|
+
try:
|
|
1560
|
+
await browser.close()
|
|
1561
|
+
except Exception:
|
|
1562
|
+
pass
|
|
1563
|
+
|
|
1564
|
+
browser, page, cdp = await open_page(pw, url, W, H, style=_SELFTEST_STYLE)
|
|
1565
|
+
try:
|
|
1566
|
+
out["has_style"] = await page.evaluate(
|
|
1567
|
+
"() => typeof window.__STYLE__ === 'object' && window.__STYLE__ !== null")
|
|
1568
|
+
out["style_ink"] = await page.evaluate(
|
|
1569
|
+
"() => window.__STYLE__ && window.__STYLE__.theme ? window.__STYLE__.theme.ink : null")
|
|
1570
|
+
out["style_family"] = await page.evaluate(
|
|
1571
|
+
"() => window.FK ? window.FK.FAMILY : null")
|
|
1572
|
+
out["style_canvas"] = await page.evaluate(
|
|
1573
|
+
"() => [document.getElementById('stage').width, document.getElementById('stage').height]")
|
|
1574
|
+
out["style_a"] = await _grab(page, cdp, 45)
|
|
1575
|
+
out["style_b"] = await _grab(page, cdp, 45)
|
|
1576
|
+
finally:
|
|
1577
|
+
try:
|
|
1578
|
+
await browser.close()
|
|
1579
|
+
except Exception:
|
|
1580
|
+
pass
|
|
1581
|
+
|
|
1582
|
+
browser, page, cdp = await open_page(pw, url, 1280, 720, style=_SELFTEST_STYLE)
|
|
1583
|
+
try:
|
|
1584
|
+
out["small_canvas"] = await page.evaluate(
|
|
1585
|
+
"() => [document.getElementById('stage').width, document.getElementById('stage').height]")
|
|
1586
|
+
out["small_png"] = await _grab(page, cdp, 45)
|
|
1587
|
+
finally:
|
|
1588
|
+
try:
|
|
1589
|
+
await browser.close()
|
|
1590
|
+
except Exception:
|
|
1591
|
+
pass
|
|
1592
|
+
return out
|
|
1593
|
+
|
|
1594
|
+
|
|
892
1595
|
# ---------------------------------------------------------------- 自检
|
|
893
1596
|
def selftest() -> int:
|
|
894
1597
|
"""不联网自检:页面契约、确定性(同帧截图 byte 相同)、cue 递增、文字质检、编码链路。"""
|
|
@@ -902,7 +1605,7 @@ def selftest() -> int:
|
|
|
902
1605
|
print(("[PASS] " if ok else "[FAIL] ") + title + ((" —— " + detail) if detail else ""), flush=True)
|
|
903
1606
|
|
|
904
1607
|
print("=" * 78)
|
|
905
|
-
print("webengine.py --selftest(v0.
|
|
1608
|
+
print("webengine.py --selftest(v0.8.0):无头浏览器逐帧渲染 / 确定性 / cue / 文字质检 / 风格注入")
|
|
906
1609
|
print("=" * 78)
|
|
907
1610
|
|
|
908
1611
|
names = ("index.html", "engine.js", "film.js", "style.css", "qa_text.js")
|
|
@@ -1089,6 +1792,213 @@ def selftest() -> int:
|
|
|
1089
1792
|
check("不同帧渲染结果不同", bool(probe.get("diff_frames")),
|
|
1090
1793
|
"f=45 与 f=46 的 PNG 不同" if probe.get("diff_frames") else (perr or "相邻帧截图相同(时间码没变?)"))
|
|
1091
1794
|
|
|
1795
|
+
# ---- v0.8.0:画布尺寸 / 风格注入 ----
|
|
1796
|
+
good_sizes, bad_sizes, portrait_msg = [], [], ""
|
|
1797
|
+
for w_, h_ in ((1920, 1080), (1280, 720), (3840, 2160)):
|
|
1798
|
+
try:
|
|
1799
|
+
_validate_canvas(w_, h_)
|
|
1800
|
+
good_sizes.append("%dx%d" % (w_, h_))
|
|
1801
|
+
except Exception as exc: # noqa: BLE001
|
|
1802
|
+
good_sizes.append("意外拒绝 %dx%d(%s)" % (w_, h_, exc))
|
|
1803
|
+
# 竖屏单独测:报错必须点名"竖屏"(用户最常踩的就是 9:16),不能被后面的用例覆盖
|
|
1804
|
+
try:
|
|
1805
|
+
_validate_canvas(1080, 1920)
|
|
1806
|
+
bad_sizes.append("1080x1920 没被拒")
|
|
1807
|
+
except ValueError as exc:
|
|
1808
|
+
if "竖屏" not in str(exc):
|
|
1809
|
+
bad_sizes.append("竖屏报错没点名竖屏:%s" % str(exc)[:80])
|
|
1810
|
+
else:
|
|
1811
|
+
portrait_msg = str(exc)
|
|
1812
|
+
for w_, h_ in ((1920, 1200), (1921, 1080), (0, 1080)):
|
|
1813
|
+
try:
|
|
1814
|
+
_validate_canvas(w_, h_)
|
|
1815
|
+
bad_sizes.append("%dx%d 没被拒" % (w_, h_))
|
|
1816
|
+
except ValueError:
|
|
1817
|
+
pass
|
|
1818
|
+
check("画布只收 16:9 横屏偶数尺寸(竖屏/4:3/奇数/非正数 fail-closed)",
|
|
1819
|
+
(not bad_sizes) and len(good_sizes) == 3 and "竖屏" in portrait_msg,
|
|
1820
|
+
";".join(bad_sizes) or ("合法:%s;竖屏报错点名竖屏" % "、".join(good_sizes)))
|
|
1821
|
+
|
|
1822
|
+
# ---- 深审:确定性开关三态 / 文字质检参数与返回值 fail-closed ----
|
|
1823
|
+
# 这三条都是纯函数校验(不开浏览器),专门守"门禁看着干净、其实没在查"的三条历史路径:
|
|
1824
|
+
# · CLI --deterministic 用 store_true 时默认 False 会压掉 WEBENGINE_DETERMINISTIC;
|
|
1825
|
+
# · textqa 空扫描(t1<t0)/ 步长 >= brief 阈值时返回全 0 并 exit 0;
|
|
1826
|
+
# · 页面 qaText 返回 {} / frames=0 时同样被当成"零缺陷"。
|
|
1827
|
+
det_lines = []
|
|
1828
|
+
det_args = _build_parser().parse_args(["frames", "x.html", "--frames", "1"])
|
|
1829
|
+
if det_args.deterministic is not None:
|
|
1830
|
+
det_lines.append("未传 --deterministic 时默认值是 %r(应为 None,才能读环境变量)"
|
|
1831
|
+
% (det_args.deterministic,))
|
|
1832
|
+
det_on = _build_parser().parse_args(["frames", "x.html", "--frames", "1", "--deterministic"])
|
|
1833
|
+
if det_on.deterministic is not True:
|
|
1834
|
+
det_lines.append("显式 --deterministic 没变成 True(%r)" % (det_on.deterministic,))
|
|
1835
|
+
old_env = os.environ.get("WEBENGINE_DETERMINISTIC")
|
|
1836
|
+
try:
|
|
1837
|
+
os.environ["WEBENGINE_DETERMINISTIC"] = "1"
|
|
1838
|
+
env_ok = bool(_use_deterministic(None))
|
|
1839
|
+
cli_env_ok = "--disable-gpu" in browser_args(det_args.deterministic)
|
|
1840
|
+
force_off = "--disable-gpu" in browser_args(False)
|
|
1841
|
+
finally:
|
|
1842
|
+
if old_env is None:
|
|
1843
|
+
os.environ.pop("WEBENGINE_DETERMINISTIC", None)
|
|
1844
|
+
else:
|
|
1845
|
+
os.environ["WEBENGINE_DETERMINISTIC"] = old_env
|
|
1846
|
+
if not env_ok:
|
|
1847
|
+
det_lines.append("WEBENGINE_DETERMINISTIC=1 时 _use_deterministic(None) 不是 True")
|
|
1848
|
+
if not cli_env_ok:
|
|
1849
|
+
det_lines.append("CLI 未传 --deterministic 时环境变量没生效")
|
|
1850
|
+
if force_off:
|
|
1851
|
+
det_lines.append("显式 deterministic=False 仍然加了 --disable-gpu(三态语义坏了)")
|
|
1852
|
+
check("确定性开关三态(CLI --deterministic 未传时尊重 WEBENGINE_DETERMINISTIC)",
|
|
1853
|
+
not det_lines, ";".join(det_lines) or "None=读环境变量 / True=软件光栅 / False=明确关")
|
|
1854
|
+
|
|
1855
|
+
qa_lines = []
|
|
1856
|
+
for label, args_ in (("t1<t0", (2.0, 1.0, 0.2)),
|
|
1857
|
+
("step=0", (0.0, 2.0, 0.0)),
|
|
1858
|
+
("step<0", (0.0, 2.0, -0.1)),
|
|
1859
|
+
("step=brief 阈值 0.6", (0.0, 2.0, 0.6)),
|
|
1860
|
+
("step=1.0", (0.0, 2.0, 1.0))):
|
|
1861
|
+
try:
|
|
1862
|
+
_check_qa_window(*args_)
|
|
1863
|
+
qa_lines.append("%s 没被拒(会退化成空扫描/永远报不出 brief)" % label)
|
|
1864
|
+
except ValueError:
|
|
1865
|
+
pass
|
|
1866
|
+
ok_args = True
|
|
1867
|
+
try:
|
|
1868
|
+
a_, b_, s_ = _check_qa_window(0.0, 2.0, 0.2)
|
|
1869
|
+
ok_args = (a_, b_, s_) == (0.0, 2.0, 0.2)
|
|
1870
|
+
# t1=None 表示"到页面 DURATION",必须放行(由 _qa_text_async 现读)
|
|
1871
|
+
_check_qa_window(0.0, None, 0.2)
|
|
1872
|
+
except Exception as exc: # noqa: BLE001
|
|
1873
|
+
ok_args = False
|
|
1874
|
+
qa_lines.append("合法参数被拒:%s" % exc)
|
|
1875
|
+
check("文字质检参数 fail-closed(空扫描 / 无效步长不再 exit 0)",
|
|
1876
|
+
ok_args and not qa_lines,
|
|
1877
|
+
";".join(qa_lines) or "t1<t0 / step<=0 / step>=0.6 全拒;t1=None、step=0.2 放行")
|
|
1878
|
+
|
|
1879
|
+
res_lines = []
|
|
1880
|
+
for label, bad in (("{}", {}),
|
|
1881
|
+
("缺 never", {"overlaps": [], "offscreen": [], "brief": [], "frames": 3}),
|
|
1882
|
+
("frames=0", {"overlaps": [], "offscreen": [], "brief": [],
|
|
1883
|
+
"never": [], "frames": 0}),
|
|
1884
|
+
("overlaps 不是数组", {"overlaps": 0, "offscreen": [], "brief": [],
|
|
1885
|
+
"never": [], "frames": 3})):
|
|
1886
|
+
try:
|
|
1887
|
+
_check_qa_result(bad)
|
|
1888
|
+
res_lines.append("%s 没被拒(会被当成零缺陷 PASS)" % label)
|
|
1889
|
+
except RuntimeError:
|
|
1890
|
+
pass
|
|
1891
|
+
try:
|
|
1892
|
+
good_res = _check_qa_result({"overlaps": [], "offscreen": [], "brief": [],
|
|
1893
|
+
"never": ["x"], "frames": 5})
|
|
1894
|
+
if good_res.get("frames") != 5:
|
|
1895
|
+
res_lines.append("合法返回值被改坏")
|
|
1896
|
+
except Exception as exc: # noqa: BLE001
|
|
1897
|
+
res_lines.append("合法返回值被拒:%s" % exc)
|
|
1898
|
+
check("页面 qaText 返回值形状 fail-closed(缺字段 / frames=0 不再当 PASS)",
|
|
1899
|
+
not res_lines, ";".join(res_lines) or "{} / 缺字段 / frames=0 / 类型不对 全拒;正常返回值放行")
|
|
1900
|
+
|
|
1901
|
+
# cue / event 时间必须是有限数:NaN / Infinity 会被 json.dumps 写成非法 JSON 字面量,
|
|
1902
|
+
# 再被下游 json.load 照单收下 —— 时间轴静默错位,且看不出是哪条提示点。
|
|
1903
|
+
t_lines = []
|
|
1904
|
+
try:
|
|
1905
|
+
ok_cues = _norm_cue_list([{"t": 1.0, "type": "ui_blip"}], "web")
|
|
1906
|
+
if ok_cues[0]["t"] != 1.0 or ok_cues[0]["src"] != "web":
|
|
1907
|
+
t_lines.append("合法 cue 被改坏:%r" % (ok_cues,))
|
|
1908
|
+
ok_ev = _norm_events([{"t": 1.0, "kind": "flash"}])
|
|
1909
|
+
if ok_ev[0]["t"] != 1.0:
|
|
1910
|
+
t_lines.append("合法 event 被改坏:%r" % (ok_ev,))
|
|
1911
|
+
except Exception as exc: # noqa: BLE001
|
|
1912
|
+
t_lines.append("合法 cue/event 被拒:%s" % exc)
|
|
1913
|
+
for label, bad_cue in (("NaN", {"t": float("nan"), "type": "ui_blip"}),
|
|
1914
|
+
("Infinity", {"t": float("inf"), "type": "ui_blip"}),
|
|
1915
|
+
("字符串", {"t": "abc", "type": "ui_blip"})):
|
|
1916
|
+
try:
|
|
1917
|
+
_norm_cue_list([bad_cue], "web")
|
|
1918
|
+
t_lines.append("cue t=%s 没被拒(会写出非法 JSON)" % label)
|
|
1919
|
+
except ValueError:
|
|
1920
|
+
pass
|
|
1921
|
+
for label, bad_ev in (("NaN", {"t": float("nan"), "kind": "flash"}),
|
|
1922
|
+
("Infinity", {"t": float("inf"), "kind": "flash"})):
|
|
1923
|
+
try:
|
|
1924
|
+
_norm_events([bad_ev])
|
|
1925
|
+
t_lines.append("event t=%s 没被拒" % label)
|
|
1926
|
+
except ValueError:
|
|
1927
|
+
pass
|
|
1928
|
+
check("cue / 视觉事件时间只收有限数(NaN / Infinity 不再写进 cues.json)",
|
|
1929
|
+
not t_lines, ";".join(t_lines) or "1.0 放行(src 补齐);NaN / Infinity / 字符串 全拒")
|
|
1930
|
+
|
|
1931
|
+
style_norm, style_lines = {}, []
|
|
1932
|
+
try:
|
|
1933
|
+
style_norm["none"] = normalize_style(None)
|
|
1934
|
+
except Exception as exc: # noqa: BLE001
|
|
1935
|
+
style_lines.append("None 报错:%s" % exc)
|
|
1936
|
+
try:
|
|
1937
|
+
style_norm["plan"] = normalize_style({
|
|
1938
|
+
"style": "selftest", "style_name": "自检",
|
|
1939
|
+
"theme": dict(_SELFTEST_STYLE), "layout_default": "split-right"})
|
|
1940
|
+
except Exception as exc: # noqa: BLE001
|
|
1941
|
+
style_lines.append("style plan 报错:%s" % exc)
|
|
1942
|
+
try:
|
|
1943
|
+
style_norm["theme"] = normalize_style(dict(_SELFTEST_STYLE))
|
|
1944
|
+
except Exception as exc: # noqa: BLE001
|
|
1945
|
+
style_lines.append("裸主题报错:%s" % exc)
|
|
1946
|
+
for label, bad in (("非 dict", "abc"),
|
|
1947
|
+
("缺色键", {"bg": (1, 2, 3), "ink": (255, 255, 255)}),
|
|
1948
|
+
("颜色越界", dict(_SELFTEST_STYLE, accent=(999, 0, 0))),
|
|
1949
|
+
("未知字体族", dict(_SELFTEST_STYLE, family="comic"))):
|
|
1950
|
+
try:
|
|
1951
|
+
normalize_style(bad)
|
|
1952
|
+
style_lines.append("%s 没被拒" % label)
|
|
1953
|
+
except (ValueError, TypeError):
|
|
1954
|
+
pass
|
|
1955
|
+
plan = style_norm.get("plan") or {}
|
|
1956
|
+
plan_ok = (plan.get("theme", {}).get("ink") == "#1c1a18"
|
|
1957
|
+
and plan.get("typography", {}).get("family") == "serif"
|
|
1958
|
+
and plan.get("layout", {}).get("default") == "split-right")
|
|
1959
|
+
same_sha = bool(plan) and style_sha256(plan) == style_sha256(normalize_style(
|
|
1960
|
+
{"style": "selftest", "style_name": "自检",
|
|
1961
|
+
"theme": dict(_SELFTEST_STYLE), "layout_default": "split-right"}))
|
|
1962
|
+
check("style=None 不注入;plan / 裸主题两种写法都归一;非法 style fail-closed",
|
|
1963
|
+
style_norm.get("none") is None and plan_ok and same_sha and not style_lines,
|
|
1964
|
+
";".join(style_lines) or "None→None;ink=#1c1a18;family=serif;layout=split-right;非法 4 例全 raise")
|
|
1965
|
+
|
|
1966
|
+
style_probe = {}
|
|
1967
|
+
try:
|
|
1968
|
+
style_probe = asyncio.run(_selftest_style_probe())
|
|
1969
|
+
except Exception as exc: # noqa: BLE001
|
|
1970
|
+
style_probe = {"_error": "%s: %s" % (type(exc).__name__, str(exc)[:300])}
|
|
1971
|
+
serr = style_probe.get("_error")
|
|
1972
|
+
plain_a = style_probe.get("plain_a") or b""
|
|
1973
|
+
styled_a = style_probe.get("style_a") or b""
|
|
1974
|
+
check("style=None 时页面无 window.__STYLE__、画布 1920x1080(旧行为零注入)",
|
|
1975
|
+
(not serr) and style_probe.get("plain_has_style") is False
|
|
1976
|
+
and style_probe.get("plain_canvas") == [W, H],
|
|
1977
|
+
serr or "页面里没有 __STYLE__;画布 %s" % (style_probe.get("plain_canvas"),))
|
|
1978
|
+
check("注入 style 后 __STYLE__ / 字体栈 / 画布生效",
|
|
1979
|
+
(not serr) and bool(style_probe.get("has_style"))
|
|
1980
|
+
and style_probe.get("style_ink") == "#1c1a18"
|
|
1981
|
+
and style_probe.get("style_family") == FONT_STACKS["serif"]
|
|
1982
|
+
and style_probe.get("style_canvas") == [W, H],
|
|
1983
|
+
serr or ("theme.ink=%s;FK.FAMILY=衬线栈;画布 %s"
|
|
1984
|
+
% (style_probe.get("style_ink"), style_probe.get("style_canvas"))))
|
|
1985
|
+
check("有 style 时同一帧两次截图 byte 相同(确定性不被风格破坏)",
|
|
1986
|
+
bool(styled_a) and styled_a == style_probe.get("style_b"),
|
|
1987
|
+
serr or ("两次均 %d bytes,sha256=%s…"
|
|
1988
|
+
% (len(styled_a), hashlib.sha256(styled_a).hexdigest()[:16])))
|
|
1989
|
+
mean_plain = _png_stats(plain_a)["mean"] if plain_a else 0.0
|
|
1990
|
+
mean_style = _png_stats(styled_a)["mean"] if styled_a else 0.0
|
|
1991
|
+
check("有 style 与无 style 同一帧 PNG 不同(确实换了画风)",
|
|
1992
|
+
bool(plain_a) and bool(styled_a) and plain_a != styled_a
|
|
1993
|
+
and abs(mean_style - mean_plain) > 8.0,
|
|
1994
|
+
serr or ("f=45 灰度均值 %.1f → %.1f(深蓝橙 → 浅色纸感)" % (mean_plain, mean_style)))
|
|
1995
|
+
check("1280x720 + style 时画布与截图像素尺寸都跟着变",
|
|
1996
|
+
(not serr) and style_probe.get("small_canvas") == [1280, 720]
|
|
1997
|
+
and _png_size(style_probe.get("small_png")) == (1280, 720),
|
|
1998
|
+
serr or ("canvas=%s,PNG=%s"
|
|
1999
|
+
% (style_probe.get("small_canvas"), _png_size(style_probe.get("small_png")))))
|
|
2000
|
+
|
|
2001
|
+
|
|
1092
2002
|
cues = probe.get("cues") or []
|
|
1093
2003
|
ts = [float(c.get("t", -1)) for c in cues]
|
|
1094
2004
|
monotonic = len(ts) >= 2 and all(b > a for a, b in zip(ts, ts[1:]))
|
|
@@ -1235,10 +2145,69 @@ def _parse_times(text: str) -> list:
|
|
|
1235
2145
|
return out
|
|
1236
2146
|
|
|
1237
2147
|
|
|
2148
|
+
def _parse_frames(text: str) -> list:
|
|
2149
|
+
"""解析 --frames 45,270(帧号,整数,逗号分隔);非法项给可操作报错。"""
|
|
2150
|
+
out = []
|
|
2151
|
+
for part in str(text or "").split(","):
|
|
2152
|
+
part = part.strip()
|
|
2153
|
+
if not part:
|
|
2154
|
+
continue
|
|
2155
|
+
try:
|
|
2156
|
+
out.append(int(part))
|
|
2157
|
+
except ValueError:
|
|
2158
|
+
raise ValueError("--frames 里有不是整数的项:%r(要逗号分隔的帧号,如 45,270)" % (part,))
|
|
2159
|
+
return out
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def _resolve_style_spec(text):
|
|
2163
|
+
"""解析 --style 的三种写法:内联 JSON / JSON 文件路径 / stylekit 风格 id。
|
|
2164
|
+
|
|
2165
|
+
返回 normalize_style 能吃的 dict;空字符串返回 None(= 不注入,旧行为)。
|
|
2166
|
+
解析失败一律 SystemExit(CLI 场景,带可操作提示),不做静默回落。
|
|
2167
|
+
"""
|
|
2168
|
+
s = str(text or "").strip()
|
|
2169
|
+
if not s:
|
|
2170
|
+
return None
|
|
2171
|
+
if s.startswith("{"):
|
|
2172
|
+
try:
|
|
2173
|
+
return json.loads(s)
|
|
2174
|
+
except json.JSONDecodeError as exc:
|
|
2175
|
+
raise SystemExit("[错误] --style 的内联 JSON 解析失败:%s" % exc)
|
|
2176
|
+
p = Path(s).expanduser()
|
|
2177
|
+
if p.is_file():
|
|
2178
|
+
try:
|
|
2179
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
2180
|
+
except json.JSONDecodeError as exc:
|
|
2181
|
+
raise SystemExit("[错误] --style 的 JSON 文件 %s 解析失败:%s" % (p, exc))
|
|
2182
|
+
try:
|
|
2183
|
+
import stylekit as ST
|
|
2184
|
+
except Exception as exc: # noqa: BLE001
|
|
2185
|
+
raise SystemExit("[错误] --style %r 被当成风格 id,但 stylekit.py 不在同目录(%s)。"
|
|
2186
|
+
"要么用 --style work/style.json,要么把 stylekit.py 放在脚本旁边。"
|
|
2187
|
+
% (s, exc))
|
|
2188
|
+
try:
|
|
2189
|
+
pack = ST.get(s)
|
|
2190
|
+
except KeyError as exc:
|
|
2191
|
+
raise SystemExit("[错误] %s" % (exc.args[0] if exc.args else exc))
|
|
2192
|
+
return {"style": s, "style_name": pack.get("name"), "legacy": ST.is_legacy(s),
|
|
2193
|
+
"theme": ST.theme_of(s), "layout_default": (pack.get("layouts") or ("left-stack",))[0]}
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
def _add_style_args(p, *, fps=True):
|
|
2197
|
+
"""给子命令加风格参数(缺省 = v0.7 旧行为:不注入、1920x1080)。"""
|
|
2198
|
+
p.add_argument("--style", default="",
|
|
2199
|
+
help="风格:stylekit 风格 id(如 magazine)/ work/style.json / 内联 JSON;缺省不注入")
|
|
2200
|
+
p.add_argument("--width", type=int, default=W, help="画布宽(只收 16:9 横屏偶数,默认 %d)" % W)
|
|
2201
|
+
p.add_argument("--height", type=int, default=H, help="画布高(默认 %d)" % H)
|
|
2202
|
+
if fps:
|
|
2203
|
+
p.add_argument("--fps", type=float, default=None,
|
|
2204
|
+
help="写进 __STYLE__.canvas.fps 的帧率(缺省不约束,页面 window.FPS 为准)")
|
|
2205
|
+
|
|
2206
|
+
|
|
1238
2207
|
def _build_parser() -> argparse.ArgumentParser:
|
|
1239
2208
|
ap = argparse.ArgumentParser(
|
|
1240
2209
|
prog="webengine.py",
|
|
1241
|
-
description="浏览器逐帧渲染引擎(v0.
|
|
2210
|
+
description="浏览器逐帧渲染引擎(v0.8.0):Canvas 页面 → CDP 截图 → ffmpeg → mp4(支持风格包注入)",
|
|
1242
2211
|
)
|
|
1243
2212
|
ap.add_argument("--selftest", action="store_true", help="跑自检(不联网,≤60 秒)")
|
|
1244
2213
|
sub = ap.add_subparsers(dest="cmd")
|
|
@@ -1251,13 +2220,23 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1251
2220
|
s.add_argument("--to", dest="t_to", type=float)
|
|
1252
2221
|
s.add_argument("--out", default="", help="输出目录(缺省 ./snaps)")
|
|
1253
2222
|
s.add_argument("--tag", default="", help="文件名前缀")
|
|
1254
|
-
s.add_argument("--deterministic", action="
|
|
2223
|
+
s.add_argument("--deterministic", action="store_const", const=True, default=None,
|
|
1255
2224
|
help="软件光栅(跨浏览器会话 byte 级可复现,慢约 20%%)")
|
|
2225
|
+
_add_style_args(s)
|
|
2226
|
+
|
|
2227
|
+
fr = sub.add_parser("frames", help="指定帧号截 PNG(打印 SHA-256,用于像素回归对比)")
|
|
2228
|
+
fr.add_argument("html", nargs="?", default=str(DEFAULT_HTML))
|
|
2229
|
+
fr.add_argument("--frames", required=True, help="逗号分隔的帧号,如 45,270")
|
|
2230
|
+
fr.add_argument("--out", default="", help="输出目录(缺省 ./frames)")
|
|
2231
|
+
fr.add_argument("--tag", default="", help="文件名前缀")
|
|
2232
|
+
fr.add_argument("--deterministic", action="store_const", const=True, default=None)
|
|
2233
|
+
_add_style_args(fr)
|
|
1256
2234
|
|
|
1257
2235
|
c = sub.add_parser("cues", help="导出 cue / 视觉事件(契约 §4.2 schema)")
|
|
1258
2236
|
c.add_argument("html", nargs="?", default=str(DEFAULT_HTML))
|
|
1259
2237
|
c.add_argument("--out", default="", help="写到 JSON 文件;缺省打到标准输出")
|
|
1260
|
-
c.add_argument("--deterministic", action="
|
|
2238
|
+
c.add_argument("--deterministic", action="store_const", const=True, default=None)
|
|
2239
|
+
_add_style_args(c, fps=False)
|
|
1261
2240
|
|
|
1262
2241
|
f = sub.add_parser("full", help="并行渲染整片(分块 + 断点续跑 + 缺口检测)")
|
|
1263
2242
|
f.add_argument("html", nargs="?", default=str(DEFAULT_HTML))
|
|
@@ -1271,16 +2250,19 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1271
2250
|
f.add_argument("--to", dest="t_to", type=float, default=None, help="结束秒(缺省到片尾)")
|
|
1272
2251
|
f.add_argument("--crf", type=int, default=17)
|
|
1273
2252
|
f.add_argument("--preset", default="slow")
|
|
1274
|
-
f.add_argument("--deterministic", action="
|
|
2253
|
+
f.add_argument("--deterministic", action="store_const", const=True, default=None,
|
|
1275
2254
|
help="软件光栅(跨浏览器会话 byte 级可复现,慢约 20%%)")
|
|
2255
|
+
_add_style_args(f)
|
|
1276
2256
|
|
|
1277
2257
|
q = sub.add_parser("textqa", help="浏览器端文字质检(重叠 / 出画 / 一闪而过 / 从没露面)")
|
|
1278
2258
|
q.add_argument("html", nargs="?", default=str(DEFAULT_HTML))
|
|
1279
2259
|
q.add_argument("--from", dest="t0", type=float, default=0.0)
|
|
1280
2260
|
q.add_argument("--to", dest="t1", type=float, default=None)
|
|
1281
|
-
q.add_argument("--step", type=float, default=0.2
|
|
2261
|
+
q.add_argument("--step", type=float, default=0.2,
|
|
2262
|
+
help="采样步长秒;必须 < 0.6(一闪而过的判据阈值),默认 0.2")
|
|
1282
2263
|
q.add_argument("--out", default="", help="把完整结果写到 JSON")
|
|
1283
|
-
q.add_argument("--deterministic", action="
|
|
2264
|
+
q.add_argument("--deterministic", action="store_const", const=True, default=None)
|
|
2265
|
+
_add_style_args(q)
|
|
1284
2266
|
|
|
1285
2267
|
sub.add_parser("selftest", help="跑自检(等价于 --selftest)")
|
|
1286
2268
|
return ap
|
|
@@ -1304,6 +2286,16 @@ def main(argv=None) -> int:
|
|
|
1304
2286
|
_build_parser().print_help()
|
|
1305
2287
|
return 2
|
|
1306
2288
|
|
|
2289
|
+
# --style 统一在分支之前解析:内联 JSON / JSON 文件 / 风格 id 三种写法,解析失败算用法
|
|
2290
|
+
# 错误(exit 2)。以前每个分支各自解析,坏 id 会抛 SystemExit 变成 exit 1,脚本里分不清
|
|
2291
|
+
# "参数写错"和"渲染失败"。
|
|
2292
|
+
try:
|
|
2293
|
+
style_spec = _resolve_style_spec(getattr(args, "style", ""))
|
|
2294
|
+
except SystemExit as exc:
|
|
2295
|
+
msg = exc.code if isinstance(exc.code, str) else ("[错误] %s" % (exc.code,))
|
|
2296
|
+
print(msg, flush=True)
|
|
2297
|
+
return 2
|
|
2298
|
+
|
|
1307
2299
|
if args.cmd == "snap":
|
|
1308
2300
|
try:
|
|
1309
2301
|
times = _parse_times(args.times)
|
|
@@ -1318,7 +2310,9 @@ def main(argv=None) -> int:
|
|
|
1318
2310
|
flush=True)
|
|
1319
2311
|
return 2
|
|
1320
2312
|
if args.every:
|
|
1321
|
-
end = args.t_to if args.t_to is not None else page_meta(
|
|
2313
|
+
end = args.t_to if args.t_to is not None else page_meta(
|
|
2314
|
+
args.html, style=style_spec, width=args.width, height=args.height,
|
|
2315
|
+
fps=args.fps)[0]
|
|
1322
2316
|
k = float(args.t_from)
|
|
1323
2317
|
while k <= end + 1e-9:
|
|
1324
2318
|
times.append(round(k, 3))
|
|
@@ -1327,11 +2321,49 @@ def main(argv=None) -> int:
|
|
|
1327
2321
|
print("[错误] snap 需要 --times 3,9.5 或 --every 2 [--to 12]", flush=True)
|
|
1328
2322
|
return 2
|
|
1329
2323
|
out_dir = args.out or str(Path.cwd() / "snaps")
|
|
1330
|
-
|
|
2324
|
+
try:
|
|
2325
|
+
snap(args.html, times, out_dir, tag=args.tag, deterministic=args.deterministic,
|
|
2326
|
+
style=style_spec, width=args.width, height=args.height, fps=args.fps)
|
|
2327
|
+
except (ValueError, TypeError) as exc:
|
|
2328
|
+
print("[错误] %s" % exc, flush=True)
|
|
2329
|
+
return 2
|
|
2330
|
+
return 0
|
|
2331
|
+
|
|
2332
|
+
if args.cmd == "frames":
|
|
2333
|
+
try:
|
|
2334
|
+
frames = _parse_frames(args.frames)
|
|
2335
|
+
except ValueError as exc:
|
|
2336
|
+
print("[错误] %s" % exc, flush=True)
|
|
2337
|
+
return 2
|
|
2338
|
+
if not frames:
|
|
2339
|
+
print("[错误] frames 需要 --frames 45,270(逗号分隔的帧号)", flush=True)
|
|
2340
|
+
return 2
|
|
2341
|
+
out_dir = args.out or str(Path.cwd() / "frames")
|
|
2342
|
+
try:
|
|
2343
|
+
results = render_frames(args.html, frames, out_dir, tag=args.tag,
|
|
2344
|
+
style=style_spec, width=args.width, height=args.height,
|
|
2345
|
+
fps=args.fps, deterministic=args.deterministic)
|
|
2346
|
+
except (ValueError, TypeError) as exc:
|
|
2347
|
+
print("[错误] %s" % exc, flush=True)
|
|
2348
|
+
return 2
|
|
2349
|
+
for r in results:
|
|
2350
|
+
print("[frames] f=%d %s sha256=%s %d bytes → %s"
|
|
2351
|
+
% (r["f"], "fps=%g" % r["fps"], r["sha256"], r["bytes"], r["path"]), flush=True)
|
|
1331
2352
|
return 0
|
|
1332
2353
|
|
|
1333
2354
|
if args.cmd == "cues":
|
|
1334
|
-
|
|
2355
|
+
try:
|
|
2356
|
+
data = export_cues(args.html, deterministic=args.deterministic, style=style_spec,
|
|
2357
|
+
width=args.width, height=args.height)
|
|
2358
|
+
except (ValueError, TypeError) as exc:
|
|
2359
|
+
print("[错误] %s" % exc, flush=True)
|
|
2360
|
+
return 2
|
|
2361
|
+
except RuntimeError as exc:
|
|
2362
|
+
# 页面契约 / 浏览器起不来这类错误(缺 getCues、ready 超时、缺 playwright…):
|
|
2363
|
+
# 以前直接抛 traceback(exit 1,且报错淹没在栈里)。这是"要改页面/环境"的用法
|
|
2364
|
+
# 错误,和 --style 写坏一样给 exit 2 + 一行可读原因。
|
|
2365
|
+
print("[错误] %s" % exc, flush=True)
|
|
2366
|
+
return 2
|
|
1335
2367
|
if args.out:
|
|
1336
2368
|
dst = _json_dump(data, args.out)
|
|
1337
2369
|
print("[cues] %d 个 cue、%d 个视觉事件 → %s"
|
|
@@ -1341,18 +2373,33 @@ def main(argv=None) -> int:
|
|
|
1341
2373
|
return 0
|
|
1342
2374
|
|
|
1343
2375
|
if args.cmd == "full":
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
2376
|
+
try:
|
|
2377
|
+
out = render_full(
|
|
2378
|
+
args.html, args.out, args.duration,
|
|
2379
|
+
workers=args.workers, chunk_s=args.chunk_s, resume=args.resume,
|
|
2380
|
+
from_s=args.t_from, to_s=args.t_to, crf=args.crf, preset=args.preset,
|
|
2381
|
+
deterministic=args.deterministic,
|
|
2382
|
+
style=style_spec, width=args.width, height=args.height, fps=args.fps,
|
|
2383
|
+
)
|
|
2384
|
+
except (ValueError, TypeError) as exc:
|
|
2385
|
+
print("[错误] %s" % exc, flush=True)
|
|
2386
|
+
return 2
|
|
1350
2387
|
print("[done] %s" % out, flush=True)
|
|
1351
2388
|
return 0
|
|
1352
2389
|
|
|
1353
2390
|
if args.cmd == "textqa":
|
|
1354
|
-
|
|
1355
|
-
|
|
2391
|
+
try:
|
|
2392
|
+
result = qa_text(args.html, args.t0, args.t1, args.step,
|
|
2393
|
+
deterministic=args.deterministic, style=style_spec,
|
|
2394
|
+
width=args.width, height=args.height, fps=args.fps)
|
|
2395
|
+
except (ValueError, TypeError) as exc:
|
|
2396
|
+
print("[错误] %s" % exc, flush=True)
|
|
2397
|
+
return 2
|
|
2398
|
+
except RuntimeError as exc:
|
|
2399
|
+
# 同 cues:页面 qaText 返回值形状不对 / 契约缺失 / 浏览器起不来都属于"要改页面
|
|
2400
|
+
# 或环境",给 exit 2 + 可读原因;不再让一个 traceback 决定门禁红绿。
|
|
2401
|
+
print("[错误] %s" % exc, flush=True)
|
|
2402
|
+
return 2
|
|
1356
2403
|
print("[textqa] 重叠 %d、出画 %d、一闪而过 %d、从没露面 %d"
|
|
1357
2404
|
% (len(result.get("overlaps") or []), len(result.get("offscreen") or []),
|
|
1358
2405
|
len(result.get("brief") or []), len(result.get("never") or [])), flush=True)
|