@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +951 -0
  2. package/README.md +51 -8
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/index.js +4 -1
  5. package/package.json +5 -3
  6. package/schemas/film.schema.json +162 -2
  7. package/skills/video-studio/SKILL.md +211 -11
  8. package/skills/video-studio/assets/golden_frames.json +65 -0
  9. package/skills/video-studio/assets/web/engine.js +63 -2
  10. package/skills/video-studio/assets/web/film.js +224 -101
  11. package/skills/video-studio/assets/web/index.html +4 -2
  12. package/skills/video-studio/assets/web/qa_text.js +36 -8
  13. package/skills/video-studio/references/audio-variation.md +4 -3
  14. package/skills/video-studio/references/audio.md +2 -2
  15. package/skills/video-studio/references/change-impact.md +1 -1
  16. package/skills/video-studio/references/quality-gates.md +25 -3
  17. package/skills/video-studio/references/style-packs.md +7 -3
  18. package/skills/video-studio/references/v08-quality.md +256 -0
  19. package/skills/video-studio/references/web-engine.md +19 -2
  20. package/skills/video-studio/references/word-timeline.md +147 -0
  21. package/skills/video-studio/requirements.txt +14 -0
  22. package/skills/video-studio/scripts/filmcheck.py +121 -0
  23. package/skills/video-studio/scripts/filmkit.py +521 -38
  24. package/skills/video-studio/scripts/gates.py +88 -4
  25. package/skills/video-studio/scripts/preflight.py +774 -0
  26. package/skills/video-studio/scripts/qa_frames.py +49 -8
  27. package/skills/video-studio/scripts/qaasr.py +1563 -0
  28. package/skills/video-studio/scripts/qavmaf.py +1442 -0
  29. package/skills/video-studio/scripts/render.py +719 -32
  30. package/skills/video-studio/scripts/scorekit.py +16 -30
  31. package/skills/video-studio/scripts/selfcheck.py +274 -0
  32. package/skills/video-studio/scripts/sfxkit.py +130 -14
  33. package/skills/video-studio/scripts/stagewatch.py +19 -6
  34. package/skills/video-studio/scripts/stylekit.py +43 -12
  35. package/skills/video-studio/scripts/voicekit.py +15 -7
  36. package/skills/video-studio/scripts/webengine.py +1119 -72
  37. package/tools/ci_check.py +171 -31
  38. package/tools/estimate.py +130 -19
  39. package/tools/golden_frames.py +664 -0
@@ -1,10 +1,25 @@
1
1
  # -*- coding: utf-8 -*-
2
- """webengine.py — 浏览器逐帧渲染引擎(v0.3.0)。
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.3.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=1080, log=False, deterministic=None,
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
- 视口固定 1920x1080 / devicePixelRatio=1,截图与画布 1:1 像素。
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
- page = await browser.new_page(viewport={"width": int(width), "height": int(height)}, device_scale_factor=1)
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
- except Exception:
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
- fps = await page_number(page, "FPS", default=FPS)
310
- return duration, fps
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) -> list:
326
- """截取指定时刻的静帧(PNG);多帧时额外拼一张联系表。返回帧 PNG 路径列表。"""
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) -> list:
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, W, H, deterministic=deterministic)
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"] = round(float(cue["t"]), 3)
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"] = round(float(ev["t"]), 3)
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) -> dict:
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, W, H, deterministic=deterministic)
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
- cues = await page.evaluate("window.getCues ? window.getCues() : []")
418
- events = await page.evaluate("window.getEvents ? window.getEvents() : []")
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": W,
429
- "height": H,
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
- return out.parent / "chunks" / ("%s_%g-%g" % (out.stem, float(from_s), float(to_s)))
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, W, H, k=k, deterministic=deterministic)
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, fps = await _page_meta_async(url, deterministic=deterministic)
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(float(chunk_s) * fps)))
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 块,work=%s"
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
- def qa_text(html_path, t0=0.0, t1=None, step=0.2, deterministic=None) -> dict:
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
- return asyncio.run(_qa_text_async(html_path, t0, t1, step, deterministic))
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) -> dict:
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, W, H, deterministic=deterministic)
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
- if not isinstance(result, dict):
758
- raise RuntimeError("页面 qaText 返回值不是对象:%r" % (result,))
759
- return result
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.3.0):无头浏览器逐帧渲染 / 确定性 / cue / 文字质检")
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.3.0):Canvas 页面 → CDP 截图 → ffmpeg → mp4",
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="store_true",
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="store_true")
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="store_true",
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="store_true")
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(args.html)[0]
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
- snap(args.html, times, out_dir, tag=args.tag, deterministic=args.deterministic)
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
- data = export_cues(args.html, deterministic=args.deterministic)
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
- out = render_full(
1345
- args.html, args.out, args.duration,
1346
- workers=args.workers, chunk_s=args.chunk_s, resume=args.resume,
1347
- from_s=args.t_from, to_s=args.t_to, crf=args.crf, preset=args.preset,
1348
- deterministic=args.deterministic,
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
- result = qa_text(args.html, args.t0, args.t1, args.step,
1355
- deterministic=args.deterministic)
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)